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
+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) {