Skip to content

feat(editor): add code folding to the SQL editor - #2232

Open
datlechin wants to merge 12 commits into
mainfrom
feat/sql-code-folding
Open

feat(editor): add code folding to the SQL editor#2232
datlechin wants to merge 12 commits into
mainfrom
feat/sql-code-folding

Conversation

@datlechin

Copy link
Copy Markdown
Member

Folds SQL in the query editor: statements, table bodies, CTEs, subqueries, BEGIN blocks and multi-line block comments. A collapsed region is replaced by a chip showing the start of what it hides and how many lines are hidden.

The folding engine already existed in the vendored CodeEditSourceEditor package but was switched off at all seven call sites by #1654, because recalculating folds walks the whole document on every keystroke and that froze the editor on a large paste. Most of this PR is removing the reasons it had to stay off.

What was blocking it

Fold depth reset every 50 lines. ChunkedLineIterator.next() declared a local previousDepth that shadowed the instance property and was never written back, so the running depth dropped to zero at every chunk boundary. Any document longer than 50 lines produced wrong folds. LineFoldChunkBoundaryTests covers it; reintroducing the shadow makes it fail at lines 50 and 100 and pass at 49.

No way to supply a fold provider. TextViewController.foldProvider is internal and the public SourceEditor never forwarded it, so every consumer was stuck with the indentation-based default. Indentation is the wrong signal for SQL. SourceEditor now takes foldProvider:, defaulting to nil so existing callers are unaffected.

The performance cliff. Fold calculation now stops above Peripherals.foldingSizeLimit, defaulting to EditorHighlighting.maxHighlightableCharacters (2,000,000), the same threshold that already gates highlighting, inline suggestions and diagnostics. The text-changed stream also switched to .bufferingNewest(1), so a burst of keystrokes coalesces into one recalculation instead of queueing one per keystroke.

The placeholder had no text. It drew three dots at a fixed width. It now measures and draws a label. The label string is built by the fold provider rather than the package, because the package has no strings catalog and no defaultLocalization, so this is the only seam where the app's localized strings can reach it.

Folding required visible line numbers. The ribbon is a subview of the gutter, so three editors that hide the gutter could not show it. Peripherals.showLineNumbers now separates "draw the gutter" from "draw the numbers in it". This also fixes a coupling in the main editor, where turning off "Show line numbers" would have taken code folding with it.

The scanner

SQLFoldScanner walks the document once and builds a depth stack of frames. Strings, line comments, block comments, MySQL # comments and PostgreSQL dollar-quoted bodies are skipped, so a ( inside a string never opens a fold. A region that opens and closes on the same line is discarded.

Two rules are worth calling out because both were bugs first:

  • A semicolon only ends a statement when the statement is the innermost open frame. Otherwise the ; after an inner statement inside a BEGIN block closed the enclosing statement and destroyed the block.
  • Start events for one line are emitted shallowest first and end events deepest first. The calculator consumes them with a depth-keyed stack, so a child reported before its parent made the parent's fold vanish and collapsed the child to a zero-width range. This hit any statement whose first foldable child opens on the same line, which is ordinary formatting: CREATE TABLE users (, SELECT foo(.

END IF, END LOOP, END WHILE and END FOR close constructs the scanner does not open, so they are skipped rather than popping a BEGIN or CASE frame. A closer that does not match the top of the stack is ignored instead of reaching past unrelated open frames, which matters while a block is half-typed.

Sharing the lexical rules

The app already had six SQL scanners duplicating string and comment handling. SqlLexer now holds the character constants and the skip routines the scanners agree on, and both SQLFoldScanner and SQLStatementScanner use it.

They are not fully merged, and that is deliberate. SQLStatementScanner treats a backslash as an escape in every dialect; SqlLexer.skipQuotedString gates it on the dialect, which is what PostgreSQL requires. Unifying that would change how scripts are split for execution, and SQLStatementScannerTests pins the current behaviour. The divergence is documented on SqlLexer and pinned by SqlLexerTests.backslashEscapeIsDialectGated so it is not "fixed" by accident later.

Fold state across tabs

Query tabs share one editor view identity, and LineFoldStorage carries collapse state across a recalculation by (depth, start offset). Two unrelated documents hitting the same pair meant one tab's collapsed regions appeared in another. Switching tabs now drops every fold before replaying the incoming tab's own ranges, and the outbound value is guarded against a document that no longer matches the binding, the same way cursor positions already were.

Collapsed regions persist through a tab close and reopen. Ranges that no longer fit the query are dropped rather than replayed, mirroring the existing cursor-offset clamping.

Scope

Enabled in all seven editors: SQL editor, DDL view, trigger editor, SQL import preview, AI review sheet, AI chat code blocks and the JSON cell viewer. JSON and JavaScript keep the package's indentation provider, which suits them.

Fold All, Unfold All and Toggle Fold are in the Query menu with rebindable shortcuts (Cmd+Option+Shift+Left, Cmd+Option+Shift+Right, Cmd+Option+Left; the arrow combinations are unclaimed, unlike Cmd+Option+[). Fold and Unfold are on the editor's right-click menu. Fold All collapses top-level regions only, because the layout manager ignores an attachment that overlaps an earlier one.

The Settings toggle defaults to on.

Testing

117 unit tests and 3 UI tests, all passing. The three bugs above were each verified by reintroducing the defect and confirming the test fails.

  • SQLFoldScannerTests, SQLFoldEventOrderingTests: region shapes, nesting, dialect handling, malformed input, event ordering
  • SQLFoldPerformanceGuardTests: the size gate stops the provider, a 170k-character script scans in under a second, and a single 2,000,000-character line does not stall
  • SqlLexerTests: including the dialect-gated backslash divergence
  • QueryTabFoldPersistenceTests: round trip, out-of-bounds and malformed ranges, tab files written before folding existed
  • LineFoldChunkBoundaryTests: the depth regression
  • QueryCodeFoldingUITests: Fold All and Unfold All, Toggle Fold, and the menu items, asserting the document text never changes

Left out

TablePro/Resources/Localizable.xcstrings is not in this PR. It is shared, was already dirty from other work in this checkout before this change started, and the new keys fall back to their English text until a build regenerates it.

@mintlify

mintlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Aug 19, 2026, 7:59 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.

1 participant