Skip to content

fix(editor): keep the SQL editor's contents across a connection switch - #2253

Merged
datlechin merged 1 commit into
mainfrom
fix/editor-teardown-on-connection-switch
Aug 19, 2026
Merged

fix(editor): keep the SQL editor's contents across a connection switch#2253
datlechin merged 1 commit into
mainfrom
fix/editor-teardown-on-connection-switch

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2236.

Type SQL into a query tab on connection A, switch to connection B, come back, and the editor is blank.

Root cause

SQLEditorView wired a destructor to a SwiftUI appearance event:

.onDisappear { teardownFavoritesObserver(); coordinator.destroy() }

SQLEditorCoordinator.destroy() called TextViewController.releaseHeavyState(), which discarded the text storage (textView?.setText("")), the highlighter, the tree-sitter client, the text coordinators, the controller's Combine subscriptions and its local key-event monitor. setUpHighlighter() and setUpKeyBindings() run in loadView() alone, which never runs again for a retained controller, so revive() could not bring them back. TextBindingSync.lastSyncedText still held the model's SQL, so the representable's update pass early-returned and never refilled the editor either.

That was correct when it shipped. releaseHeavyState() came from cb9a272, "fix: release memory when closing tabs" (#434), when one window served one connection and a workspace switch rebuilt the panes, so onDisappear really did mean "the tab is gone". #2116, shipped in v0.65.0, made each connection's panes long-lived: WorkspacePaneHost.show() now unparents the outgoing pane while the hosting controller stays alive. The destructor was never moved.

What one switch actually cost

All from the same cause, all fixed here:

  1. The editor goes blank, and it is not display-only for long. setTextStorage posts no textDidChangeNotification, so the model survives the blanking, but SourceEditor.Coordinator's observer for that notification is still armed: the first keystroke in the blank editor writes that single character into QueryTab.content.query and scheduleDraftSave() persists it. The query is then gone from memory and from disk.
  2. The undo stack is cleared (setTextStorage calls _undoManager?.clearStack()).
  3. Syntax highlighting is dead for the rest of the tab's life.
  4. So are the editor's own key bindings: Cmd+/ comment, Cmd+[ and Cmd+] indent, Cmd+Shift+D duplicate line, Cmd+Shift+K delete line, Option+Up/Down move line, Ctrl+Space completion, Tab and Shift+Tab block indent, Cmd+Ctrl+J jump to definition. No menu item carries those key equivalents, so there is no fallback.
  5. textCoordinators is emptied, so keyword auto-uppercasing, SQL diagnostics, the inline AI trigger and the Copilot document mirror all stop.
  6. Vim mode silently turns off while the indicator still reads NORMAL, so dd and x type themselves into the SQL.

The fix

Teardown moves off the appearance event onto the terminal one.

  • SourceEditor implements dismantleNSViewController(_:coordinator:), SwiftUI's documented hook for permanent removal, and destroys the editor's text coordinators there. SourceEditor.Coordinator now holds them strongly, because TextViewController.textCoordinators is a weak list and the destroy must not depend on SwiftUI releasing the view's @State after the dismantle rather than before it.
  • TextViewController.releaseHeavyState() stops discarding the text storage. It frees caches; the document is not a cache.
  • SQLEditorView.onDisappear no longer destroys, and SQLEditorCoordinator.revive() is gone with it.
  • WorkspacePanes.teardown() clears each pane's rootView and forces a layout pass before unparenting. SwiftUI reconciles a hosting controller on a layout pass and nothing lays out a detached view, so in the previous order the tree was never dismantled at all. Closing a connection used to reach the editor teardown through the onDisappear abuse this PR removes, so without this the teardown would simply stop happening.

CLAUDE.md gains an invariant for the class of bug, since this area now has a documented rule rather than a habit.

Measured, not assumed

Eight standalone swiftc probes against the real SDK, since the whole fix turns on when SwiftUI considers a view gone:

Action Events
removeFromSuperview() on a retained hosting controller's view onDisappear, then onAppear again on re-add, same identity and same @State
view.isHidden = true/false none
window.orderOut(nil) none
.id() change with the host attached dismantleNSViewController, then the controller deinits
rootView replaced while attached dismantleNSViewController, coordinator still alive
rootView replaced after detaching, no layout pass nothing, even after the host is released
rootView replaced after detaching, with layoutSubtreeIfNeeded() dismantleNSViewController
window closed with the panes still parented nothing, before or after this change

The last row is why window close is unchanged here: it never ran the editor teardown either.

Verification

  • verify.sh build: PASS.
  • verify.sh test over EditorLifecycleTeardownTests, GutterHighlightTests, SQLEditorCoordinatorTests, SQLEditorCoordinatorCleanupTests, SQLEditorCoordinatorEscapeMenuTests, MinimapHitTestTests, PasteHighlightCancelTests, ConnectionWorkspaceRegistryTests, MainSplitViewControllerDetailWidthTests: 64 executed, 64 passed.
  • verify.sh lint TablePro LocalPackages/CodeEditSourceEditor/Sources TableProTests: 0 violations.
  • Before and after: with the two source changes reverted and the new tests kept, 3 of the 5 new cases fail (releaseHeavyStateKeepsDocument, releaseHeavyStateKeepsUndoStack, teardownDismantlesDetachedPaneContent). All pass with the fix.

New suite TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift covers: releaseHeavyState() keeps the document, releaseHeavyState() keeps the undo stack, SQLEditorCoordinator.destroy() keeps the document, dismantleNSViewController destroys each coordinator exactly once and empties the controller's list, and WorkspacePanes.teardown() dismantles a pane's content both detached (the production shape) and attached. The TextViewController harness GutterHighlightTests used inline is now EditorControllerFixture, shared by both suites.

No UI automation, and no screenshots

Reproducing needs two connected connections in one window. TableProUITests ships one sample database (Chinook) and has no non-interactive way to create a second, so this flow has no deterministic XCUITest and no captured before and after. The state that could not be photographed is the blank editor after the switch; it is covered by the unit tests above and by the probe table.

@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.

@datlechin
datlechin merged commit 0fa3072 into main Aug 19, 2026
7 of 8 checks passed
@datlechin
datlechin deleted the fix/editor-teardown-on-connection-switch branch August 19, 2026 15:57
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 编辑器区域中已有的 SQL 语句会消失不显示。

1 participant