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
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,13 @@ class HighlightProviderState {
}

/// Accumulates all pending ranges and calls `queryHighlights`.
/// For large documents, limits to a reasonable number of chunks per cycle
/// to avoid blocking the main thread with tree-sitter queries.
func highlightInvalidRanges() {
let docLength = visibleRangeProvider?.documentRange.length ?? 0
let maxRanges = docLength > Self.largeDocThreshold ? 2 : Int.max
// For large docs, allow enough chunks to cover the visible viewport
// (~60 lines ≈ 3-4 chunks of 4096 chars), not just 2.
let maxRanges = docLength > Self.largeDocThreshold ? 8 : Int.max

var ranges: [NSRange] = []
while ranges.count < maxRanges, let nextRange = getNextRange() {
Expand All @@ -146,7 +150,6 @@ extension HighlightProviderState {
switch result {
case .success(let invalidSet):
let modifiedRange = NSRange(location: range.location, length: range.length + delta)
// Make sure we add in the edited range too
self?.invalidate(invalidSet.union(IndexSet(integersIn: modifiedRange)))
case .failure(let error):
if case HighlightProvidingError.operationCancelled = error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ extension Highlighter: @preconcurrency NSTextStorageDelegate {
// This method is called whenever attributes are updated, so to avoid re-highlighting the entire document
// each time an attribute is applied, we check to make sure this is in response to an edit.
guard editedMask.contains(.editedCharacters) else { return }
guard textView?.textStorage.length ?? 0 <= maxHighlightableLength else { return }
let docLength = textView?.textStorage.length ?? 0
guard docLength <= maxHighlightableLength else { return }

styleContainer.storageUpdated(editedRange: editedRange, changeInLength: delta)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,29 @@ extension SourceEditor {
self.highlightProviders = highlightProviders
}

private var textBindingTask: Task<Void, Never>?

@objc func textViewDidChangeText(_ notification: Notification) {
guard let textView = notification.object as? TextView else {
return
}
// A plain string binding is one-way (from this view, up the hierarchy) so it's not in the state binding
if case .binding(let binding) = text {
guard case .binding(let binding) = text else { return }

// For large documents, debounce the binding writeback to avoid
// copying megabytes of text into SwiftUI on every keystroke.
let docLength = textView.textStorage.length
// Set flag immediately so SwiftUI's updateNSViewController knows
// the text view is the source of truth during the debounce window.
isUpdateFromTextView = true
if docLength > 500_000 {
textBindingTask?.cancel()
textBindingTask = Task { @MainActor [weak self, weak textView] in
try? await Task.sleep(for: .milliseconds(150))
guard !Task.isCancelled, let self, let textView else { return }
binding.wrappedValue = textView.string
}
} else {
binding.wrappedValue = textView.string
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ public final class TreeSitterClient: HighlightProviding {
}

if !forceSyncOperation {
executor.cancelAll(below: .reset) // Cancel all edits, add it to the pending edit queue
// Only cancel pending highlight queries (.access), not edits.
// Cancelling edits causes them to accumulate in pendingEdits,
// making the next parse even slower. Let edits queue and apply
// incrementally instead.
executor.cancelAll(below: .edit)
executor.execAsync(
priority: .edit,
operation: { completion(.success(operation())) },
Expand Down Expand Up @@ -214,10 +218,17 @@ public final class TreeSitterClient: HighlightProviding {
}

let longQuery = range.length > Constants.maxSyncQueryLength
let longDocument = textView.documentRange.length > Constants.maxSyncContentLength
// For small highlight queries (typical per-keystroke chunks of 4096 chars),
// run synchronously to avoid the async cancellation delay that causes
// highlights to only appear after typing stops.
let isSmallQuery = range.length <= 8192
let longDocument = !isSmallQuery && textView.documentRange.length > Constants.maxSyncContentLength
let execAsync = longQuery || longDocument

if !execAsync || forceSyncOperation {
// Small queries attempt sync first. If the executor queue is
// busy (e.g., pending large parse), fall through to async path.
// This is thread-safe because execSync acquires the lock.
let result = executor.execSync(operation)
if case .success(let highlights) = result {
DispatchQueue.dispatchMainIfNot { completion(.success(highlights)) }
Expand Down
50 changes: 36 additions & 14 deletions TablePro/Views/Editor/SQLCompletionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,30 +82,39 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
try? await Task.sleep(nanoseconds: debounceNanoseconds)
guard myGeneration == debounceGeneration else { return nil }

let text = textView.text
let nsText = (textView.textView.textStorage?.string ?? "") as NSString
let docLength = nsText.length
let offset = cursorPosition.range.location

// Don't show autocomplete right after semicolon or newline
if offset > 0 {
let nsString = text as NSString
guard offset - 1 < nsString.length else { return nil }
let prevChar = nsString.character(at: offset - 1)
guard offset - 1 < docLength else { return nil }
let prevChar = nsText.character(at: offset - 1)
let semicolon = UInt16(UnicodeScalar(";").value)
let newline = UInt16(UnicodeScalar("\n").value)

if prevChar == semicolon || prevChar == newline {
guard offset < nsString.length else { return nil }
let afterCursor = nsString.substring(from: offset)
guard offset < docLength else { return nil }
let afterCursor = nsText.substring(from: offset)
.trimmingCharacters(in: .whitespacesAndNewlines)
if afterCursor.isEmpty { return nil }
}
}

// Extract a windowed substring around the cursor to avoid copying
// the entire document. CompletionEngine only needs local context.
let windowRadius = 5_000
let windowStart = max(0, offset - windowRadius)
let windowEnd = min(docLength, offset + windowRadius)
let windowRange = NSRange(location: windowStart, length: windowEnd - windowStart)
let text = nsText.substring(with: windowRange)
let adjustedOffset = offset - windowStart

await completionEngine.retrySchemaIfNeeded()

guard let context = await completionEngine.getCompletions(
text: text,
cursorPosition: offset
cursorPosition: adjustedOffset
) else {
return nil
}
Expand All @@ -123,7 +132,15 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
}
}

self.currentCompletionContext = context
// Adjust replacement range from window-relative back to document coordinates
self.currentCompletionContext = CompletionContext(
items: context.items,
replacementRange: NSRange(
location: context.replacementRange.location + windowStart,
length: context.replacementRange.length
),
sqlContext: context.sqlContext
)

let entries: [CodeSuggestionEntry] = context.items.map { item in
SQLSuggestionEntry(item: item)
Expand All @@ -139,16 +156,21 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
guard let context = currentCompletionContext,
let provider = completionEngine?.provider else { return nil }

let text = textView.text
let offset = cursorPosition.range.location
let nsText = text as NSString
let docLength = (textView.textView.textStorage?.string as NSString?)?.length ?? 0

let prefixStart = context.replacementRange.location
guard offset >= prefixStart, offset <= nsText.length else { return nil }
guard offset >= prefixStart, offset <= docLength else { return nil }

let prefixLength = offset - prefixStart
// Guard against stale replacementRange producing an unreasonably
// large prefix read. Normal prefixes are <200 chars even for
// qualified identifiers (schema.table.column).
guard prefixLength > 0, prefixLength <= 500 else { return nil }

let currentPrefix = nsText.substring(
with: NSRange(location: prefixStart, length: offset - prefixStart)
).lowercased()
let prefixRange = NSRange(location: prefixStart, length: prefixLength)
let currentPrefix = (textView.textView.textStorage?.string as NSString?)?
.substring(with: prefixRange).lowercased() ?? ""

guard !currentPrefix.isEmpty else { return nil }

Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,8 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
private func uppercaseKeywordIfNeeded(textView: TextView, range: NSRange, string: String) {
guard !isUppercasing,
AppSettingsManager.shared.editor.uppercaseKeywords,
KeywordUppercaseHelper.isWordBoundary(string) else { return }
KeywordUppercaseHelper.isWordBoundary(string),
(textView.textStorage.string as NSString).length < 500_000 else { return }

let nsText = textView.textStorage.string as NSString
guard let match = KeywordUppercaseHelper.keywordBeforePosition(nsText, at: range.location) else { return }
Expand Down
Loading