diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift index a75013844..d328ce1de 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift @@ -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() { @@ -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 { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index a2599f717..229b79951 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -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) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index fe7420f95..cd008290e 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -119,12 +119,29 @@ extension SourceEditor { self.highlightProviders = highlightProviders } + private var textBindingTask: Task? + @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 } } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift index 44a0a3473..7330f5d9f 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift @@ -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())) }, @@ -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)) } diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index 008d5e967..a8bc61f7e 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -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 } @@ -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) @@ -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 } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index a8973cab4..2ae9d8189 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -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 }