From 1ca16eb69ae08da684b5c9ae9080f61f596bcc99 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Sun, 18 Oct 2026 16:23:03 +0100 Subject: [PATCH] added typeaheead and introspetion --- DBSyn/DatabaseModel.swift | 24 +++++++++++++------- DBSyn/MainView.swift | 47 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 7425594..72d1ef8 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -194,6 +194,7 @@ final class DatabaseModel: ObservableObject { let database: String let loadedAt: Date let tables: [String] + let views: [String] let columnsByTable: [String: [IntrospectedColumn]] } @Published private(set) var introspections: [String: SchemaIntrospection] = [:] @@ -218,13 +219,14 @@ final class DatabaseModel: ObservableObject { do { let tableResult = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, - sql: "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '\(escaped)' AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME" + sql: "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME" ) let columnResult = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, sql: "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(escaped)' ORDER BY TABLE_NAME, ORDINAL_POSITION" ) - let tables = tableResult.rows.compactMap { $0.first } + let tables = tableResult.rows.compactMap { $0.count >= 2 && $0[1] == "BASE TABLE" ? $0[0] : nil } + let views = tableResult.rows.compactMap { $0.count >= 2 && $0[1] == "VIEW" ? $0[0] : nil } var columns: [String: [IntrospectedColumn]] = [:] for row in columnResult.rows where row.count >= 7 { let column = IntrospectedColumn( @@ -238,13 +240,14 @@ final class DatabaseModel: ObservableObject { guard profiles.contains(where: { $0.id == profile.id }) else { return } introspections[key] = SchemaIntrospection( connectionID: profile.id, database: database, - loadedAt: Date(), tables: tables, columnsByTable: columns + loadedAt: Date(), tables: tables, views: views, columnsByTable: columns ) schemaTables[database] = tables + schemaViews[database] = views schemaColumns[database] = Array(Set(columns.values.flatMap { $0 }.flatMap { [$0.name, "\($0.table).\($0.name)"] })).sorted() - status = "Introspection complete: \(tables.count) tables, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database) (RAM)" + status = "Introspection complete: \(tables.count) tables, \(views.count) views, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database) (RAM)" } catch { status = "Introspection failed for \(database): \(error.localizedDescription)" } @@ -431,8 +434,13 @@ final class DatabaseModel: ObservableObject { } } - schemaTables[database] = tables - schemaViews[database] = views + if let snapshot = introspection(for: database) { + schemaTables[database] = snapshot.tables + schemaViews[database] = snapshot.views + } else { + schemaTables[database] = tables + schemaViews[database] = views + } // Routine metadata is separate from SHOW FULL TABLES. // Failure (e.g. insufficient INFORMATION_SCHEMA privileges) @@ -470,9 +478,9 @@ final class DatabaseModel: ObservableObject { names.insert(row[1]) names.insert("\(row[0]).\(row[1])") } - schemaColumns[database] = names.sorted() + if introspection(for: database) == nil { schemaColumns[database] = names.sorted() } } catch { - schemaColumns[database] = [] + if introspection(for: database) == nil { schemaColumns[database] = [] } } status = "Loaded \(tables.count) tables and \(views.count) views from \(database)" diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index d9ebf6e..a67dd77 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -478,7 +478,7 @@ struct MainView: View { Label("Introspection — Load Table Structures into RAM", systemImage: "memorychip") } if let snapshot = model.introspection(for: db) { - Text("Cached: \(snapshot.tables.count) tables, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns") + Text("Cached: \(snapshot.tables.count) tables, \(snapshot.views.count) views, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns") } Divider() Button("Set as Default Schema") { model.selectedDatabase = db } @@ -1407,6 +1407,7 @@ struct SQLTextView: NSViewRepresentable { height: 8 ) editor.delegate = context.coordinator + editor.isAutomaticTextCompletionEnabled = true editor.string = text context.coordinator.highlight(editor) @@ -1462,6 +1463,8 @@ struct SQLTextView: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { var parent: SQLTextView var lastFocusRequest: Int + private var pendingCompletion: DispatchWorkItem? + private var showingCompletion = false init(_ parent: SQLTextView) { self.parent = parent @@ -1490,6 +1493,8 @@ struct SQLTextView: NSViewRepresentable { return Array(Set(completionWords + parent.completions)) .filter { $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil } .sorted() + .prefix(60) + .map { $0 } } func textViewDidChangeSelection(_ notification: Notification) { @@ -1508,6 +1513,46 @@ struct SQLTextView: NSViewRepresentable { parent.text = editor.string highlight(editor) + // Never open AppKit's completion panel synchronously inside + // textDidChange: it re-enters NSTextView editing/delegate callbacks. + pendingCompletion?.cancel() + guard !showingCompletion, + let event = NSApp.currentEvent, + event.type == .keyDown, + let characters = event.characters, + characters.rangeOfCharacter(from: CharacterSet.letters.union(.decimalDigits).union(CharacterSet(charactersIn: "_"))) != nil, + !parent.completions.isEmpty else { return } + + let selected = editor.selectedRange() + guard selected.length == 0, selected.location != NSNotFound else { return } + let content = editor.string as NSString + let cursor = min(selected.location, content.length) + var start = cursor + let valid = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + while start > 0 { + let scalar = content.substring(with: NSRange(location: start - 1, length: 1)) + guard scalar.unicodeScalars.allSatisfy({ valid.contains($0) }) else { break } + start -= 1 + } + guard cursor - start >= 2 else { return } + let expectedText = editor.string + let expectedCursor = cursor + let task = DispatchWorkItem { [weak self, weak editor] in + guard let self, let editor, + editor.window?.firstResponder === editor, + editor.string == expectedText, + editor.selectedRange().location == expectedCursor, + editor.selectedRange().length == 0, + !self.showingCompletion else { return } + self.showingCompletion = true + editor.complete(nil) + // AppKit may synchronously notify text changes while opening. + DispatchQueue.main.async { [weak self] in + self?.showingCompletion = false + } + } + pendingCompletion = task + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: task) } func highlight(_ editor: NSTextView) {