added typeaheead and introspetion
This commit is contained in:
+38
-15
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user