From 2ee57ea7599b1aa181e2c3b999ea4f5f37404570 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 26 Sep 2026 02:43:35 +0700 Subject: [PATCH] fix(editor): rank a completion list that arrives late for where the cursor is now --- CHANGELOG.md | 1 + .../Model/SuggestionViewModel.swift | 34 ++- .../SuggestionLiveCursorTests.swift | 213 ++++++++++++++++++ .../Views/Editor/QueryCompletionAdapter.swift | 14 +- ...QueryCompletionAdapterLifecycleTests.swift | 71 ++++++ 5 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0767f4655b..0183195573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Slow definition diff in Compare & Sync for large tables. - Autocomplete offering another schema's tables without their schema once that schema was completed or expanded. - Stale column and MongoDB field suggestions when a refresh ran while they were loading. +- Autocomplete list that opened while typing ranked for an earlier prefix, such as `set` first for `sel`. - Tables in an expanded Oracle or Snowflake schema missing from Open Quickly until the next refresh. - Tables from the previous database listed under a schema after switching database on Snowflake or Trino. - Hundreds of catalog queries from one keystroke in the sidebar filter on Oracle, Snowflake and BigQuery. diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift index d97aee8fc2..fdae4b55e4 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift @@ -152,20 +152,31 @@ final class SuggestionViewModel: ObservableObject { return } - guard let cursorPosition = textView.resolveCursorPosition(completionItems.windowPosition), + guard let windowPosition = textView.resolveCursorPosition(completionItems.windowPosition), let cursorRect = textView.textView.layoutManager.rectForOffset( - cursorPosition.range.location + windowPosition.range.location ) else { Self.logger.warning("showCompletions: cursor rect resolution failed") self.endSession(generation: generation) return } + guard let items = self.itemsForLiveCursor( + requested: completionItems.items, + answeredAt: windowPosition, + textView: textView, + delegate: delegate + ) else { + Self.logger.debug("showCompletions: nothing matches where the cursor moved while loading") + self.endSession(generation: generation) + return + } + let screenCursorRect = window.convertToScreen( textView.textView.convert(cursorRect, to: nil) ) - self.items = completionItems.items + self.items = items self.selectedIndex = 0 self.syntaxHighlightedCache = [:] self.notifySelection() @@ -184,6 +195,23 @@ final class SuggestionViewModel: ObservableObject { } } + private func itemsForLiveCursor( + requested: [CodeSuggestionEntry], + answeredAt windowPosition: CursorPosition, + textView: TextViewController, + delegate: CodeSuggestionDelegate + ) -> [CodeSuggestionEntry]? { + guard let liveCursor = textView.cursorPositions.first, + liveCursor.range != windowPosition.range else { + return requested + } + guard let reranked = delegate.completionOnCursorMove(textView: textView, cursorPosition: liveCursor), + !reranked.isEmpty else { + return nil + } + return reranked + } + /// Ends the session this request owns, so nothing is left claiming a window that was never /// shown. Superseded requests end nothing: their successor already owns the session. private func endSession(generation: Int) { diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift new file mode 100644 index 0000000000..af7fd4c1b2 --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionLiveCursorTests.swift @@ -0,0 +1,213 @@ +import AppKit +import SwiftUI +@testable import TableProEditorKit +import XCTest + +final class SuggestionLiveCursorTests: XCTestCase { + @MainActor + func test_showCompletions_ranksForTheKeysTypedWhileTheRequestWasOut() async throws { + let editor = try FocusedEditor(text: "s") + defer { editor.close() } + let delegate = GatedDelegate( + requested: [LiveCursorStubEntry(label: "set"), LiveCursorStubEntry(label: "select")], + rankedAt: [3: [LiveCursorStubEntry(label: "select")]] + ) + let model = SuggestionViewModel() + + var presentations = 0 + model.showCompletions( + textView: editor.controller, + delegate: delegate, + cursorPosition: CursorPosition(range: NSRange(location: 1, length: 0)) + ) { _, _ in presentations += 1 } + let request = try XCTUnwrap(model.itemsRequestTask) + await delegate.untilAsked() + + var closes = 0 + editor.type("e", at: 1) + model.cursorsUpdated(textView: editor.controller, delegate: delegate, position: editor.cursor) { closes += 1 } + editor.type("l", at: 2) + model.cursorsUpdated(textView: editor.controller, delegate: delegate, position: editor.cursor) { closes += 1 } + + delegate.answer() + await request.value + + XCTAssertEqual(closes, 0) + XCTAssertEqual(presentations, 1) + XCTAssertTrue(model.isPresented) + XCTAssertEqual(model.items.map(\.label), ["select"]) + XCTAssertEqual(model.selectedItem?.label, "select") + XCTAssertEqual(delegate.rankedPositions, [3]) + } + + @MainActor + func test_showCompletions_endsTheSessionWhenNothingMatchesWhereTheCursorMoved() async throws { + let editor = try FocusedEditor(text: "s") + defer { editor.close() } + let delegate = GatedDelegate( + requested: [LiveCursorStubEntry(label: "set"), LiveCursorStubEntry(label: "select")], + rankedAt: [:] + ) + let model = SuggestionViewModel() + + var presentations = 0 + model.showCompletions( + textView: editor.controller, + delegate: delegate, + cursorPosition: CursorPosition(range: NSRange(location: 1, length: 0)) + ) { _, _ in presentations += 1 } + let request = try XCTUnwrap(model.itemsRequestTask) + await delegate.untilAsked() + + editor.type("x", at: 1) + model.cursorsUpdated(textView: editor.controller, delegate: delegate, position: editor.cursor) {} + + delegate.answer() + await request.value + + XCTAssertEqual(presentations, 0) + XCTAssertFalse(model.isPresented) + XCTAssertTrue(model.items.isEmpty) + XCTAssertNil(model.activeTextView) + XCTAssertEqual(delegate.didCloseCount, 1) + } + + @MainActor + func test_showCompletions_presentsTheAnswerAsIsWhenTheCursorStayedPut() async throws { + let editor = try FocusedEditor(text: "u.") + defer { editor.close() } + let delegate = GatedDelegate( + requested: [LiveCursorStubEntry(label: "id"), LiveCursorStubEntry(label: "name")], + rankedAt: [2: [LiveCursorStubEntry(label: "unrelated")]] + ) + let model = SuggestionViewModel() + + var presentations = 0 + model.showCompletions( + textView: editor.controller, + delegate: delegate, + cursorPosition: CursorPosition(range: NSRange(location: 2, length: 0)) + ) { _, _ in presentations += 1 } + let request = try XCTUnwrap(model.itemsRequestTask) + await delegate.untilAsked() + + delegate.answer() + await request.value + + XCTAssertEqual(presentations, 1) + XCTAssertEqual(model.items.map(\.label), ["id", "name"]) + XCTAssertTrue(delegate.rankedPositions.isEmpty) + } +} + +@MainActor +private struct FocusedEditor { + let window: NSWindow + let controller: TextViewController + + init(text: String) throws { + controller = Mock.textViewController(theme: Mock.theme()) + window = LiveCursorKeyWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.orderFrontRegardless() + controller.textView.setText(text) + controller.view.layoutSubtreeIfNeeded() + let end = (text as NSString).length + controller.setCursorPositions([CursorPosition(range: NSRange(location: end, length: 0))]) + XCTAssertTrue(window.makeFirstResponder(controller.textView)) + } + + var cursor: CursorPosition { + controller.cursorPositions.first ?? CursorPosition(range: NSRange(location: 0, length: 0)) + } + + func type(_ character: String, at location: Int) { + controller.textView.replaceCharacters(in: NSRange(location: location, length: 0), with: character) + let end = location + (character as NSString).length + controller.setCursorPositions([CursorPosition(range: NSRange(location: end, length: 0))]) + } + + func close() { + window.close() + } +} + +private final class LiveCursorKeyWindow: NSWindow { + override var isKeyWindow: Bool { true } +} + +@MainActor +private final class GatedDelegate: CodeSuggestionDelegate { + private let requested: [CodeSuggestionEntry] + private let rankedAt: [Int: [CodeSuggestionEntry]] + private let asked: AsyncStream + private let askedContinuation: AsyncStream.Continuation + private var gate: CheckedContinuation? + private(set) var rankedPositions: [Int] = [] + private(set) var didCloseCount = 0 + + init(requested: [CodeSuggestionEntry], rankedAt: [Int: [CodeSuggestionEntry]]) { + self.requested = requested + self.rankedAt = rankedAt + (asked, askedContinuation) = AsyncStream.makeStream() + } + + func untilAsked() async { + for await _ in asked { + break + } + } + + func answer() { + gate?.resume() + gate = nil + } + + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition, + isManualTrigger: Bool + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + await withCheckedContinuation { continuation in + gate = continuation + askedContinuation.yield() + } + return (windowPosition: cursorPosition, items: requested) + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + rankedPositions.append(cursorPosition.range.location) + return rankedAt[cursorPosition.range.location] + } + + func completionWindowDidClose() { + didCloseCount += 1 + } + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) {} +} + +private struct LiveCursorStubEntry: 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/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index fac8d58c01..40074516fa 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -17,6 +17,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { private struct Session { var candidates: [SQLCompletionItem] var replacementRange: NSRange + var tokenStart: Int } private struct Configuration: Equatable { @@ -114,7 +115,11 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { return nil } - session = Session(candidates: result.candidates, replacementRange: result.replacementRange) + session = Session( + candidates: result.candidates, + replacementRange: result.replacementRange, + tokenStart: service.tokenStart(in: text, endingAt: offset) + ) return (windowPosition: liveCursorPosition, items: result.items.map { SQLSuggestionEntry(item: $0) }) } @@ -130,7 +135,11 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { offset >= 0, offset <= text.length else { return } let start = service.tokenStart(in: text, endingAt: offset) - session = Session(candidates: items, replacementRange: NSRange(location: start, length: offset - start)) + session = Session( + candidates: items, + replacementRange: NSRange(location: start, length: offset - start), + tokenStart: start + ) } /// Filters and ranks the open session's candidates for the token the cursor sits at the end of. @@ -154,6 +163,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { offset >= 0, offset <= text.length else { return nil } let start = service.tokenStart(in: text, endingAt: offset) + guard start == session.tokenStart else { return nil } let length = offset - start guard length > 0, length <= maximumPrefixLength else { return nil } diff --git a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift index 28aa658559..56418a553b 100644 --- a/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift @@ -305,6 +305,37 @@ struct QueryCompletionAdapterLifecycleTests { #expect(service.rankingInputCounts == [400, 400]) } + // MARK: - The session's token + + @MainActor + @Test("a session re-ranks its own token and declines the next one") + func sessionDeclinesACursorOnAnotherToken() async { + let service = TokenBoundCompletionService(labels: ["select", "set", "update", "users"]) + let adapter = QueryCompletionAdapter(serviceForTesting: service) + let controller = EditorControllerFixture.make(string: "se") + + _ = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: cursor(atEndOf: "se"), + isManualTrigger: false + ) + + controller.textView.setText("sel") + let sameToken = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: "sel") + )?.map(\.label) + + controller.textView.setText("se u") + let nextToken = adapter.completionOnCursorMove( + textView: controller, + cursorPosition: cursor(atEndOf: "se u") + )?.map(\.label) + + #expect(sameToken == ["select"]) + #expect(nextToken == nil) + } + // MARK: - Helpers @MainActor @@ -356,6 +387,46 @@ private struct PinnedKeywordCase { } } +@MainActor +private final class TokenBoundCompletionService: QueryCompletionService { + private let items: [SQLCompletionItem] + + init(labels: [String]) { + items = labels.map { SQLCompletionItem.keyword($0) } + } + + var triggerCharacters: Set { [] } + + func seedItems() -> [SQLCompletionItem] { [] } + + func completions( + in text: NSString, + at offset: Int, + isManualTrigger: Bool + ) async -> QueryCompletionSession? { + _ = isManualTrigger + let start = tokenStart(in: text, endingAt: offset) + return QueryCompletionSession( + items: items, + candidates: items, + replacementRange: NSRange(location: start, length: offset - start) + ) + } + + func rank(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + let lowerPrefix = prefix.lowercased() + return items.filter { $0.filterText.hasPrefix(lowerPrefix) } + } + + func tokenStart(in text: NSString, endingAt offset: Int) -> Int { + SQLTokenBoundary.segmentStart(in: text, endingAt: offset) + } + + func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { + _ = keywords + } +} + /// Records what each incremental update was asked to rank, so a test can pin the pool's bound /// without reaching into the adapter's private session. @MainActor