Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ public class GutterView: NSView {
public var showFoldingRibbon: Bool = true {
didSet {
foldingRibbon.isHidden = !showFoldingRibbon
if showFoldingRibbon {
foldingRibbon.model?.refresh()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
)
Comment on lines +179 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dispatch async edit completions to the main actor

When this new gate returns true for a large caret paste, applyEdit now takes the async executor path; that path still invokes the @MainActor completion directly from TreeSitterExecutor's background Task (operation: { completion(.success(operation())) }). In the inspected highlighter flow (Highlighter.textStorage(...didProcessEditing...)HighlightProviderState.storageDidUpdate), that completion mutates highlight invalidation state and can touch UI-facing state off the main actor for exactly the large paste case this commit adds to the async path. The async branch should hop back to the main actor/queue before calling the completion, as the query and cancel paths already do.

Useful? React with 👍 / 👎.


if !execAsync || forceSyncOperation {
let result = executor.execSync(operation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the highlighter before exercising edit callbacks

In this new test the TextView never adds highlighter as a storage delegate, so replaceCharacters does not call Highlighter.textStorage(_:didProcessEditing:...) and queryCount remains at its setup value. The nearby edit integration test registers with textView.addStorageDelegate(highlighter) before editing; this test should do the same or it fails while not actually covering the under-limit edit path.

Useful? React with 👍 / 👎.


XCTAssertGreaterThan(
highlightProvider.queryCount,
0,
"A document under maxHighlightableLength should still be highlighted"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
range editedRange: NSRange,
changeInLength delta: Int
) {
guard processesEdits else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update attachments when edit processing is suspended

When a hidden minimap sets processesEdits = false, returning here also skips attachments.textUpdated(atOffset:delta:). If the document has a text attachment such as a folded range and the user edits before it while the minimap is hidden, the main layout manager shifts/removes that attachment but the minimap's mirrored attachment ranges stay stale; reset() rebuilds only line storage, not attachments, so showing the minimap again can lay out folds at the wrong offsets. The suspended path should still keep attachment ranges in sync or rebuild/clear them on reset.

Useful? React with 👍 / 👎.

guard editedMask.contains(.editedCharacters) else {
if editedMask.contains(.editedAttributes) && delta == 0 {
invalidateLayoutForRange(editedRange)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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..<pasteManager.lineCount).compactMap {
pasteManager.lineStorage.getLine(atIndex: $0)?.range
}
let oracleRanges = (0..<oracle.lineCount).compactMap {
oracle.lineStorage.getLine(atIndex: $0)?.range
}
#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)
}
}
Loading
Loading