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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,18 @@ 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
/// fires suggestion triggers, none of which should happen for a programmatic
/// 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 }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel pending writeback before skipping sync

When a large document edit is still in the 150 ms debounced writeback window, isUpdateFromTextView remains true and this guard skips any external binding update such as Clear/Format. Because the pending textBindingTask is not cancelled on this path, that task can later write the pre-clear text back into the binding, so the programmatic change is dropped or reverted; the old duplicate SQLEditorView push-down was removed in this commit, so syncBindingText is now the only path that can apply the external change.

Useful? React with 👍 / 👎.

guard newValue != lastSyncedText else { return }
textBindingTask?.cancel()
isUpdatingFromRepresentable = true
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Autocomplete/CompletionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 24 additions & 26 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions TablePro/Views/Editor/SQLCompletionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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..<offset,
dotPrefix: nil,
tableReferences: [],
isInsideString: false,
isInsideComment: false
)
)
}

func completionOnCursorMove(
Expand Down
Loading
Loading