diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da8691f4..41b33c2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Default row sort now applies to the very first table opened after launch, not just tables opened after it. (#1603) - Cancelling a SQLite query no longer races a disconnect happening at the same moment. (#1610) +- Typing in the query editor no longer erases characters or drops focus on each keystroke, a timing-dependent bug most visible on macOS 15. (#1608) +- The autocomplete popup now filters in place as you type instead of closing and reopening on every keystroke. (#1608) ## [0.49.1] - 2026-06-06 diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index fa739a23a..6f60cb88b 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -142,29 +142,27 @@ final class SuggestionViewModel: ObservableObject { position: CursorPosition, close: () -> Void ) { - if itemsRequestTask != nil { + if activeTextView !== textView { itemsRequestTask?.cancel() itemsRequestTask = nil - } - - if activeTextView !== textView { close() return } - guard let newItems = delegate.completionOnCursorMove( + if let newItems = delegate.completionOnCursorMove( textView: textView, cursorPosition: position - ), - !newItems.isEmpty else { - close() + ), !newItems.isEmpty { + items = newItems + selectedIndex = 0 + syntaxHighlightedCache = [:] + notifySelection() return } - items = newItems - selectedIndex = 0 - syntaxHighlightedCache = [:] - notifySelection() + guard itemsRequestTask == nil else { return } + + close() } func didSelect(item: CodeSuggestionEntry) { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index 7f84975a0..cfba799e2 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -156,9 +156,10 @@ extension SourceEditor { } /// Pushes an external binding change down into the text view. The text view's - /// content wins while one of its own edits is still in flight (`lastSyncedText` - /// only trails the binding during the debounce window, when both hold the same - /// pre-edit value), so user typing is never clobbered by a stale binding. + /// content wins while one of its own edits is still in flight: `isUpdateFromTextView` + /// is set the moment the text view mutates, before SwiftUI re-renders, so a render + /// that still carries a stale binding snapshot is skipped entirely and user typing + /// is never clobbered by a stale binding. /// /// Uses `setText` rather than `replaceCharacters` on purpose: `replaceCharacters` /// is the user-edit path. It is gated on `isEditable`, runs mutation filters, and @@ -166,6 +167,7 @@ extension SourceEditor { /// whole-document replacement. `setText` clearing the undo stack matches the /// new-document semantics of that replacement. func syncBindingText(_ newValue: String, controller: TextViewController) { + guard !isUpdateFromTextView else { return } guard newValue != lastSyncedText else { return } textBindingTask?.cancel() isUpdatingFromRepresentable = true diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift new file mode 100644 index 000000000..824a0cacd --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionCursorsUpdatedTests.swift @@ -0,0 +1,137 @@ +import AppKit +@testable import CodeEditSourceEditor +import SwiftUI +import XCTest + +final class SuggestionCursorsUpdatedTests: XCTestCase { + @MainActor + func test_cursorsUpdated_keepsWindowOpenWhileRequestIsInFlight() throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let delegate = FilteringStubDelegate(itemsOnCursorMove: nil) + + model.activeTextView = textViewController + model.delegate = delegate + model.items = [FilterStubEntry(label: "SELECT")] + model.itemsRequestTask = Task { try? await Task.sleep(for: .seconds(10)) } + defer { model.itemsRequestTask?.cancel() } + + var closeCount = 0 + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 1, length: 0)) + ) { closeCount += 1 } + + XCTAssertEqual(closeCount, 0) + XCTAssertNotNil(model.itemsRequestTask) + } + + @MainActor + func test_cursorsUpdated_filtersItemsInPlaceWhenDelegateProvidesThem() throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let filtered: [CodeSuggestionEntry] = [FilterStubEntry(label: "SELECT"), FilterStubEntry(label: "SET")] + let delegate = FilteringStubDelegate(itemsOnCursorMove: filtered) + + model.activeTextView = textViewController + model.delegate = delegate + model.items = [FilterStubEntry(label: "stale")] + model.selectedIndex = 0 + + var closeCount = 0 + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 2, length: 0)) + ) { closeCount += 1 } + + XCTAssertEqual(closeCount, 0) + XCTAssertEqual(model.items.map(\.label), ["SELECT", "SET"]) + XCTAssertEqual(model.selectedIndex, 0) + } + + @MainActor + func test_cursorsUpdated_closesWhenNoItemsAndNoRequestInFlight() throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let delegate = FilteringStubDelegate(itemsOnCursorMove: nil) + + model.activeTextView = textViewController + model.delegate = delegate + model.items = [FilterStubEntry(label: "SELECT")] + model.itemsRequestTask = nil + + var closeCount = 0 + model.cursorsUpdated( + textView: textViewController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 0, length: 0)) + ) { closeCount += 1 } + + XCTAssertEqual(closeCount, 1) + } + + @MainActor + func test_cursorsUpdated_cancelsRequestAndClosesForDifferentTextView() throws { + let model = SuggestionViewModel() + let activeController = Mock.textViewController(theme: Mock.theme()) + let otherController = Mock.textViewController(theme: Mock.theme()) + let delegate = FilteringStubDelegate(itemsOnCursorMove: [FilterStubEntry(label: "SELECT")]) + + model.activeTextView = activeController + model.delegate = delegate + model.itemsRequestTask = Task { try? await Task.sleep(for: .seconds(10)) } + + var closeCount = 0 + model.cursorsUpdated( + textView: otherController, + delegate: delegate, + position: CursorPosition(range: NSRange(location: 0, length: 0)) + ) { closeCount += 1 } + + XCTAssertEqual(closeCount, 1) + XCTAssertNil(model.itemsRequestTask) + } +} + +private final class FilteringStubDelegate: CodeSuggestionDelegate { + private let itemsOnCursorMove: [CodeSuggestionEntry]? + + init(itemsOnCursorMove: [CodeSuggestionEntry]?) { + self.itemsOnCursorMove = itemsOnCursorMove + } + + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition, + isManualTrigger: Bool + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + nil + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + itemsOnCursorMove + } + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) {} +} + +private struct FilterStubEntry: CodeSuggestionEntry { + var label: String + var detail: String? { nil } + var documentation: String? { nil } + var pathComponents: [String]? { nil } + var targetPosition: CursorPosition? { nil } + var sourcePreview: String? { nil } + var image: Image { Image(systemName: "circle") } + var imageColor: Color { .gray } + var deprecated: Bool { false } +} diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift index 92eb52f3e..00d9b7947 100644 --- a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift @@ -122,6 +122,42 @@ final class SourceEditorBindingSyncTests: XCTestCase { } } + @MainActor + func test_syncSkipsStaleBindingSnapshotWhileEditIsInFlight() { + var bound = "" + let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) + controller.textView.setText("s") + + coordinator.textViewDidChangeText( + Notification(name: TextView.textDidChangeNotification, object: controller.textView) + ) + XCTAssertEqual(bound, "s") + XCTAssertTrue(coordinator.isUpdateFromTextView) + + coordinator.syncBindingText("", controller: controller) + + XCTAssertEqual(controller.textView.string, "s") + XCTAssertEqual(coordinator.lastSyncedText, "s") + } + + @MainActor + func test_syncAppliesExternalChangeAfterEditFlagIsCleared() { + var bound = "" + let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) + controller.textView.setText("select 1") + + coordinator.textViewDidChangeText( + Notification(name: TextView.textDidChangeNotification, object: controller.textView) + ) + coordinator.isUpdateFromTextView = false + + bound = "SELECT 1" + coordinator.syncBindingText(bound, controller: controller) + + XCTAssertEqual(controller.textView.string, "SELECT 1") + XCTAssertEqual(coordinator.lastSyncedText, "SELECT 1") + } + @MainActor func test_repeatedSyncWithSameValueLeavesTextViewUntouched() { var bound = "stable" diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index 46f0552ba..16b74c48e 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -55,6 +55,12 @@ final class CompletionEngine { await provider.retrySchemaIfNeeded() } + /// Statement-start keyword items available synchronously, without schema access. + /// Used to seed a filterable completion context before the async fetch completes. + func keywordCompletions() -> [SQLCompletionItem] { + provider.statementStartCompletionItems() + } + /// Completions for a single-table filter expression (a bare WHERE-clause /// fragment such as `id = 1 AND na`). The fragment is completed as the WHERE /// clause it denotes and columns are scoped to `tableName`, so suggestions diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 9b855b50c..a397eba1e 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -449,32 +449,7 @@ final class SQLCompletionProvider { items += await schemaProvider.tableCompletionItems() case .unknown: - if !cachedStatementCompletions.isEmpty { - items = cachedStatementCompletions.map { entry in - SQLCompletionItem( - label: entry.label, - kind: .keyword, - insertText: entry.insertText - ) - } - } else { - items = filterKeywords([ - // DML - "SELECT", "INSERT", "UPDATE", "DELETE", "REPLACE", "MERGE", "UPSERT", - // DDL - "CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", - // Database operations - "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "ANALYZE", - // Transaction control - "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "START TRANSACTION", - // CTEs and advanced - "WITH", "RECURSIVE", - // Database/schema - "USE", "SET", "GRANT", "REVOKE", - // Utility - "CALL", "EXECUTE", "PREPARE" - ]) - } + items = statementStartCompletionItems() items += await schemaProvider.tableCompletionItems() } @@ -532,6 +507,29 @@ final class SQLCompletionProvider { keywords.map { SQLCompletionItem.keyword($0) } } + private static let statementStartKeywords = [ + "SELECT", "INSERT", "UPDATE", "DELETE", "REPLACE", "MERGE", "UPSERT", + "CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", + "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "ANALYZE", + "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "START TRANSACTION", + "WITH", "RECURSIVE", + "USE", "SET", "GRANT", "REVOKE", + "CALL", "EXECUTE", "PREPARE" + ] + + func statementStartCompletionItems() -> [SQLCompletionItem] { + guard cachedStatementCompletions.isEmpty else { + return cachedStatementCompletions.map { entry in + SQLCompletionItem( + label: entry.label, + kind: .keyword, + insertText: entry.insertText + ) + } + } + return filterKeywords(Self.statementStartKeywords) + } + /// Create keyword items with boosted (lower) sort priority private func boostedKeywords(_ keywords: [String], priority: Int) -> [SQLCompletionItem] { keywords.map { kw in diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index c958f76e6..0697a6ffc 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -76,15 +76,18 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { return nil } + seedKeywordContextIfNeeded(textView: textView, cursorPosition: cursorPosition) + // Debounce: wait briefly and check if a newer request arrived debounceGeneration &+= 1 let myGeneration = debounceGeneration try? await Task.sleep(nanoseconds: debounceNanoseconds) guard myGeneration == debounceGeneration else { return nil } + let liveCursorPosition = textView.cursorPositions.first ?? cursorPosition let nsText = (textView.textView.textStorage?.string ?? "") as NSString let docLength = nsText.length - let offset = cursorPosition.range.location + let offset = liveCursorPosition.range.location // Don't show autocomplete right after semicolon or newline if offset > 0 { @@ -148,7 +151,33 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { SQLSuggestionEntry(item: item) } - return (windowPosition: cursorPosition, items: entries) + return (windowPosition: liveCursorPosition, items: entries) + } + + private func seedKeywordContextIfNeeded(textView: TextViewController, cursorPosition: CursorPosition) { + guard currentCompletionContext == nil, let completionEngine else { return } + + let keywordItems = completionEngine.keywordCompletions() + guard !keywordItems.isEmpty else { return } + + let offset = cursorPosition.range.location + guard let nsText = textView.textView.textStorage?.string as NSString?, + offset >= 0, offset <= nsText.length else { return } + + let prefixStart = SQLTokenBoundary.segmentStart(in: nsText, endingAt: offset) + currentCompletionContext = CompletionContext( + items: keywordItems, + replacementRange: NSRange(location: prefixStart, length: offset - prefixStart), + sqlContext: SQLContext( + clauseType: .unknown, + prefix: "", + prefixRange: prefixStart.. Binding { let tabId = tab.id + let fallbackQuery = tab.content.query return Binding( - get: { tab.content.query }, + get: { + tabManager.tabs.first(where: { $0.id == tabId })?.content.query ?? fallbackQuery + }, set: { newValue in // Find this tab by ID, not by selectedTabIndex. During tab switch, // flushTextUpdate() fires on the OLD tab's EditorCoordinator when