Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void>
private let askedContinuation: AsyncStream<Void>.Continuation
private var gate: CheckedContinuation<Void, Never>?
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<Void>.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 }
}
14 changes: 12 additions & 2 deletions TablePro/Views/Editor/QueryCompletionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate {
private struct Session {
var candidates: [SQLCompletionItem]
var replacementRange: NSRange
var tokenStart: Int
}

private struct Configuration: Equatable {
Expand Down Expand Up @@ -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) })
}
Expand All @@ -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.
Expand All @@ -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 }

Expand Down
Loading
Loading