diff --git a/DBSyn/App.swift b/DBSyn/App.swift index dfe2489..3b28257 100644 --- a/DBSyn/App.swift +++ b/DBSyn/App.swift @@ -224,13 +224,14 @@ struct WorkbenchSettingsView: View { .font(.system(size: 12)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 190) + .padding(.leading, 186) Divider() .padding(.vertical, 12) Text("Examples") .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) Text( "500 rows: Faster browsing and lower memory usage.\n" + @@ -284,10 +285,22 @@ struct WorkbenchSettingsView: View { Text("Light").tag("light") Text("Dark").tag("dark") } + .pickerStyle(.segmented) .labelsHidden() - .frame(width: 170) + .accessibilityLabel("Application appearance") + .frame(width: 260, alignment: .leading) } + Text(appearance == "system" + ? "DBSyn follows your Mac’s appearance setting." + : appearance == "light" + ? "DBSyn always uses Light appearance." + : "DBSyn always uses Dark appearance.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 186) + settingsRow("Navigator") { Toggle( "Automatically expand table folders", @@ -327,7 +340,7 @@ struct WorkbenchSettingsView: View { Text(title) .font(.system(size: 13)) - .frame(width: 170, alignment: .trailing) + .frame(width: 170, alignment: .leading) content() diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 3bb589f..e3735b8 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -455,27 +455,42 @@ final class DatabaseModel: ObservableObject { } private func execute(_ statement: String, listingDatabases: Bool) { guard let profile = selected else { status = "Select a connection"; return } - guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let statements = listingDatabases ? [statement] : SQLStatementSplitter.split(statement) + guard !statements.isEmpty else { return } isRunning = true if !listingDatabases { queryDurationText = "Running…" } let queryStarted = Date() - status = "Connecting…" - let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase) + status = "Executing…" let password = PasswordStore.read(profile.id) Task { + var activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase) do { - let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement) + var lastResult = QueryResult() + for item in statements { + // Each query is sent separately: COM_QUERY accepts one SQL + // statement, not an entire semicolon-separated editor buffer. + let parsed = try await NativeMySQLRunner.run( + profile: profile, database: activeDB, password: password, sql: item + ) + lastResult = parsed + if let usedSchema = SQLStatementSplitter.usedDatabase(item) { + activeDB = usedSchema + selectedDatabase = usedSchema + // Metadata is cached in memory for native SQL completion. + loadTables(database: usedSchema) + } + } if listingDatabases { - databases = parsed.rows.compactMap { $0.first } + databases = lastResult.rows.compactMap { $0.first } status = "Connected to \(profile.name)" } else { - result = parsed + result = lastResult if let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) { queryTabs[index].sql = sql - queryTabs[index].result = parsed + queryTabs[index].result = lastResult } - status = "Query completed (\(parsed.rows.count) rows)" + status = "Executed \(statements.count) statement(s) (\(lastResult.rows.count) rows)" recordQuery(statement, profile: profile, database: activeDB, succeeded: true, detail: status) } } catch { @@ -491,9 +506,72 @@ final class DatabaseModel: ObservableObject { isRunning = false } } + } +// Splits editor scripts without splitting semicolons inside quoted values or comments. +private enum SQLStatementSplitter { + static func split(_ source: String) -> [String] { + let chars = Array(source) + var output: [String] = [] + var current = "" + var state = 0 // 0 normal, 1 single quote, 2 double quote, 3 identifier, 4 line comment, 5 block comment + var i = 0 + while i < chars.count { + let c = chars[i] + let next = i + 1 < chars.count ? chars[i + 1] : "\0" + if state == 4 { + if c == "\n" || c == "\r" { state = 0; current.append(" ") } + i += 1; continue + } + if state == 5 { + if c == "*" && next == "/" { state = 0; i += 2; current.append(" ") } + else { i += 1 } + continue + } + if state == 0 { + if c == "#" || (c == "-" && next == "-" && (i + 2 == chars.count || chars[i + 2].isWhitespace)) { + state = 4; i += c == "#" ? 1 : 2; continue + } + if c == "/" && next == "*" { state = 5; i += 2; continue } + if c == "'" { state = 1 } + else if c == "\"" { state = 2 } + else if c == "`" { state = 3 } + else if c == ";" { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { output.append(trimmed) } + current = ""; i += 1; continue + } + } else { + if c == "\\" && state != 3 && i + 1 < chars.count { + current.append(c); current.append(next); i += 2; continue + } + if (state == 1 && c == "'") || (state == 2 && c == "\"") || (state == 3 && c == "`") { + if next == c { current.append(c); current.append(next); i += 2; continue } + state = 0 + } + } + current.append(c); i += 1 + } + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { output.append(trimmed) } + return output + } + + static func usedDatabase(_ statement: String) -> String? { + let pattern = #"(?is)^\s*USE\s+(?:`((?:``|[^`])+)`|([A-Za-z0-9_$]+))\s*$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: statement, range: NSRange(statement.startIndex..., in: statement)) else { return nil } + for index in [1, 2] where match.range(at: index).location != NSNotFound { + if let range = Range(match.range(at: index), in: statement) { + return String(statement[range]).replacingOccurrences(of: "``", with: "`") + } + } + return nil + } +} + // MARK: - SQL Keyword Formatter enum SQLFormatter { diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index cd3f067..dbfb0fe 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -1546,6 +1546,14 @@ struct SQLTextView: NSViewRepresentable { } } + // MySQL # comments are muted, including the entire line. + if let comments = try? NSRegularExpression(pattern: #"(?m)^[ \t]*#.*$"#) { + for match in comments.matches(in: editor.string, range: range) { + storage.addAttribute(.foregroundColor, + value: NSColor.secondaryLabelColor, + range: match.range) + } + } storage.endEditing() } } diff --git a/docs/PHASE61-2-APPEARANCE-SETTINGS.md b/docs/PHASE61-2-APPEARANCE-SETTINGS.md new file mode 100644 index 0000000..af7847f --- /dev/null +++ b/docs/PHASE61-2-APPEARANCE-SETTINGS.md @@ -0,0 +1,3 @@ +# Phase 61.2 — Appearance selector + +Settings → General now has a prominent segmented control for System, Light, and Dark. Changes apply immediately and persist through `preferences.appearance`.