fix(editor): keep the SQL editor's contents across a connection switch - #2253
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2236.
Type SQL into a query tab on connection A, switch to connection B, come back, and the editor is blank.
Root cause
SQLEditorViewwired a destructor to a SwiftUI appearance event:SQLEditorCoordinator.destroy()calledTextViewController.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()andsetUpKeyBindings()run inloadView()alone, which never runs again for a retained controller, sorevive()could not bring them back.TextBindingSync.lastSyncedTextstill 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, soonDisappearreally 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:
setTextStorageposts notextDidChangeNotification, so the model survives the blanking, butSourceEditor.Coordinator's observer for that notification is still armed: the first keystroke in the blank editor writes that single character intoQueryTab.content.queryandscheduleDraftSave()persists it. The query is then gone from memory and from disk.setTextStoragecalls_undoManager?.clearStack()).textCoordinatorsis emptied, so keyword auto-uppercasing, SQL diagnostics, the inline AI trigger and the Copilot document mirror all stop.ddandxtype themselves into the SQL.The fix
Teardown moves off the appearance event onto the terminal one.
SourceEditorimplementsdismantleNSViewController(_:coordinator:), SwiftUI's documented hook for permanent removal, and destroys the editor's text coordinators there.SourceEditor.Coordinatornow holds them strongly, becauseTextViewController.textCoordinatorsis a weak list and the destroy must not depend on SwiftUI releasing the view's@Stateafter the dismantle rather than before it.TextViewController.releaseHeavyState()stops discarding the text storage. It frees caches; the document is not a cache.SQLEditorView.onDisappearno longer destroys, andSQLEditorCoordinator.revive()is gone with it.WorkspacePanes.teardown()clears each pane'srootViewand 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 theonDisappearabuse this PR removes, so without this the teardown would simply stop happening.CLAUDE.mdgains an invariant for the class of bug, since this area now has a documented rule rather than a habit.Measured, not assumed
Eight standalone
swiftcprobes against the real SDK, since the whole fix turns on when SwiftUI considers a view gone:removeFromSuperview()on a retained hosting controller's viewonDisappear, thenonAppearagain on re-add, same identity and same@Stateview.isHidden = true/falsewindow.orderOut(nil).id()change with the host attacheddismantleNSViewController, then the controller deinitsrootViewreplaced while attacheddismantleNSViewController, coordinator still aliverootViewreplaced after detaching, no layout passrootViewreplaced after detaching, withlayoutSubtreeIfNeeded()dismantleNSViewControllerThe last row is why window close is unchanged here: it never ran the editor teardown either.
Verification
verify.sh build: PASS.verify.sh testoverEditorLifecycleTeardownTests,GutterHighlightTests,SQLEditorCoordinatorTests,SQLEditorCoordinatorCleanupTests,SQLEditorCoordinatorEscapeMenuTests,MinimapHitTestTests,PasteHighlightCancelTests,ConnectionWorkspaceRegistryTests,MainSplitViewControllerDetailWidthTests: 64 executed, 64 passed.verify.sh lint TablePro LocalPackages/CodeEditSourceEditor/Sources TableProTests: 0 violations.releaseHeavyStateKeepsDocument,releaseHeavyStateKeepsUndoStack,teardownDismantlesDetachedPaneContent). All pass with the fix.New suite
TableProTests/Views/Editor/EditorLifecycleTeardownTests.swiftcovers:releaseHeavyState()keeps the document,releaseHeavyState()keeps the undo stack,SQLEditorCoordinator.destroy()keeps the document,dismantleNSViewControllerdestroys each coordinator exactly once and empties the controller's list, andWorkspacePanes.teardown()dismantles a pane's content both detached (the production shape) and attached. TheTextViewControllerharnessGutterHighlightTestsused inline is nowEditorControllerFixture, shared by both suites.No UI automation, and no screenshots
Reproducing needs two connected connections in one window.
TableProUITestsships 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.