Skip to content

Commit 3fd9eac

Browse files
author
xuhengyu
committed
perf(inline-suggest): debounce and cancel completion refilter while typing
1 parent 07961dc commit 3fd9eac

3 files changed

Lines changed: 185 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Oracle connections no longer crash the app when the server sends a backend message the driver cannot decode; the query fails with a clear error and the connection reconnects. (#483)
1313
- MongoDB TLS handshake failures now report the actual cause instead of always blaming a cipher or protocol mismatch. (#1418)
14+
- Typing fast with the autocomplete window open no longer stalls each keystroke; the live refilter is debounced, cancelable, and moved off the main thread.
1415

1516
## [0.52.0] - 2026-06-19
1617

TablePro/Views/Editor/SQLCompletionAdapter.swift

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import AppKit
99
import CodeEditSourceEditor
1010
import CodeEditTextView
11+
import os
1112
import SwiftUI
1213

1314
/// Adapts the existing CompletionEngine to CodeEditSourceEditor's suggestion system
@@ -29,6 +30,13 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
2930
private var favoriteKeywords: [String: (name: String, query: String)] = [:]
3031
private var session: CompletionSession?
3132
private let debounceNanoseconds: UInt64 = 50_000_000
33+
private let refilterDebounceNanoseconds: UInt64 = 30_000_000
34+
35+
private var cursorRefilterTask: Task<Void, Never>?
36+
private var lastRefilterPrefix: String?
37+
private var lastRefilterItems: [SQLCompletionItem]?
38+
39+
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLCompletionAdapter")
3240

3341
// MARK: - Initialization
3442

@@ -133,6 +141,10 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
133141
}
134142

135143
// Adjust replacement range from window-relative back to document coordinates
144+
cursorRefilterTask?.cancel()
145+
cursorRefilterTask = nil
146+
lastRefilterPrefix = nil
147+
lastRefilterItems = nil
136148
session = CompletionSession(
137149
phase: .final,
138150
context: CompletionContext(
@@ -201,13 +213,83 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
201213

202214
guard !currentPrefix.isEmpty else { return nil }
203215

204-
let ranked = provider.filterAndRank(context.items, prefix: currentPrefix, context: context.sqlContext)
216+
let synchronousItems = synchronousRefilter(
217+
provider: provider,
218+
fullItems: context.items,
219+
sqlContext: context.sqlContext,
220+
prefix: currentPrefix
221+
)
222+
223+
scheduleRefilterTask(
224+
provider: provider,
225+
fullItems: context.items,
226+
sqlContext: context.sqlContext,
227+
prefix: currentPrefix
228+
)
229+
230+
return synchronousItems?.map { SQLSuggestionEntry(item: $0) }
231+
}
232+
233+
private func synchronousRefilter(
234+
provider: SQLCompletionProvider,
235+
fullItems: [SQLCompletionItem],
236+
sqlContext: SQLContext,
237+
prefix: String
238+
) -> [SQLCompletionItem]? {
239+
if prefix == lastRefilterPrefix, let cached = lastRefilterItems {
240+
return cached
241+
}
242+
243+
if let lastPrefix = lastRefilterPrefix,
244+
prefix.hasPrefix(lastPrefix),
245+
let lastItems = lastRefilterItems {
246+
let narrowed = provider.filterByPrefix(lastItems, prefix: prefix)
247+
return narrowed.isEmpty ? nil : narrowed
248+
}
249+
250+
let seeded = provider.filterByPrefix(fullItems, prefix: prefix)
251+
return seeded.isEmpty ? nil : seeded
252+
}
253+
254+
private func scheduleRefilterTask(
255+
provider: SQLCompletionProvider,
256+
fullItems: [SQLCompletionItem],
257+
sqlContext: SQLContext,
258+
prefix: String
259+
) {
260+
cursorRefilterTask?.cancel()
261+
262+
cursorRefilterTask = Task { [weak self] in
263+
guard let self else { return }
205264

206-
return ranked.isEmpty ? nil : ranked.map { SQLSuggestionEntry(item: $0) }
265+
do {
266+
try await Task.sleep(nanoseconds: self.refilterDebounceNanoseconds)
267+
} catch {
268+
return
269+
}
270+
guard !Task.isCancelled else { return }
271+
272+
let ranked = await Task.detached(priority: .userInitiated) {
273+
provider.filterAndRank(fullItems, prefix: prefix, context: sqlContext)
274+
}.value
275+
276+
guard !Task.isCancelled else { return }
277+
278+
await MainActor.run {
279+
guard !Task.isCancelled else { return }
280+
self.lastRefilterPrefix = prefix
281+
self.lastRefilterItems = ranked
282+
Self.logger.debug("refilter cached prefix='\(prefix)' count=\(ranked.count)")
283+
}
284+
}
207285
}
208286

209287
func completionWindowDidClose() {
210288
session = nil
289+
cursorRefilterTask?.cancel()
290+
cursorRefilterTask = nil
291+
lastRefilterPrefix = nil
292+
lastRefilterItems = nil
211293
}
212294

213295
func completionWindowApplyCompletion(
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//
2+
// SQLCompletionProviderConcurrencyTests.swift
3+
// TableProTests
4+
//
5+
// Guards the invariant that filterByPrefix and filterAndRank are pure and
6+
// safe to call off the main actor. SQLCompletionAdapter runs filterAndRank
7+
// on a detached task while typing, so concurrent invocations from the main
8+
// actor's synchronous fast path must not diverge.
9+
//
10+
11+
import TableProPluginKit
12+
@testable import TablePro
13+
import Testing
14+
15+
@Suite("SQL Completion Provider Concurrency")
16+
struct SQLCompletionProviderConcurrencyTests {
17+
private func makeProvider() -> SQLCompletionProvider {
18+
SQLCompletionProvider(schemaProvider: SQLSchemaProvider())
19+
}
20+
21+
private func makeItems() -> [SQLCompletionItem] {
22+
["select", "set", "session", "schema", "savepoint", "score", "scalar"]
23+
.map { SQLCompletionItem.keyword($0) }
24+
}
25+
26+
private func makeContext(prefix: String) -> SQLContext {
27+
SQLContext(
28+
clauseType: .unknown,
29+
prefix: prefix,
30+
prefixRange: 0..<prefix.count,
31+
dotPrefix: nil,
32+
tableReferences: [],
33+
isInsideString: false,
34+
isInsideComment: false
35+
)
36+
}
37+
38+
@Test("filterAndRank returns identical results across repeated invocations")
39+
func filterAndRankIsDeterministic() {
40+
let provider = makeProvider()
41+
let items = makeItems()
42+
let context = makeContext(prefix: "s")
43+
44+
let first = provider.filterAndRank(items, prefix: "s", context: context)
45+
let second = provider.filterAndRank(items, prefix: "s", context: context)
46+
47+
#expect(first == second)
48+
#expect(!first.isEmpty)
49+
}
50+
51+
@Test("filterByPrefix result is a superset of filterAndRank matches")
52+
func filterByPrefixSupersetOfRanked() {
53+
let provider = makeProvider()
54+
let items = makeItems()
55+
56+
let filtered = provider.filterByPrefix(items, prefix: "se")
57+
let ranked = provider.filterAndRank(items, prefix: "se", context: makeContext(prefix: "se"))
58+
59+
let filteredLabels = Set(filtered.map { $0.label })
60+
let rankedLabels = Set(ranked.map { $0.label })
61+
62+
#expect(rankedLabels.isSubset(of: filteredLabels))
63+
}
64+
65+
@Test("filterAndRank is safe under concurrent invocations from a detached task")
66+
func filterAndRankConcurrent() async {
67+
let provider = makeProvider()
68+
let items = makeItems()
69+
let context = makeContext(prefix: "sc")
70+
71+
let baseline = provider.filterAndRank(items, prefix: "sc", context: context)
72+
73+
await withTaskGroup(of: [SQLCompletionItem].self) { group in
74+
for _ in 0..<8 {
75+
group.addTask {
76+
provider.filterAndRank(items, prefix: "sc", context: context)
77+
}
78+
}
79+
for await result in group {
80+
#expect(result == baseline)
81+
}
82+
}
83+
}
84+
85+
@Test("filterByPrefix narrows correctly when prefix extends")
86+
func filterByPrefixNarrowsOnExtension() {
87+
let provider = makeProvider()
88+
let items = makeItems()
89+
90+
let short = provider.filterByPrefix(items, prefix: "s")
91+
let extended = provider.filterByPrefix(short, prefix: "se")
92+
93+
let direct = provider.filterByPrefix(items, prefix: "se")
94+
let directLabels = Set(direct.map { $0.label })
95+
let extendedLabels = Set(extended.map { $0.label })
96+
97+
#expect(extendedLabels == directLabels)
98+
#expect(extended.count <= short.count)
99+
}
100+
}

0 commit comments

Comments
 (0)