Skip to content

Commit 0e5a9fd

Browse files
authored
fix(editor): stop large SQL paste from freezing the editor (#1654)
* fix(editor): stop large SQL paste from freezing the editor * perf(editor): suspend syntax highlighting and inline AI for documents over 2MB * perf(editor): stop the hidden minimap rebuilding line storage on every edit * perf(editor): avoid O(n) Unicode comparison of query text during SwiftUI diffs * perf(editor): idle the hidden fold ribbon and use NSString equality in binding sync * perf(editor): box query text in a reference so SwiftUI compares a pointer not the whole string * refactor(editor): single source for the large-document threshold and flatten query equality
1 parent a5a2053 commit 0e5a9fd

15 files changed

Lines changed: 366 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- 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)
2727
- Cmd+R on a table now reloads its rows instead of failing with a query error; the refresh was sending the database a stray cancel that aborted its own freshly-issued reload.
2828
- 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)
29+
- 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)
2930

3031
### Security
3132

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Gutter/GutterView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ public class GutterView: NSView {
7878
public var showFoldingRibbon: Bool = true {
7979
didSet {
8080
foldingRibbon.isHidden = !showFoldingRibbon
81+
if showFoldingRibbon {
82+
foldingRibbon.model?.refresh()
83+
}
8184
}
8285
}
8386

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ import SwiftTreeSitter
1212
import CodeEditLanguages
1313
import OSLog
1414

15+
/// Thresholds for degrading language services on large documents.
16+
public enum EditorHighlighting {
17+
/// Documents longer than this (UTF-16 character count) are not syntax highlighted by default. Above it the
18+
/// per-edit re-parse and re-highlight cost dominates, so the editor stops highlighting to keep typing, scrolling,
19+
/// and deleting responsive, the same way DataGrip and VS Code degrade large files.
20+
public static let maxHighlightableCharacters = 2_000_000
21+
}
22+
1523
/// This class manages fetching syntax highlights from providers, and applying those styles to the editor.
1624
/// Multiple highlight providers can be used to style the editor.
1725
///
@@ -81,7 +89,7 @@ class Highlighter: NSObject {
8189
/// Counts upwards to provide unique IDs for new highlight providers.
8290
private var providerIdCounter: Int
8391

84-
public var maxHighlightableLength: Int = 5_000_000
92+
public var maxHighlightableLength: Int = EditorHighlighting.maxHighlightableCharacters
8593

8694
// MARK: - Init
8795

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject {
5757
foldCache.folds(in: range)
5858
}
5959

60+
/// Recomputes folds for the whole document. Used to catch up after the ribbon has been hidden and is shown again.
61+
func refresh() {
62+
textChangedStreamContinuation.yield()
63+
}
64+
6065
func textStorage(
6166
_ textStorage: NSTextStorage,
6267
didProcessEditing editedMask: NSTextStorageEditActions,
@@ -67,6 +72,9 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject {
6772
return
6873
}
6974
foldCache.storageUpdated(editedRange: editedRange, changeInLength: delta)
75+
// Recalculating folds walks the whole document. Skip it while the ribbon is hidden; `refresh()` rebuilds when
76+
// it is shown again.
77+
guard foldView?.isHidden != true else { return }
7078
textChangedStreamContinuation.yield()
7179
}
7280

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ public class MinimapView: FlippedNSView {
7272
scrollView.visibleRect.height - scrollView.contentInsets.vertical
7373
}
7474

75+
/// Suspends the minimap's line storage updates while it is hidden so it does not rebuild on every edit. When the
76+
/// minimap becomes visible again, its line storage is rebuilt to catch up on the edits it skipped.
77+
override public var isHidden: Bool {
78+
didSet {
79+
guard isHidden != oldValue else { return }
80+
layoutManager?.processesEdits = !isHidden
81+
if !isHidden {
82+
layoutManager?.reset()
83+
}
84+
}
85+
}
86+
7587
// MARK: - Init
7688

7789
/// Creates a minimap view with the text view to track, and an initial theme.

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/TextBindingSync.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ final class TextBindingSync {
7777
/// tree-sitter state for the old document and highlighting never recovers.
7878
func applyRepresentableText(_ newValue: String, controller: TextViewController) {
7979
guard !phase.isEditorChangePending else { return }
80-
guard newValue != lastSyncedText else { return }
80+
// Compare with NSString literal equality. The text can be multiple megabytes and bridged from NSTextStorage,
81+
// so Swift's canonical `!=` walks the whole string through Unicode normalization on every representable update.
82+
if let lastSyncedText, (newValue as NSString).isEqual(to: lastSyncedText) { return }
8183

8284
writebackTask?.cancel()
8385
phase.applyRepresentableValue {

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,21 @@ public final class TreeSitterClient: HighlightProviding {
138138

139139
// MARK: - HighlightProviding
140140

141+
/// Decides whether an edit must be parsed asynchronously to keep the main thread responsive.
142+
///
143+
/// The magnitude of an edit is the larger of the replaced range and the inserted length. Keying only on the
144+
/// replaced range misses a large paste at the caret, where the replaced range is empty but `delta` is huge, so a
145+
/// multi-megabyte insertion into a small document would otherwise run a full re-parse synchronously.
146+
/// - Parameters:
147+
/// - editLength: The length of the range being replaced.
148+
/// - delta: The change in length, negative for deletions.
149+
/// - documentLength: The length of the document after the edit.
150+
/// - Returns: `true` when the edit should be parsed off the main thread.
151+
static func shouldExecuteAsync(editLength: Int, delta: Int, documentLength: Int) -> Bool {
152+
let editMagnitude = max(editLength, abs(delta))
153+
return editMagnitude > Constants.maxSyncEditLength || documentLength > Constants.maxSyncContentLength
154+
}
155+
141156
/// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted.
142157
/// The returned `IndexSet` should include all indexes that need to be highlighted, including any inserted text.
143158
/// - Parameters:
@@ -161,9 +176,11 @@ public final class TreeSitterClient: HighlightProviding {
161176
return self?.applyEdit(edit: edit) ?? IndexSet()
162177
}
163178

164-
let longEdit = range.length > Constants.maxSyncEditLength
165-
let longDocument = textView.documentRange.length > Constants.maxSyncContentLength
166-
let execAsync = longEdit || longDocument
179+
let execAsync = Self.shouldExecuteAsync(
180+
editLength: range.length,
181+
delta: delta,
182+
documentLength: textView.documentRange.length
183+
)
167184

168185
if !execAsync || forceSyncOperation {
169186
let result = executor.execSync(operation)

LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/Highlighting/HighlighterTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,51 @@ final class HighlighterTests: XCTestCase {
277277
textView.insertText("func helloWorld() {\n\tprint(\"Hello World!\")\n}")
278278
XCTAssertEqual(textView.string, "func helloWorld() {\n\tprint(\"Hello World!\")\n}")
279279
}
280+
281+
@MainActor
282+
func test_editDoesNotHighlightDocumentOverMaxLength() {
283+
let highlightProvider = MockHighlightProvider(queryResponse: { .success([]) })
284+
let textView = Mock.textView()
285+
textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000)
286+
textView.setText(String(repeating: "a", count: 64))
287+
288+
let highlighter = Mock.highlighter(
289+
textView: textView,
290+
highlightProviders: [highlightProvider],
291+
attributeProvider: attributeProvider
292+
)
293+
highlighter.maxHighlightableLength = 32
294+
295+
let baseline = highlightProvider.queryCount
296+
textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "b")
297+
298+
XCTAssertEqual(
299+
highlightProvider.queryCount,
300+
baseline,
301+
"Editing a document over maxHighlightableLength must not query highlights"
302+
)
303+
}
304+
305+
@MainActor
306+
func test_editHighlightsDocumentUnderMaxLength() {
307+
let highlightProvider = MockHighlightProvider(queryResponse: { .success([]) })
308+
let textView = Mock.textView()
309+
textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000)
310+
textView.setText("SELECT 1;")
311+
312+
let highlighter = Mock.highlighter(
313+
textView: textView,
314+
highlightProviders: [highlightProvider],
315+
attributeProvider: attributeProvider
316+
)
317+
highlighter.maxHighlightableLength = 1_000
318+
319+
textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "x")
320+
321+
XCTAssertGreaterThan(
322+
highlightProvider.queryCount,
323+
0,
324+
"A document under maxHighlightableLength should still be highlighted"
325+
)
326+
}
280327
}

LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,38 @@ final class TreeSitterClientTests: XCTestCase {
194194
wait(for: editExpectations + [finalEditExpectation], timeout: 5.0)
195195
}
196196
}
197+
198+
final class TreeSitterClientAsyncGateTests: XCTestCase {
199+
private var savedEditLength = 0
200+
private var savedContentLength = 0
201+
202+
override func setUp() {
203+
savedEditLength = TreeSitterClient.Constants.maxSyncEditLength
204+
savedContentLength = TreeSitterClient.Constants.maxSyncContentLength
205+
TreeSitterClient.Constants.maxSyncEditLength = 1024
206+
TreeSitterClient.Constants.maxSyncContentLength = 1_000_000
207+
}
208+
209+
override func tearDown() {
210+
TreeSitterClient.Constants.maxSyncEditLength = savedEditLength
211+
TreeSitterClient.Constants.maxSyncContentLength = savedContentLength
212+
}
213+
214+
func test_largePasteAtCaretRunsAsync() {
215+
// The replaced range is empty for a caret paste; the inserted length must still force the async path.
216+
XCTAssertTrue(TreeSitterClient.shouldExecuteAsync(editLength: 0, delta: 500_000, documentLength: 500_000))
217+
}
218+
219+
func test_smallEditInSmallDocumentRunsSync() {
220+
XCTAssertFalse(TreeSitterClient.shouldExecuteAsync(editLength: 4, delta: 4, documentLength: 1_000))
221+
}
222+
223+
func test_largeDocumentRunsAsync() {
224+
XCTAssertTrue(TreeSitterClient.shouldExecuteAsync(editLength: 1, delta: 1, documentLength: 2_000_000))
225+
}
226+
227+
func test_largeDeletionRunsAsync() {
228+
XCTAssertTrue(TreeSitterClient.shouldExecuteAsync(editLength: 0, delta: -500_000, documentLength: 10))
229+
}
230+
}
197231
// swiftlint:enable all

LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
3535
range editedRange: NSRange,
3636
changeInLength delta: Int
3737
) {
38+
guard processesEdits else { return }
3839
guard editedMask.contains(.editedCharacters) else {
3940
if editedMask.contains(.editedAttributes) && delta == 0 {
4041
invalidateLayoutForRange(editedRange)
@@ -80,42 +81,44 @@ extension TextLayoutManager: NSTextStorageDelegate {
8081
/// - Parameter range: The range of the string that was inserted into the text storage.
8182
private func insertNewLines(for range: NSRange) {
8283
guard !range.isEmpty, let string = textStorage?.substring(from: range) as? NSString else { return }
83-
// Loop through each line being inserted, inserting & splitting where necessary
84+
// Loop through each line being inserted, inserting & splitting where necessary. Each line is described by its
85+
// length and whether it ends in a line break rather than a materialized substring, so a large paste does not
86+
// allocate one bridged `String` per line just to test its terminator.
8487
var index = 0
8588
while let nextLine = string.getNextLine(startingAt: index) {
86-
let lineRange = NSRange(start: index, end: nextLine.max)
87-
applyLineInsert(string.substring(with: lineRange) as NSString, at: range.location + index)
89+
applyLineInsert(length: nextLine.max - index, endsInLineBreak: true, at: range.location + index)
8890
index = nextLine.max
8991
}
9092

9193
if index < string.length {
9294
// Get the last line.
93-
applyLineInsert(string.substring(from: index) as NSString, at: range.location + index)
95+
applyLineInsert(length: string.length - index, endsInLineBreak: false, at: range.location + index)
9496
}
9597
}
9698

9799
/// Applies a line insert to the internal line storage tree.
98100
/// - Parameters:
99-
/// - insertedString: The string being inserted.
101+
/// - length: The length of the line being inserted.
102+
/// - endsInLineBreak: Whether the inserted line is terminated by a line break.
100103
/// - location: The location the string is being inserted into.
101-
private func applyLineInsert(_ insertedString: NSString, at location: Int) {
102-
if LineEnding(line: insertedString as String) != nil {
104+
private func applyLineInsert(length: Int, endsInLineBreak: Bool, at location: Int) {
105+
if endsInLineBreak {
103106
if location == lineStorage.length {
104107
// Insert a new line at the end of the document, need to insert a new line 'cause there's nothing to
105108
// split. Also, append the new text to the last line.
106-
lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0)
109+
lineStorage.update(atOffset: location, delta: length, deltaHeight: 0.0)
107110
lineStorage.insert(
108111
line: TextLine(),
109-
atOffset: location + insertedString.length,
112+
atOffset: location + length,
110113
length: 0,
111114
height: estimateLineHeight()
112115
)
113116
} else {
114117
// Need to split the line inserting into and create a new line with the split section of the line
115118
guard let linePosition = lineStorage.getLine(atOffset: location) else { return }
116-
let splitLocation = location + insertedString.length
119+
let splitLocation = location + length
117120
let splitLength = linePosition.range.max - location
118-
let lineDelta = insertedString.length - splitLength // The difference in the line being edited
121+
let lineDelta = length - splitLength // The difference in the line being edited
119122
if lineDelta != 0 {
120123
lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0)
121124
}
@@ -128,7 +131,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
128131
)
129132
}
130133
} else {
131-
lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0)
134+
lineStorage.update(atOffset: location, delta: length, deltaHeight: 0.0)
132135
}
133136
}
134137
}

0 commit comments

Comments
 (0)