added typeaheead and introspetion

This commit is contained in:
Mark Gregory
2026-10-18 16:39:55 +01:00
parent 1ca16eb69a
commit b6123a0043
3 changed files with 226 additions and 66 deletions
+38 -15
View File
@@ -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<String> = []
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)
+178 -51
View File
@@ -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<Int>?) -> [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) {
+10
View File
@@ -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.