From f6a67d5fe4fc3dcf3178d32b56972b32d7ff37dd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 11 Jun 2026 14:59:13 +0700 Subject: [PATCH 1/7] fix(editor): stop large SQL paste from freezing the editor --- CHANGELOG.md | 1 + .../TreeSitter/TreeSitterClient.swift | 23 +++++++-- .../TreeSitterClientTests.swift | 34 +++++++++++++ .../TextLayoutManager+Edits.swift | 26 +++++----- .../TextLayoutManagerTests.swift | 48 +++++++++++++++++++ 5 files changed, 117 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 612401059..6aa5524b8 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) +- Pasting a large SQL script no longer freezes the editor. The line index rebuild stops allocating per line, and a big paste parses off the main thread. (#1652) ### Security 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/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..a982920bb 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -80,42 +80,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 +130,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/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index f40c1b878..ec44d62ec 100644 --- a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -269,4 +269,52 @@ 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.. Date: Thu, 11 Jun 2026 23:00:02 +0700 Subject: [PATCH 2/7] perf(editor): suspend syntax highlighting and inline AI for documents over 2MB --- CHANGELOG.md | 2 +- .../Highlighting/Highlighter.swift | 5 +- .../Highlighting/HighlighterTests.swift | 47 +++++++++++++++++++ .../Views/Editor/SQLEditorCoordinator.swift | 12 ++++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa5524b8..7577979e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +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) -- Pasting a large SQL script no longer freezes the editor. The line index rebuild stops allocating per line, and a big paste parses off the main thread. (#1652) +- 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/Highlighting/Highlighter.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 229b79951..0e1cd8c4c 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -81,7 +81,10 @@ class Highlighter: NSObject { /// Counts upwards to provide unique IDs for new highlight providers. private var providerIdCounter: Int - public var maxHighlightableLength: Int = 5_000_000 + /// Documents longer than this are not highlighted. Above this length the per-edit re-parse and re-highlight cost + /// dominates, so the editor stops syntax highlighting to keep typing, scrolling, and deleting responsive, the same + /// way DataGrip and VS Code degrade large files. + public var maxHighlightableLength: Int = 2_000_000 // MARK: - Init 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/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 4f3ddf643..28ed9d1a1 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -21,6 +21,10 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { private static let logger = Logger(subsystem: "com.TablePro", category: "SQLEditorCoordinator") + /// Above this document length inline AI features are suspended, matching the syntax-highlighting cutoff, so a large + /// document does not copy its whole contents to the assistant on every keystroke. + private static let languageServiceLengthLimit = 2_000_000 + @ObservationIgnored weak var controller: TextViewController? /// Shared schema provider for inline AI suggestions (avoids duplicate schema fetches) @ObservationIgnored var schemaProvider: SQLSchemaProvider? @@ -130,12 +134,16 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { vimEngine?.invalidateLineCache() + let isLargeDocument = textView.textStorage.length > Self.languageServiceLengthLimit + Task { [weak self] in - self?.inlineSuggestionManager?.handleTextChange() + if !isLargeDocument { + self?.inlineSuggestionManager?.handleTextChange() + } self?.vimCursorManager?.updatePosition() } - if !didDestroy, let tabID, let sync = copilotDocumentSync { + if !isLargeDocument, !didDestroy, let tabID, let sync = copilotDocumentSync { let text = textView.string Task { await sync.didChangeText(tabID: tabID, newText: text) } } From 03a7f5bdf0909c63d2a58dee430b46199670d992 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 11 Jun 2026 23:34:48 +0700 Subject: [PATCH 3/7] perf(editor): stop the hidden minimap rebuilding line storage on every edit --- .../Minimap/MinimapView.swift | 12 ++++++++++++ .../TextLayoutManager+Edits.swift | 1 + .../TextLayoutManager/TextLayoutManager.swift | 10 ++++++++-- .../LayoutManager/TextLayoutManagerTests.swift | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) 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/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index a982920bb..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) 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 ec44d62ec..a5154de99 100644 --- a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -317,4 +317,22 @@ struct TextLayoutManagerTests { } #expect(pastedRanges == oracleRanges) } + + /// A suspended layout manager (such as a hidden minimap) must ignore edits, and `reset()` must rebuild its line + /// storage to match the text storage once it is resumed. + @Test + func suspendedLayoutManagerSkipsEditsUntilReset() throws { + let view = makeLaidOutTextView(string: "A\nB\nC") + let manager = try #require(view.layoutManager) + let originalLength = manager.lineStorage.length + + manager.processesEdits = false + view.textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "X\nY\n") + #expect(manager.lineStorage.length == originalLength, "A suspended layout manager must ignore edits") + + manager.processesEdits = true + manager.reset() + manager.lineStorage.validateInternalState() + #expect(manager.lineStorage.length == view.textStorage.length) + } } From 347cfa211421d268466b16e38e4aa9a501d16376 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 12 Jun 2026 00:06:33 +0700 Subject: [PATCH 4/7] perf(editor): avoid O(n) Unicode comparison of query text during SwiftUI diffs --- TablePro/Models/Query/QueryTabState.swift | 23 +++++++ .../Query/TabQueryContentEqualityTests.swift | 62 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 TableProTests/Models/Query/TabQueryContentEqualityTests.swift diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 48dba093d..83d94d6ce 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -288,6 +288,29 @@ struct TabQueryContent: Equatable { if queryNS.length != savedNS.length { return true } return queryNS != savedNS } + + static func == (lhs: TabQueryContent, rhs: TabQueryContent) -> Bool { + // `query` can be multiple megabytes and is bridged from the editor's NSTextStorage. The synthesized `==` + // compared it with Swift's canonical Unicode equality, which walks the whole string through NFC normalization + // on every SwiftUI diff and pins the CPU while editing a large query. NSString equality returns in O(1) when + // the lengths differ (every keystroke changes the length) and uses literal comparison otherwise. + guard (lhs.query as NSString).isEqual(to: rhs.query), + lhs.queryParameters == rhs.queryParameters, + lhs.isParameterPanelVisible == rhs.isParameterPanelVisible, + lhs.sourceFileURL == rhs.sourceFileURL, + lhs.loadMtime == rhs.loadMtime, + lhs.externalModificationDetected == rhs.externalModificationDetected else { + return false + } + switch (lhs.savedFileContent, rhs.savedFileContent) { + case (nil, nil): + return true + case let (lhsSaved?, rhsSaved?): + return (lhsSaved as NSString).isEqual(to: rhsSaved) + default: + return false + } + } } struct TabDisplayState: Equatable { diff --git a/TableProTests/Models/Query/TabQueryContentEqualityTests.swift b/TableProTests/Models/Query/TabQueryContentEqualityTests.swift new file mode 100644 index 000000000..4c2891dc1 --- /dev/null +++ b/TableProTests/Models/Query/TabQueryContentEqualityTests.swift @@ -0,0 +1,62 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("TabQueryContent.Equatable") +struct TabQueryContentEqualityTests { + @Test("Equal when all fields match") + func equalWhenIdentical() { + let a = TabQueryContent(query: "SELECT * FROM users;") + let b = TabQueryContent(query: "SELECT * FROM users;") + #expect(a == b) + } + + @Test("Not equal when the query changes length") + func notEqualOnLengthChange() { + var a = TabQueryContent(query: "SELECT 1") + let b = TabQueryContent(query: "SELECT 12") + #expect(a != b) + a.query = "SELECT 12" + #expect(a == b) + } + + @Test("Not equal when the query changes at the same length") + func notEqualOnSameLengthChange() { + let a = TabQueryContent(query: "abc") + let b = TabQueryContent(query: "abd") + #expect(a != b) + } + + @Test("Detects a single-character edit in a large query") + func detectsEditInLargeQuery() { + let base = String(repeating: "SELECT 1;\n", count: 100_000) + let a = TabQueryContent(query: base) + let b = TabQueryContent(query: base + "X") + #expect(a != b) + let c = TabQueryContent(query: base) + #expect(a == c) + } + + @Test("Not equal when a non-text field differs") + func notEqualOnOtherField() { + var a = TabQueryContent(query: "Q") + let b = TabQueryContent(query: "Q") + a.isParameterPanelVisible = true + #expect(a != b) + a.isParameterPanelVisible = false + #expect(a == b) + } + + @Test("savedFileContent participates in equality") + func savedFileContentEquality() { + var a = TabQueryContent(query: "Q") + var b = TabQueryContent(query: "Q") + #expect(a == b) + a.savedFileContent = "disk" + #expect(a != b) + b.savedFileContent = "disk" + #expect(a == b) + b.savedFileContent = "other" + #expect(a != b) + } +} From 0559fc4fe5446ce7d90dfef1bf9bd9dd27a5595d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 12 Jun 2026 00:26:03 +0700 Subject: [PATCH 5/7] perf(editor): idle the hidden fold ribbon and use NSString equality in binding sync --- .../Sources/CodeEditSourceEditor/Gutter/GutterView.swift | 3 +++ .../LineFolding/Model/LineFoldModel.swift | 8 ++++++++ .../SourceEditor/TextBindingSync.swift | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) 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/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/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 { From 82ae24639bb5de5cf6d7c62344429ee8650ae1ce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 12 Jun 2026 00:54:17 +0700 Subject: [PATCH 6/7] perf(editor): box query text in a reference so SwiftUI compares a pointer not the whole string --- TablePro/Models/Query/QueryTabState.swift | 43 ++++++++++++++++--- .../Query/TabQueryContentEqualityTests.swift | 10 +++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 83d94d6ce..9847b2eee 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -271,7 +271,21 @@ struct TabTableContext: Equatable { } struct TabQueryContent: Equatable { - var query: String = "" + /// Holds the query text behind a reference. SwiftUI's attribute-graph diff compares a value by walking its memory + /// layout, so a stored `String` field made it normalize the whole multi-megabyte query (NFC) on every view update. + /// Storing the text in a class makes that walk compare an 8-byte pointer instead. Value semantics are preserved + /// because the setter replaces the box. + private final class QueryStorage: Sendable { + let text: String + init(_ text: String) { self.text = text } + } + + private var queryStorage: QueryStorage + + var query: String { + get { queryStorage.text } + set { queryStorage = QueryStorage(newValue) } + } var queryParameters: [QueryParameter] = [] var isParameterPanelVisible: Bool = false var sourceFileURL: URL? @@ -281,6 +295,24 @@ struct TabQueryContent: Equatable { static let maxPersistableQuerySize = 500_000 + init( + query: String = "", + queryParameters: [QueryParameter] = [], + isParameterPanelVisible: Bool = false, + sourceFileURL: URL? = nil, + savedFileContent: String? = nil, + loadMtime: Date? = nil, + externalModificationDetected: Bool = false + ) { + self.queryStorage = QueryStorage(query) + self.queryParameters = queryParameters + self.isParameterPanelVisible = isParameterPanelVisible + self.sourceFileURL = sourceFileURL + self.savedFileContent = savedFileContent + self.loadMtime = loadMtime + self.externalModificationDetected = externalModificationDetected + } + var isFileDirty: Bool { guard sourceFileURL != nil, let saved = savedFileContent else { return false } let queryNS = query as NSString @@ -290,11 +322,10 @@ struct TabQueryContent: Equatable { } static func == (lhs: TabQueryContent, rhs: TabQueryContent) -> Bool { - // `query` can be multiple megabytes and is bridged from the editor's NSTextStorage. The synthesized `==` - // compared it with Swift's canonical Unicode equality, which walks the whole string through NFC normalization - // on every SwiftUI diff and pins the CPU while editing a large query. NSString equality returns in O(1) when - // the lengths differ (every keystroke changes the length) and uses literal comparison otherwise. - guard (lhs.query as NSString).isEqual(to: rhs.query), + // The query can be multiple megabytes and is bridged from the editor's NSTextStorage. Same-box comparison is + // O(1); otherwise use NSString literal equality, which returns in O(1) when the lengths differ (every keystroke + // changes the length) and avoids Swift's canonical Unicode normalization. + guard lhs.queryStorage === rhs.queryStorage || (lhs.query as NSString).isEqual(to: rhs.query), lhs.queryParameters == rhs.queryParameters, lhs.isParameterPanelVisible == rhs.isParameterPanelVisible, lhs.sourceFileURL == rhs.sourceFileURL, diff --git a/TableProTests/Models/Query/TabQueryContentEqualityTests.swift b/TableProTests/Models/Query/TabQueryContentEqualityTests.swift index 4c2891dc1..4e6320521 100644 --- a/TableProTests/Models/Query/TabQueryContentEqualityTests.swift +++ b/TableProTests/Models/Query/TabQueryContentEqualityTests.swift @@ -59,4 +59,14 @@ struct TabQueryContentEqualityTests { b.savedFileContent = "other" #expect(a != b) } + + @Test("Value semantics: mutating a copy does not change the original") + func valueSemantics() { + let a = TabQueryContent(query: "original") + var b = a + b.query = "changed" + #expect(a.query == "original") + #expect(b.query == "changed") + #expect(a != b) + } } From d46f2670ec01b6e01f66789944e82d7f0600c970 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 12 Jun 2026 01:32:33 +0700 Subject: [PATCH 7/7] refactor(editor): single source for the large-document threshold and flatten query equality --- .../Highlighting/Highlighter.swift | 13 +++++--- TablePro/Models/Query/QueryTabState.swift | 31 ++++++++++--------- .../Views/Editor/SQLEditorCoordinator.swift | 6 ++-- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 0e1cd8c4c..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,10 +89,7 @@ class Highlighter: NSObject { /// Counts upwards to provide unique IDs for new highlight providers. private var providerIdCounter: Int - /// Documents longer than this are not highlighted. Above this length the per-edit re-parse and re-highlight cost - /// dominates, so the editor stops syntax highlighting to keep typing, scrolling, and deleting responsive, the same - /// way DataGrip and VS Code degrade large files. - public var maxHighlightableLength: Int = 2_000_000 + public var maxHighlightableLength: Int = EditorHighlighting.maxHighlightableCharacters // MARK: - Init diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 9847b2eee..d2dbe5999 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -322,22 +322,25 @@ struct TabQueryContent: Equatable { } static func == (lhs: TabQueryContent, rhs: TabQueryContent) -> Bool { - // The query can be multiple megabytes and is bridged from the editor's NSTextStorage. Same-box comparison is - // O(1); otherwise use NSString literal equality, which returns in O(1) when the lengths differ (every keystroke - // changes the length) and avoids Swift's canonical Unicode normalization. - guard lhs.queryStorage === rhs.queryStorage || (lhs.query as NSString).isEqual(to: rhs.query), - lhs.queryParameters == rhs.queryParameters, - lhs.isParameterPanelVisible == rhs.isParameterPanelVisible, - lhs.sourceFileURL == rhs.sourceFileURL, - lhs.loadMtime == rhs.loadMtime, - lhs.externalModificationDetected == rhs.externalModificationDetected else { - return false - } - switch (lhs.savedFileContent, rhs.savedFileContent) { + // Cheap scalar fields short-circuit first. The query and saved-file text can be multiple megabytes and are + // bridged from NSTextStorage, so they are compared last and with `sameText` to avoid Swift's canonical Unicode + // comparison (O(n) on the bridged text); the same-box identity check makes an unchanged query O(1). + lhs.isParameterPanelVisible == rhs.isParameterPanelVisible + && lhs.externalModificationDetected == rhs.externalModificationDetected + && lhs.sourceFileURL == rhs.sourceFileURL + && lhs.loadMtime == rhs.loadMtime + && lhs.queryParameters == rhs.queryParameters + && (lhs.queryStorage === rhs.queryStorage || sameText(lhs.query, rhs.query)) + && sameText(lhs.savedFileContent, rhs.savedFileContent) + } + + /// Literal text equality that skips Swift's canonical Unicode comparison, returning in O(1) when the lengths differ. + private static func sameText(_ lhs: String?, _ rhs: String?) -> Bool { + switch (lhs, rhs) { case (nil, nil): return true - case let (lhsSaved?, rhsSaved?): - return (lhsSaved as NSString).isEqual(to: rhsSaved) + case let (lhs?, rhs?): + return (lhs as NSString).isEqual(to: rhs) default: return false } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 28ed9d1a1..6199bf923 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -21,9 +21,9 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { private static let logger = Logger(subsystem: "com.TablePro", category: "SQLEditorCoordinator") - /// Above this document length inline AI features are suspended, matching the syntax-highlighting cutoff, so a large - /// document does not copy its whole contents to the assistant on every keystroke. - private static let languageServiceLengthLimit = 2_000_000 + /// Above this document length inline AI features are suspended, at the same cutoff where syntax highlighting stops, + /// so a large document does not copy its whole contents to the assistant on every keystroke. + private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters @ObservationIgnored weak var controller: TextViewController? /// Shared schema provider for inline AI suggestions (avoids duplicate schema fetches)