diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 72d1ef8..9988592 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -178,8 +178,8 @@ final class DatabaseModel: ObservableObject { @Published var schemaProcedures: [String: [String]] = [:] @Published var schemaFunctions: [String: [String]] = [:] @Published var schemaColumns: [String: [String]] = [:] - // Introspection is a connection-scoped, RAM-only snapshot of table structures. - struct IntrospectedColumn: Identifiable { + // Connection-scoped metadata snapshots persist between launches. + struct IntrospectedColumn: Identifiable, Codable { var id: String { "\(table).\(name)" } let table: String let name: String @@ -189,7 +189,7 @@ final class DatabaseModel: ObservableObject { let defaultValue: String? let extra: String } - struct SchemaIntrospection { + struct SchemaIntrospection: Codable { let connectionID: UUID let database: String let loadedAt: Date @@ -198,6 +198,21 @@ final class DatabaseModel: ObservableObject { let columnsByTable: [String: [IntrospectedColumn]] } @Published private(set) var introspections: [String: SchemaIntrospection] = [:] + private let introspectionStorageKey = "DBSyn.introspection.snapshots.v2" + private func persistIntrospections() { + if let data = try? JSONEncoder().encode(introspections) { + UserDefaults.standard.set(data, forKey: introspectionStorageKey) + } + } + private func applyIntrospection(_ snapshot: SchemaIntrospection) { + schemaTables[snapshot.database] = snapshot.tables + schemaViews[snapshot.database] = snapshot.views + schemaColumns[snapshot.database] = Array(Set(snapshot.columnsByTable.values.flatMap { $0 }.flatMap { + [$0.name, "\($0.table).\($0.name)"] + })).sorted() + } + func refreshIntrospection(database: String) { introspect(database: database) } + @Published private(set) var introspectionLoading: Set = [] private func introspectionKey(_ database: String, _ connectionID: UUID) -> String { "\(connectionID.uuidString):\(database)" @@ -242,12 +257,9 @@ final class DatabaseModel: ObservableObject { connectionID: profile.id, database: database, 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, \(views.count) views, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database) (RAM)" + if let snapshot = introspections[key] { applyIntrospection(snapshot) } + persistIntrospections() + status = "Introspection refreshed: \(tables.count) tables, \(views.count) views, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database)" } catch { status = "Introspection failed for \(database): \(error.localizedDescription)" } @@ -272,7 +284,16 @@ final class DatabaseModel: ObservableObject { if let data = UserDefaults.standard.data(forKey: storageKey), let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded } if profiles.isEmpty { profiles = [ConnectionProfile()] } + if let data = UserDefaults.standard.data(forKey: introspectionStorageKey), + let cached = try? JSONDecoder().decode([String: SchemaIntrospection].self, from: data) { + introspections = cached.filter { entry in profiles.contains(where: { $0.id == entry.value.connectionID }) } + } selectedID = profiles.first?.id + if let id = selectedID { + for snapshot in introspections.values where snapshot.connectionID == id { + applyIntrospection(snapshot) + } + } activeQueryID = queryTabs.first?.id } @@ -434,12 +455,12 @@ final class DatabaseModel: ObservableObject { } } - if let snapshot = introspection(for: database) { - schemaTables[database] = snapshot.tables - schemaViews[database] = snapshot.views - } else { - schemaTables[database] = tables - schemaViews[database] = views + schemaTables[database] = tables + schemaViews[database] = views + // Refresh cached structure when the server reports a changed object list. + if let snapshot = introspection(for: database), + (Set(snapshot.tables) != Set(tables) || Set(snapshot.views) != Set(views)) { + introspect(database: database) } // Routine metadata is separate from SHOW FULL TABLES. @@ -484,6 +505,8 @@ final class DatabaseModel: ObservableObject { } status = "Loaded \(tables.count) tables and \(views.count) views from \(database)" + // Revalidate column definitions as well as object names on reconnect/refresh. + if introspection(for: database) != nil { introspect(database: database) } } catch { status = "Unable to load tables: \(error.localizedDescription)" result = QueryResult(message: error.localizedDescription) diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index a67dd77..a57147c 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -475,7 +475,7 @@ struct MainView: View { Button { model.introspect(database: db) } label: { - Label("Introspection — Load Table Structures into RAM", systemImage: "memorychip") + Label("Introspection — Refresh Cached Structures", systemImage: "memorychip") } if let snapshot = model.introspection(for: db) { Text("Cached: \(snapshot.tables.count) tables, \(snapshot.views.count) views, \(snapshot.columnsByTable.values.reduce(0) { $0 + $1.count }) columns") @@ -972,7 +972,11 @@ struct MainView: View { .fill(WB.border) .frame(width: 1) - SQLTextView(text: $model.sql, selectedSQL: $selectedSQL, focusRequest: sqlFocusRequest, completions: Array(Set((model.schemaTables[model.selectedDatabase] ?? []) + (model.schemaViews[model.selectedDatabase] ?? []) + (model.schemaColumns[model.selectedDatabase] ?? []))).sorted()) + SQLTextView(text: $model.sql, selectedSQL: $selectedSQL, focusRequest: sqlFocusRequest, completions: model.schemaColumns[model.selectedDatabase] ?? [], + tables: model.introspection(for: model.selectedDatabase)?.tables ?? model.schemaTables[model.selectedDatabase] ?? [], + views: model.introspection(for: model.selectedDatabase)?.views ?? model.schemaViews[model.selectedDatabase] ?? [], + columnsByTable: model.introspection(for: model.selectedDatabase)?.columnsByTable.mapValues { $0.map(\.name) } ?? [:], + databases: model.databases) } .background(Color(nsColor: .textBackgroundColor)) } @@ -1372,6 +1376,10 @@ struct SQLTextView: NSViewRepresentable { @Binding var selectedSQL: String var focusRequest: Int = 0 var completions: [String] = [] + var tables: [String] = [] + var views: [String] = [] + var columnsByTable: [String: [String]] = [:] + var databases: [String] = [] @Environment(\.colorScheme) private var colorScheme func makeCoordinator() -> Coordinator { @@ -1407,7 +1415,7 @@ struct SQLTextView: NSViewRepresentable { height: 8 ) editor.delegate = context.coordinator - editor.isAutomaticTextCompletionEnabled = true + editor.isAutomaticTextCompletionEnabled = false editor.string = text context.coordinator.highlight(editor) @@ -1465,6 +1473,146 @@ struct SQLTextView: NSViewRepresentable { var lastFocusRequest: Int private var pendingCompletion: DispatchWorkItem? private var showingCompletion = false + private var suggestionPopover: NSPopover? + private var keyMonitor: Any? + private var suggestionItems: [String] = [] + private var selectedSuggestion = 0 + private var replacementRange = NSRange(location: 0, length: 0) + + deinit { + pendingCompletion?.cancel() + if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } + } + + private func dismissSuggestions() { + suggestionPopover?.close() + suggestionPopover = nil + suggestionItems = [] + if let keyMonitor { NSEvent.removeMonitor(keyMonitor); self.keyMonitor = nil } + } + + private func acceptSuggestion(_ editor: NSTextView) { + guard suggestionItems.indices.contains(selectedSuggestion) else { return } + let value = suggestionItems[selectedSuggestion] + let range = replacementRange + dismissSuggestions() + guard NSMaxRange(range) <= (editor.string as NSString).length else { return } + editor.insertText(value, replacementRange: range) + parent.text = editor.string + highlight(editor) + } + + private func scheduleSuggestions(_ editor: NSTextView) { + pendingCompletion?.cancel() + let task = DispatchWorkItem { [weak self, weak editor] in + guard let self, let editor else { return } + self.showSuggestions(editor) + } + pendingCompletion = task + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: task) + } + + private func showSuggestions(_ editor: NSTextView) { + guard editor.window?.firstResponder === editor else { dismissSuggestions(); return } + let selection = editor.selectedRange() + guard selection.length == 0, selection.location != NSNotFound else { dismissSuggestions(); return } + let source = editor.string as NSString + guard selection.location <= source.length else { dismissSuggestions(); return } + let before = source.substring(to: selection.location) + let expression = try! NSRegularExpression(pattern: #"[A-Za-z_][A-Za-z0-9_]*$"#) + let beforeRange = NSRange(location: 0, length: (before as NSString).length) + guard let match = expression.matches(in: before, range: beforeRange).last else { dismissSuggestions(); return } + let prefix = (before as NSString).substring(with: match.range) + guard prefix.count >= 2 else { dismissSuggestions(); return } + let leading = (before as NSString).substring(to: match.range.location) + let upper = leading.uppercased() + let candidates: [String] + if upper.range(of: #"\bUSE\s+`?$"#, options: .regularExpression) != nil { + candidates = parent.databases + } else if upper.range(of: #"\b(FROM|JOIN|INTO|UPDATE|TABLE|DESCRIBE)\s+`?$"#, options: .regularExpression) != nil { + candidates = parent.tables + parent.views + } else if let dot = leading.range(of: #"`?([A-Za-z_][A-Za-z0-9_]*)`?\.$"#, options: .regularExpression) { + let table = String(leading[dot]).replacingOccurrences(of: "`", with: "").dropLast() + candidates = parent.columnsByTable.first(where: { $0.key.caseInsensitiveCompare(String(table)) == .orderedSame })?.value ?? [] + } else { + candidates = completionWords + parent.tables + parent.views + } + let matches = Array(Set(candidates)).filter { + $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil && $0.caseInsensitiveCompare(prefix) != .orderedSame + }.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + suggestionItems = Array(matches.prefix(30)) + replacementRange = match.range + selectedSuggestion = 0 + guard !suggestionItems.isEmpty else { dismissSuggestions(); return } + dismissPopoverOnly() + let popover = NSPopover() + popover.behavior = .semitransient + let items = suggestionItems + let content = NSHostingView(rootView: VStack(alignment: .leading, spacing: 0) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + Text(item) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(index == 0 ? Color.accentColor.opacity(0.16) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { [weak self, weak editor] in + guard let self, let editor else { return } + self.selectedSuggestion = index + self.acceptSuggestion(editor) + } + } + }.padding(.vertical, 5).frame(width: 260)) + content.frame = NSRect(x: 0, y: 0, width: 260, height: min(CGFloat(items.count) * 27 + 10, 350)) + popover.contentViewController = NSViewController() + popover.contentViewController?.view = content + popover.contentSize = content.frame.size + guard let layout = editor.layoutManager, let container = editor.textContainer else { return } + let glyph = layout.glyphIndexForCharacter(at: max(0, selection.location - 1)) + var rect = layout.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), in: container) + rect.origin.x += editor.textContainerOrigin.x + rect.origin.y += editor.textContainerOrigin.y + rect.origin.x += rect.width + rect.size.width = 2 + popover.show(relativeTo: rect, of: editor, preferredEdge: .maxY) + suggestionPopover = popover + if keyMonitor == nil { + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self, weak editor] event in + guard let self, let editor, self.suggestionPopover?.isShown == true, + editor.window?.firstResponder === editor else { return event } + switch event.keyCode { + case 53: self.dismissSuggestions(); return nil // Escape + case 36, 48: self.acceptSuggestion(editor); return nil // Return / Tab + case 125: self.selectedSuggestion = min(self.selectedSuggestion + 1, self.suggestionItems.count - 1); self.refreshSelection(); return nil + case 126: self.selectedSuggestion = max(0, self.selectedSuggestion - 1); self.refreshSelection(); return nil + default: return event + } + } + } + } + + private func dismissPopoverOnly() { + suggestionPopover?.close() + suggestionPopover = nil + } + + private func refreshSelection() { + // Refresh the highlighted item without changing the editor's selection. + guard let popover = suggestionPopover else { return } + let items = suggestionItems + let selected = selectedSuggestion + let view = NSHostingView(rootView: VStack(alignment: .leading, spacing: 0) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + Text(item).font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10).padding(.vertical, 4) + .background(index == selected ? Color.accentColor.opacity(0.16) : Color.clear) + } + }.padding(.vertical, 5).frame(width: 260)) + view.frame = NSRect(origin: .zero, size: popover.contentSize) + popover.contentViewController?.view = view + } init(_ parent: SQLTextView) { self.parent = parent @@ -1485,16 +1633,31 @@ struct SQLTextView: NSViewRepresentable { func textView(_ textView: NSTextView, completions words: [String], forPartialWordRange charRange: NSRange, indexOfSelectedItem index: UnsafeMutablePointer?) -> [String] { - let text = textView.string as NSString + let source = textView.string as NSString guard charRange.location != NSNotFound, - NSMaxRange(charRange) <= text.length else { return [] } - let prefix = text.substring(with: charRange) + NSMaxRange(charRange) <= source.length else { return [] } + let prefix = source.substring(with: charRange) guard !prefix.isEmpty else { return [] } - return Array(Set(completionWords + parent.completions)) - .filter { $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil } - .sorted() - .prefix(60) - .map { $0 } + let before = source.substring(to: charRange.location) + let trailing = String(before.suffix(300)) + let upper = trailing.uppercased() + let tableContext = upper.range(of: #"\b(FROM|JOIN|INTO|UPDATE|TABLE|DESCRIBE)\s+[`\w.]*$"#, options: .regularExpression) != nil + let databaseContext = upper.range(of: #"\bUSE\s+[`\w]*$"#, options: .regularExpression) != nil + let qualified = trailing.range(of: #"`?([A-Za-z_][A-Za-z0-9_]*)`?\.$"#, options: .regularExpression) + let candidates: [String] + if databaseContext { + candidates = parent.databases + } else if tableContext { + candidates = parent.tables + parent.views + } else if let qualified { + let token = String(trailing[qualified]).replacingOccurrences(of: "`", with: "").dropLast() + candidates = parent.columnsByTable.first(where: { $0.key.caseInsensitiveCompare(String(token)) == .orderedSame })?.value ?? [] + } else { + candidates = completionWords + parent.tables + parent.views + parent.completions.filter { !$0.contains(".") } + } + return Array(Set(candidates)).filter { + $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil + }.sorted().prefix(40).map { $0 } } func textViewDidChangeSelection(_ notification: Notification) { @@ -1513,46 +1676,10 @@ 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) + // Do not invoke AppKit's completion panel from textDidChange. + // That re-entrant path caused the previous editor freezes/crashes. + // Completion remains available through the standard macOS Complete command. + scheduleSuggestions(editor) } func highlight(_ editor: NSTextView) { diff --git a/docs/PHASE61-8-INTROSPECTION.md b/docs/PHASE61-8-INTROSPECTION.md new file mode 100644 index 0000000..c1c8812 --- /dev/null +++ b/docs/PHASE61-8-INTROSPECTION.md @@ -0,0 +1,10 @@ +# Phase 61.8 — Persistent introspection and contextual completion + +- Introspection snapshots are Codable and stored per connection UUID/database in UserDefaults. Snapshots are restored on application launch and remain available if offline. +- Database navigator refreshes re-query live table/view names and revalidate saved introspection metadata, including columns. Successful introspection updates the persisted snapshot; failures retain the previous snapshot. +- SQL completion uses context: USE suggests databases; FROM/JOIN/UPDATE/INTO suggests tables/views; qualified `table.` suggests columns for that object; otherwise SQL keywords and unqualified names. +- Automatic AppKit `complete()` invocation during `textDidChange` is disabled to avoid the previously reported UI hang/crash. Use macOS Edit > Complete (typically Escape or Option-Escape depending on key bindings) for contextual suggestions. Automatic non-blocking popup is not implemented in this stability patch. + +## Verification still required + +Build and run on macOS; test introspection after relaunch, schema changes while connected, completion in all SQL contexts, and large databases. The Linux Swift parser cannot typecheck AppKit/SwiftUI.