diff --git a/CHANGELOG.md b/CHANGELOG.md index 612401059..7577979e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) - Refreshing a table now reloads its data even when the previous load is still running; before, the refresh was silently dropped and the grid kept stale rows. (#1637) - SQL autocomplete now suggests tables after JOIN. It detects the clause at the cursor across multi-join and multi-clause queries, so columns no longer appear where a table is expected, and tables lead the list. (#1646) +- Large SQL scripts no longer freeze the editor or pin the CPU. Pasting is faster, and above 2 MB the editor suspends syntax highlighting and inline AI so typing, scrolling, and deleting stay responsive. (#1652) ### Security diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 2ad526e72..ee1676ae1 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -78,6 +78,9 @@ public class GutterView: NSView { public var showFoldingRibbon: Bool = true { didSet { foldingRibbon.isHidden = !showFoldingRibbon + if showFoldingRibbon { + foldingRibbon.model?.refresh() + } } } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 229b79951..f2cb18cd7 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -12,6 +12,14 @@ import SwiftTreeSitter import CodeEditLanguages import OSLog +/// Thresholds for degrading language services on large documents. +public enum EditorHighlighting { + /// Documents longer than this (UTF-16 character count) are not syntax highlighted by default. Above it the + /// per-edit re-parse and re-highlight cost dominates, so the editor stops highlighting to keep typing, scrolling, + /// and deleting responsive, the same way DataGrip and VS Code degrade large files. + public static let maxHighlightableCharacters = 2_000_000 +} + /// This class manages fetching syntax highlights from providers, and applying those styles to the editor. /// Multiple highlight providers can be used to style the editor. /// @@ -81,7 +89,7 @@ class Highlighter: NSObject { /// Counts upwards to provide unique IDs for new highlight providers. private var providerIdCounter: Int - public var maxHighlightableLength: Int = 5_000_000 + public var maxHighlightableLength: Int = EditorHighlighting.maxHighlightableCharacters // MARK: - Init diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift index 03de8ac7d..0674bbba4 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift @@ -57,6 +57,11 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { foldCache.folds(in: range) } + /// Recomputes folds for the whole document. Used to catch up after the ribbon has been hidden and is shown again. + func refresh() { + textChangedStreamContinuation.yield() + } + func textStorage( _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, @@ -67,6 +72,9 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { return } foldCache.storageUpdated(editedRange: editedRange, changeInLength: delta) + // Recalculating folds walks the whole document. Skip it while the ribbon is hidden; `refresh()` rebuilds when + // it is shown again. + guard foldView?.isHidden != true else { return } textChangedStreamContinuation.yield() } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 141d0bfdf..78e1c8ec7 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -72,6 +72,18 @@ public class MinimapView: FlippedNSView { scrollView.visibleRect.height - scrollView.contentInsets.vertical } + /// Suspends the minimap's line storage updates while it is hidden so it does not rebuild on every edit. When the + /// minimap becomes visible again, its line storage is rebuilt to catch up on the edits it skipped. + override public var isHidden: Bool { + didSet { + guard isHidden != oldValue else { return } + layoutManager?.processesEdits = !isHidden + if !isHidden { + layoutManager?.reset() + } + } + } + // MARK: - Init /// Creates a minimap view with the text view to track, and an initial theme. diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/TextBindingSync.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/TextBindingSync.swift index 3d22df731..50f5d75bf 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/TextBindingSync.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/TextBindingSync.swift @@ -77,7 +77,9 @@ final class TextBindingSync { /// tree-sitter state for the old document and highlighting never recovers. func applyRepresentableText(_ newValue: String, controller: TextViewController) { guard !phase.isEditorChangePending else { return } - guard newValue != lastSyncedText else { return } + // Compare with NSString literal equality. The text can be multiple megabytes and bridged from NSTextStorage, + // so Swift's canonical `!=` walks the whole string through Unicode normalization on every representable update. + if let lastSyncedText, (newValue as NSString).isEqual(to: lastSyncedText) { return } writebackTask?.cancel() phase.applyRepresentableValue { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift index 7330f5d9f..e0bdd528c 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift @@ -138,6 +138,21 @@ public final class TreeSitterClient: HighlightProviding { // MARK: - HighlightProviding + /// Decides whether an edit must be parsed asynchronously to keep the main thread responsive. + /// + /// The magnitude of an edit is the larger of the replaced range and the inserted length. Keying only on the + /// replaced range misses a large paste at the caret, where the replaced range is empty but `delta` is huge, so a + /// multi-megabyte insertion into a small document would otherwise run a full re-parse synchronously. + /// - Parameters: + /// - editLength: The length of the range being replaced. + /// - delta: The change in length, negative for deletions. + /// - documentLength: The length of the document after the edit. + /// - Returns: `true` when the edit should be parsed off the main thread. + static func shouldExecuteAsync(editLength: Int, delta: Int, documentLength: Int) -> Bool { + let editMagnitude = max(editLength, abs(delta)) + return editMagnitude > Constants.maxSyncEditLength || documentLength > Constants.maxSyncContentLength + } + /// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted. /// The returned `IndexSet` should include all indexes that need to be highlighted, including any inserted text. /// - Parameters: @@ -161,9 +176,11 @@ public final class TreeSitterClient: HighlightProviding { return self?.applyEdit(edit: edit) ?? IndexSet() } - let longEdit = range.length > Constants.maxSyncEditLength - let longDocument = textView.documentRange.length > Constants.maxSyncContentLength - let execAsync = longEdit || longDocument + let execAsync = Self.shouldExecuteAsync( + editLength: range.length, + delta: delta, + documentLength: textView.documentRange.length + ) if !execAsync || forceSyncOperation { let result = executor.execSync(operation) diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift index b6fe4f9ab..2a0dbdc14 100644 --- a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift @@ -277,4 +277,51 @@ final class HighlighterTests: XCTestCase { textView.insertText("func helloWorld() {\n\tprint(\"Hello World!\")\n}") XCTAssertEqual(textView.string, "func helloWorld() {\n\tprint(\"Hello World!\")\n}") } + + @MainActor + func test_editDoesNotHighlightDocumentOverMaxLength() { + let highlightProvider = MockHighlightProvider(queryResponse: { .success([]) }) + let textView = Mock.textView() + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textView.setText(String(repeating: "a", count: 64)) + + let highlighter = Mock.highlighter( + textView: textView, + highlightProviders: [highlightProvider], + attributeProvider: attributeProvider + ) + highlighter.maxHighlightableLength = 32 + + let baseline = highlightProvider.queryCount + textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "b") + + XCTAssertEqual( + highlightProvider.queryCount, + baseline, + "Editing a document over maxHighlightableLength must not query highlights" + ) + } + + @MainActor + func test_editHighlightsDocumentUnderMaxLength() { + let highlightProvider = MockHighlightProvider(queryResponse: { .success([]) }) + let textView = Mock.textView() + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textView.setText("SELECT 1;") + + let highlighter = Mock.highlighter( + textView: textView, + highlightProviders: [highlightProvider], + attributeProvider: attributeProvider + ) + highlighter.maxHighlightableLength = 1_000 + + textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "x") + + XCTAssertGreaterThan( + highlightProvider.queryCount, + 0, + "A document under maxHighlightableLength should still be highlighted" + ) + } } diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift index 17aca5842..be033f7bd 100644 --- a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift @@ -194,4 +194,38 @@ final class TreeSitterClientTests: XCTestCase { wait(for: editExpectations + [finalEditExpectation], timeout: 5.0) } } + +final class TreeSitterClientAsyncGateTests: XCTestCase { + private var savedEditLength = 0 + private var savedContentLength = 0 + + override func setUp() { + savedEditLength = TreeSitterClient.Constants.maxSyncEditLength + savedContentLength = TreeSitterClient.Constants.maxSyncContentLength + TreeSitterClient.Constants.maxSyncEditLength = 1024 + TreeSitterClient.Constants.maxSyncContentLength = 1_000_000 + } + + override func tearDown() { + TreeSitterClient.Constants.maxSyncEditLength = savedEditLength + TreeSitterClient.Constants.maxSyncContentLength = savedContentLength + } + + func test_largePasteAtCaretRunsAsync() { + // The replaced range is empty for a caret paste; the inserted length must still force the async path. + XCTAssertTrue(TreeSitterClient.shouldExecuteAsync(editLength: 0, delta: 500_000, documentLength: 500_000)) + } + + func test_smallEditInSmallDocumentRunsSync() { + XCTAssertFalse(TreeSitterClient.shouldExecuteAsync(editLength: 4, delta: 4, documentLength: 1_000)) + } + + func test_largeDocumentRunsAsync() { + XCTAssertTrue(TreeSitterClient.shouldExecuteAsync(editLength: 1, delta: 1, documentLength: 2_000_000)) + } + + func test_largeDeletionRunsAsync() { + XCTAssertTrue(TreeSitterClient.shouldExecuteAsync(editLength: 0, delta: -500_000, documentLength: 10)) + } +} // swiftlint:enable all diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index b3d7d11bc..98aa1ca8c 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -35,6 +35,7 @@ extension TextLayoutManager: NSTextStorageDelegate { range editedRange: NSRange, changeInLength delta: Int ) { + guard processesEdits else { return } guard editedMask.contains(.editedCharacters) else { if editedMask.contains(.editedAttributes) && delta == 0 { invalidateLayoutForRange(editedRange) @@ -80,42 +81,44 @@ extension TextLayoutManager: NSTextStorageDelegate { /// - Parameter range: The range of the string that was inserted into the text storage. private func insertNewLines(for range: NSRange) { guard !range.isEmpty, let string = textStorage?.substring(from: range) as? NSString else { return } - // Loop through each line being inserted, inserting & splitting where necessary + // Loop through each line being inserted, inserting & splitting where necessary. Each line is described by its + // length and whether it ends in a line break rather than a materialized substring, so a large paste does not + // allocate one bridged `String` per line just to test its terminator. var index = 0 while let nextLine = string.getNextLine(startingAt: index) { - let lineRange = NSRange(start: index, end: nextLine.max) - applyLineInsert(string.substring(with: lineRange) as NSString, at: range.location + index) + applyLineInsert(length: nextLine.max - index, endsInLineBreak: true, at: range.location + index) index = nextLine.max } if index < string.length { // Get the last line. - applyLineInsert(string.substring(from: index) as NSString, at: range.location + index) + applyLineInsert(length: string.length - index, endsInLineBreak: false, at: range.location + index) } } /// Applies a line insert to the internal line storage tree. /// - Parameters: - /// - insertedString: The string being inserted. + /// - length: The length of the line being inserted. + /// - endsInLineBreak: Whether the inserted line is terminated by a line break. /// - location: The location the string is being inserted into. - private func applyLineInsert(_ insertedString: NSString, at location: Int) { - if LineEnding(line: insertedString as String) != nil { + private func applyLineInsert(length: Int, endsInLineBreak: Bool, at location: Int) { + if endsInLineBreak { if location == lineStorage.length { // Insert a new line at the end of the document, need to insert a new line 'cause there's nothing to // split. Also, append the new text to the last line. - lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0) + lineStorage.update(atOffset: location, delta: length, deltaHeight: 0.0) lineStorage.insert( line: TextLine(), - atOffset: location + insertedString.length, + atOffset: location + length, length: 0, height: estimateLineHeight() ) } else { // Need to split the line inserting into and create a new line with the split section of the line guard let linePosition = lineStorage.getLine(atOffset: location) else { return } - let splitLocation = location + insertedString.length + let splitLocation = location + length let splitLength = linePosition.range.max - location - let lineDelta = insertedString.length - splitLength // The difference in the line being edited + let lineDelta = length - splitLength // The difference in the line being edited if lineDelta != 0 { lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0) } @@ -128,7 +131,7 @@ extension TextLayoutManager: NSTextStorageDelegate { ) } } else { - lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0) + lineStorage.update(atOffset: location, delta: length, deltaHeight: 0.0) } } } diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 0a8b57ffd..57796f2b1 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -66,6 +66,10 @@ public class TextLayoutManager: NSObject { public let attachments: TextAttachmentManager = TextAttachmentManager() + /// When `false`, this layout manager ignores text storage edits. Used to suspend a secondary layout manager that + /// is not currently visible (such as a hidden minimap) so it does not rebuild its line storage on every edit. + public var processesEdits: Bool = true + public weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? { didSet { lineFragmentRenderer.invisibleCharacterDelegate = invisibleCharacterDelegate @@ -178,10 +182,12 @@ public class TextLayoutManager: NSObject { #endif } - /// Resets the layout manager to an initial state. - func reset() { + /// Resets the layout manager to an initial state, rebuilding line storage from the current text storage. + public func reset() { lineStorage.removeAll() visibleLineIds.removeAll() + viewReuseQueue.usedViews.values.forEach { $0.removeFromSuperview() } + viewReuseQueue.queuedViews.forEach { $0.removeFromSuperview() } viewReuseQueue.queuedViews.removeAll() viewReuseQueue.usedViews.removeAll() maxLineWidth = 0 diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index f40c1b878..a5154de99 100644 --- a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -269,4 +269,70 @@ struct TextLayoutManagerTests { #expect(invalidatedLineIds.isSuperset(of: Set(expectedLineIds))) } + + private func makeLaidOutTextView(string: String) -> TextView { + let view = TextView(string: string) + view.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + view.updateFrameIfNeeded() + return view + } + + /// Pasting a large block of text into a document takes the incremental edit path, while opening a document takes + /// the bulk build path. Both must produce the same line index. This is the scenario behind the editor freezing on + /// a large SQL paste: the incremental path must stay correct after dropping its per-line allocations. + @Test( + arguments: [ + ("\n", false), + ("\n", true), + ("\r\n", false), + ("\r\n", true), + ("\r", false) + ] + ) + func largePasteMatchesFullRebuild(_ testItem: (String, Bool)) throws { + let (lineBreak, hasTrailingBreak) = testItem + + var pasted = (0..<3_000) + .map { "SELECT * FROM table_\($0) WHERE id = \($0);" } + .joined(separator: lineBreak) + if hasTrailingBreak { + pasted += lineBreak + } + + let pasteView = makeLaidOutTextView(string: "") + let pasteManager = try #require(pasteView.layoutManager) + pasteView.textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: pasted) + pasteManager.lineStorage.validateInternalState() + + let oracle = try #require(makeLaidOutTextView(string: pasted).layoutManager) + + #expect(pasteManager.lineCount == oracle.lineCount) + #expect(pasteManager.lineStorage.length == oracle.lineStorage.length) + + let pastedRanges = (0..