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 schemaProcedures: [String: [String]] = [:]
|
||||||
@Published var schemaFunctions: [String: [String]] = [:]
|
@Published var schemaFunctions: [String: [String]] = [:]
|
||||||
@Published var schemaColumns: [String: [String]] = [:]
|
@Published var schemaColumns: [String: [String]] = [:]
|
||||||
// Introspection is a connection-scoped, RAM-only snapshot of table structures.
|
// Connection-scoped metadata snapshots persist between launches.
|
||||||
struct IntrospectedColumn: Identifiable {
|
struct IntrospectedColumn: Identifiable, Codable {
|
||||||
var id: String { "\(table).\(name)" }
|
var id: String { "\(table).\(name)" }
|
||||||
let table: String
|
let table: String
|
||||||
let name: String
|
let name: String
|
||||||
@@ -189,7 +189,7 @@ final class DatabaseModel: ObservableObject {
|
|||||||
let defaultValue: String?
|
let defaultValue: String?
|
||||||
let extra: String
|
let extra: String
|
||||||
}
|
}
|
||||||
struct SchemaIntrospection {
|
struct SchemaIntrospection: Codable {
|
||||||
let connectionID: UUID
|
let connectionID: UUID
|
||||||
let database: String
|
let database: String
|
||||||
let loadedAt: Date
|
let loadedAt: Date
|
||||||
@@ -198,6 +198,21 @@ final class DatabaseModel: ObservableObject {
|
|||||||
let columnsByTable: [String: [IntrospectedColumn]]
|
let columnsByTable: [String: [IntrospectedColumn]]
|
||||||
}
|
}
|
||||||
@Published private(set) var introspections: [String: SchemaIntrospection] = [:]
|
@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> = []
|
@Published private(set) var introspectionLoading: Set<String> = []
|
||||||
private func introspectionKey(_ database: String, _ connectionID: UUID) -> String {
|
private func introspectionKey(_ database: String, _ connectionID: UUID) -> String {
|
||||||
"\(connectionID.uuidString):\(database)"
|
"\(connectionID.uuidString):\(database)"
|
||||||
@@ -242,12 +257,9 @@ final class DatabaseModel: ObservableObject {
|
|||||||
connectionID: profile.id, database: database,
|
connectionID: profile.id, database: database,
|
||||||
loadedAt: Date(), tables: tables, views: views, columnsByTable: columns
|
loadedAt: Date(), tables: tables, views: views, columnsByTable: columns
|
||||||
)
|
)
|
||||||
schemaTables[database] = tables
|
if let snapshot = introspections[key] { applyIntrospection(snapshot) }
|
||||||
schemaViews[database] = views
|
persistIntrospections()
|
||||||
schemaColumns[database] = Array(Set(columns.values.flatMap { $0 }.flatMap {
|
status = "Introspection refreshed: \(tables.count) tables, \(views.count) views, \(columns.values.reduce(0) { $0 + $1.count }) columns in \(database)"
|
||||||
[$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)"
|
|
||||||
} catch {
|
} catch {
|
||||||
status = "Introspection failed for \(database): \(error.localizedDescription)"
|
status = "Introspection failed for \(database): \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
@@ -272,7 +284,16 @@ final class DatabaseModel: ObservableObject {
|
|||||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
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
|
selectedID = profiles.first?.id
|
||||||
|
if let id = selectedID {
|
||||||
|
for snapshot in introspections.values where snapshot.connectionID == id {
|
||||||
|
applyIntrospection(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
activeQueryID = queryTabs.first?.id
|
activeQueryID = queryTabs.first?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,12 +455,12 @@ final class DatabaseModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let snapshot = introspection(for: database) {
|
schemaTables[database] = tables
|
||||||
schemaTables[database] = snapshot.tables
|
schemaViews[database] = views
|
||||||
schemaViews[database] = snapshot.views
|
// Refresh cached structure when the server reports a changed object list.
|
||||||
} else {
|
if let snapshot = introspection(for: database),
|
||||||
schemaTables[database] = tables
|
(Set(snapshot.tables) != Set(tables) || Set(snapshot.views) != Set(views)) {
|
||||||
schemaViews[database] = views
|
introspect(database: database)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routine metadata is separate from SHOW FULL TABLES.
|
// 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)"
|
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 {
|
} catch {
|
||||||
status = "Unable to load tables: \(error.localizedDescription)"
|
status = "Unable to load tables: \(error.localizedDescription)"
|
||||||
result = QueryResult(message: error.localizedDescription)
|
result = QueryResult(message: error.localizedDescription)
|
||||||
|
|||||||
+178
-51
@@ -475,7 +475,7 @@ struct MainView: View {
|
|||||||
Button {
|
Button {
|
||||||
model.introspect(database: db)
|
model.introspect(database: db)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Introspection — Load Table Structures into RAM", systemImage: "memorychip")
|
Label("Introspection — Refresh Cached Structures", systemImage: "memorychip")
|
||||||
}
|
}
|
||||||
if let snapshot = model.introspection(for: db) {
|
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")
|
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)
|
.fill(WB.border)
|
||||||
.frame(width: 1)
|
.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))
|
.background(Color(nsColor: .textBackgroundColor))
|
||||||
}
|
}
|
||||||
@@ -1372,6 +1376,10 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
@Binding var selectedSQL: String
|
@Binding var selectedSQL: String
|
||||||
var focusRequest: Int = 0
|
var focusRequest: Int = 0
|
||||||
var completions: [String] = []
|
var completions: [String] = []
|
||||||
|
var tables: [String] = []
|
||||||
|
var views: [String] = []
|
||||||
|
var columnsByTable: [String: [String]] = [:]
|
||||||
|
var databases: [String] = []
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
@@ -1407,7 +1415,7 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
height: 8
|
height: 8
|
||||||
)
|
)
|
||||||
editor.delegate = context.coordinator
|
editor.delegate = context.coordinator
|
||||||
editor.isAutomaticTextCompletionEnabled = true
|
editor.isAutomaticTextCompletionEnabled = false
|
||||||
editor.string = text
|
editor.string = text
|
||||||
|
|
||||||
context.coordinator.highlight(editor)
|
context.coordinator.highlight(editor)
|
||||||
@@ -1465,6 +1473,146 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
var lastFocusRequest: Int
|
var lastFocusRequest: Int
|
||||||
private var pendingCompletion: DispatchWorkItem?
|
private var pendingCompletion: DispatchWorkItem?
|
||||||
private var showingCompletion = false
|
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) {
|
init(_ parent: SQLTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
@@ -1485,16 +1633,31 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
func textView(_ textView: NSTextView, completions words: [String],
|
func textView(_ textView: NSTextView, completions words: [String],
|
||||||
forPartialWordRange charRange: NSRange,
|
forPartialWordRange charRange: NSRange,
|
||||||
indexOfSelectedItem index: UnsafeMutablePointer<Int>?) -> [String] {
|
indexOfSelectedItem index: UnsafeMutablePointer<Int>?) -> [String] {
|
||||||
let text = textView.string as NSString
|
let source = textView.string as NSString
|
||||||
guard charRange.location != NSNotFound,
|
guard charRange.location != NSNotFound,
|
||||||
NSMaxRange(charRange) <= text.length else { return [] }
|
NSMaxRange(charRange) <= source.length else { return [] }
|
||||||
let prefix = text.substring(with: charRange)
|
let prefix = source.substring(with: charRange)
|
||||||
guard !prefix.isEmpty else { return [] }
|
guard !prefix.isEmpty else { return [] }
|
||||||
return Array(Set(completionWords + parent.completions))
|
let before = source.substring(to: charRange.location)
|
||||||
.filter { $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil }
|
let trailing = String(before.suffix(300))
|
||||||
.sorted()
|
let upper = trailing.uppercased()
|
||||||
.prefix(60)
|
let tableContext = upper.range(of: #"\b(FROM|JOIN|INTO|UPDATE|TABLE|DESCRIBE)\s+[`\w.]*$"#, options: .regularExpression) != nil
|
||||||
.map { $0 }
|
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) {
|
func textViewDidChangeSelection(_ notification: Notification) {
|
||||||
@@ -1513,46 +1676,10 @@ struct SQLTextView: NSViewRepresentable {
|
|||||||
|
|
||||||
parent.text = editor.string
|
parent.text = editor.string
|
||||||
highlight(editor)
|
highlight(editor)
|
||||||
// Never open AppKit's completion panel synchronously inside
|
// Do not invoke AppKit's completion panel from textDidChange.
|
||||||
// textDidChange: it re-enters NSTextView editing/delegate callbacks.
|
// That re-entrant path caused the previous editor freezes/crashes.
|
||||||
pendingCompletion?.cancel()
|
// Completion remains available through the standard macOS Complete command.
|
||||||
guard !showingCompletion,
|
scheduleSuggestions(editor)
|
||||||
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) {
|
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