Skip to content

fix(editor): stop large SQL paste from freezing the editor#1654

Merged
datlechin merged 7 commits into
mainfrom
worktree-fix-1652-large-sql-paste
Jun 11, 2026
Merged

fix(editor): stop large SQL paste from freezing the editor#1654
datlechin merged 7 commits into
mainfrom
worktree-fix-1652-large-sql-paste

Conversation

@datlechin

@datlechin datlechin commented Jun 11, 2026

Copy link
Copy Markdown
Member

Fixes #1652.

Problem

Pasting a large block of SQL froze the editor; restart required. Profiling with temporary OSLog timing then showed two further problems on a ~50k-line / ~4 MB document: typing pinned the CPU, and paste / select-all-delete each took ~500-600 ms.

Fixes (three commits)

1. Paste no longer freezes (fix(editor))

The one-shot insertion runs synchronously inside NSTextStorage.endEditing(), a path earlier large-document work (#701) never covered.

  • TextLayoutManager+Edits.swift: the line-index rebuild allocated a bridged String per line; rewritten to pass length + a line-break flag (behavior-preserving).
  • TreeSitterClient.swift: the async gate now keys off max(replaced, abs(delta)), so a big caret paste parses off the main thread.

2. Large documents stop pinning the CPU (perf(editor))

A 3 MB document sat under the old 5 MB highlight cap, so every keystroke re-parsed and re-highlighted, and inline AI copied the whole document per keystroke.

  • Highlighter.swift: maxHighlightableLength 5 MB → 2 MB. The guard precedes both the highlight and the tree-sitter parse, so above 2 MB the editor stops both and stays responsive, the way DataGrip (2.5 MB) and VS Code degrade large files.
  • SQLEditorCoordinator.swift: above the same cutoff, skip the inline-AI document sync.

3. Paste / delete-all run the line rebuild once, not twice (perf(editor))

Timing showed both were dominated by the line-storage rebuild running twice. A hidden minimap (showMinimap: false) still created a TextLayoutManager that rebuilt the line index on every edit.

  • MinimapView suspends its layout manager (processesEdits = false) while hidden and rebuilds it on show. The hidden minimap no longer does a second full rebuild on every paste, delete, or keystroke.

4. Typing in a large query no longer pins the CPU (perf(editor))

A Time Profiler trace showed the main thread spending its time in SwiftUI's attribute-graph diff (AG::LayoutDescriptor::Compare -> _stringCompareSlow -> Unicode._NFCNormalizer): on every view-graph update, SwiftUI compared the whole ~4 MB query for equality with canonical Unicode normalization, because TabQueryContent is an Equatable value type whose synthesized == compares query: String. The query is bridged from the editor's NSTextStorage, so it took the slow foreign-string path.

  • QueryTabState.swift: TabQueryContent now has a custom == that compares the text with NSString literal equality, which returns in O(1) when the lengths differ (every keystroke) and skips NFC normalization. Literal comparison is also the correct semantics for SQL text.

5. Hidden editor peripherals stop doing whole-document work per keystroke (perf(editor))

A second Time Profiler trace (after fix 4) still showed ~40% of the main thread in canonical Unicode comparison and CFStorageGetConstValueAtIndex while typing. Two more sources, both in the vendored editor:

  • TextBindingSync.applyRepresentableText compared the multi-megabyte bridged text with Swift != (canonical NFC). Now uses NSString literal equality.
  • The line-fold ribbon recomputed folds over the whole document on every edit even though showFoldingRibbon: false. LineFoldModel now skips the recompute while the ribbon is hidden and rebuilds when it is shown (same pattern as the hidden minimap in fix 3).

6. SwiftUI stops normalizing the whole query on every diff (perf(editor))

A third trace showed the main thread still ~40% in AGGraphSetOutputValue -> AG::LayoutDescriptor::Compare -> String._stringCompareSlow (NFC). SwiftUI's attribute graph compares a view's body output by walking its memory layout, so it reached the raw query: String nested in QueryTab and normalized the full 4 MB on every update. A custom Equatable does not help here (the layout walk bypasses it).

  • QueryTabState.swift: TabQueryContent now stores the query behind a small immutable class (exposed via a computed var query). The layout walk compares an 8-byte pointer instead of the string; value semantics are preserved by replacing the box on every set.

Tests

  • TextLayoutManagerTests.largePasteMatchesFullRebuild: a large paste produces line boundaries identical to the bulk build path, across LF / CRLF / CR and trailing-break variants.
  • TextLayoutManagerTests.suspendedLayoutManagerSkipsEditsUntilReset: a suspended layout manager ignores edits and rebuilds correctly on reset().
  • TreeSitterClientAsyncGateTests: the async gate routes large edits async and keeps small edits sync.
  • HighlighterTests: edits above the cap query no highlights; edits below it still do.
  • TabQueryContentEqualityTests: equality detects length changes, same-length edits, edits in a large query, other-field changes, and that the reference-boxed query keeps value semantics.

Known limitations (separate follow-ups)

  • A single pathological very long line (millions of characters) is a CoreText typesetting cost in Typesetter.swift, not addressed here.
  • The one-shot line rebuild for a multi-MB paste is still incremental (~300 ms). Routing it through the O(L) bulk builder needs post-rebuild layout finalization that is better done as its own change.

Verification

SwiftLint scope is TablePro only, so the vendored LocalPackages edits are out of lint scope; the one in-scope file is clean. Build and tests to be run in Xcode.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ba5040444

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +179 to +183
let execAsync = Self.shouldExecuteAsync(
editLength: range.length,
delta: delta,
documentLength: textView.documentRange.length
)

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 👍 / 👎.

@datlechin datlechin force-pushed the worktree-fix-1652-large-sql-paste branch from 6ba5040 to f6a67d5 Compare June 11, 2026 08:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b634164318

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)
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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58e0d5a08d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +140 to +142
if !isLargeDocument {
self?.inlineSuggestionManager?.handleTextChange()
}

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 Cancel pending inline suggestions for large documents

When a small edit has already scheduled a debounced inline suggestion, a subsequent paste that pushes the document over languageServiceLengthLimit takes this branch and never calls handleTextChange(), so the existing debounceTask/requestTask is not cancelled. When that task fires, InlineSuggestionManager.requestSuggestion() still reads textView.string into fullText and the AI inline source can send the whole large query, which reintroduces the large-document copy/work this gate is meant to avoid; the large-document path should still dismiss/cancel pending suggestions rather than doing nothing.

Useful? React with 👍 / 👎.

/// 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

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 Reset highlighting state before resuming below the cap

Lowering this cutoff makes 2–5 MB documents skip Highlighter.textStorage(_:didProcessEditing:), whose guard returns before styleContainer.storageUpdated and highlightProviders.storageDidUpdate. If the user edits while the document is above 2 MB and then deletes enough text to fall back under the limit, the next processed edit is applied to stale style/tree-sitter state that missed the over-limit edits, so highlighting can be incorrect or fail until the editor is recreated; crossing back under the cap should rebuild/reset provider state instead of applying only the final edit.

Useful? React with 👍 / 👎.

@datlechin datlechin force-pushed the worktree-fix-1652-large-sql-paste branch from 58e0d5a to 03a7f5b Compare June 11, 2026 16:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03a7f5bdf0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82ae24639b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// 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

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 Skip initial highlighting for over-limit documents

When a script is opened or TextViewController.setText replaces the storage, setUpHighlighter() still calls highlighter.invalidate(), and Highlighter.invalidate() forwards directly to each provider without checking this cutoff. That means documents over the new 2 MB limit still run the initial tree-sitter setup/query path even though the edit and visible-range callbacks now return early, so opening or programmatically loading a large SQL script can still do the syntax-highlighting work this limit is meant to suspend. The cutoff needs to be enforced on invalidation/setup as well, not just on edits.

Useful? React with 👍 / 👎.

@datlechin datlechin merged commit 0e5a9fd into main Jun 11, 2026
3 of 4 checks passed
@datlechin datlechin deleted the worktree-fix-1652-large-sql-paste branch June 11, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

粘贴一大坨 sql 到 query 窗口,直接卡死

1 participant