Skip to content

fix(structure): give each tab its own structure editor and let Save reach it - #2296

Merged
datlechin merged 1 commit into
mainfrom
fix/structure-editor-tab-identity
Aug 20, 2026
Merged

fix(structure): give each tab its own structure editor and let Save reach it#2296
datlechin merged 1 commit into
mainfrom
fix/structure-editor-tab-identity

Conversation

@datlechin

Copy link
Copy Markdown
Member

A structure tab's staged ALTERs, and the ability to apply them, now belong to the tab rather than to whichever structure view happens to be on screen.

The two halves

A tab's structure editor was keyed on the table, not the tab. structureContent gave TableStructureView the identity "<database>.<schema>.<table>". Only the selected tab renders, so two tabs on one table occupied the same slot with the same explicit id, and SwiftUI updated the view in place instead of re-creating it. TableStructureView.init seeded _gridDelegate and _wrappedChangeManager through State(wrappedValue:), which runs only at the first creation of an identity, and StructureGridDelegate holds its manager in a let. So the grid went on writing into the first tab's StructureChangeManager while the computed structureChangeManager resolved to the second's. Save applied a different set of changes than the one on screen, the second tab's Add and Remove buttons did nothing, onAppear never re-ran so the inspector kept the first tab's rows, and closing the second tab never warned about edits it appeared to hold while closing the first discarded them.

Applying those edits required a mounted structure view. This is the worse half, and it is not visible from the identity bug. hasUnsavedWork reads structureSessions[tab.id], so the unsaved-changes prompt can be raised by a tab that is not showing its structure at all. The Save it offered dispatched through coordinator.structureActions, a weak slot only a mounted structure view ever fills, and then returned true unconditionally. Three ways to answer Save and lose the work:

Gesture What happened
Stage a rename, click Data, Cmd+W, Save the structure arm keyed on resultsViewMode, so it was skipped; every later branch was false; returned true and closed
Stage a rename, click the DDL sub-tab, Cmd+W, Save selectedTab != .ddl short-circuited the handler; returned true and closed
Close Other Tabs with staged ALTERs in a background tab the prompt is raised for the connection, but Save reached only the selected tab

The DDL, Parts and Triggers sub-tabs are read-only everywhere else in the file, so that guard was suppressing the save and nothing else.

The fix

applyStagedChanges moves onto StructureEditingSession, the one object that exists per tab whether or not a view does. It returns a StructureSaveOutcome whose allowsClose is false whenever the edits are still staged afterwards, so a save Safe Mode refused, one cancelled at the data-loss prompt, or one the server rejected leaves the tab open instead of closing over it. saveSelectedTabWork asks the session instead of the view mode, and the batch close walks its victims.

structureContent keys on .id(tab.id), matching the five sibling builders that cache a view model under tab.id. The session.identity == identity branch stays: flipping it is what forces a real remount when a tab is retargeted to another table.

gridDelegate, wrappedChangeManager, and the editor's own place (selectedTab, searchText, sortState, its sort descriptor and the per-sub-tab column layouts) move onto the session too. That removes the class of bug rather than this instance of it: with them on the session, nothing is seeded from an input that can change under a stable identity. It also means each tab keeps its own sub-tab, filter and sort, including across a trip through the Data view, where all five used to be discarded.

Two more discards came out of self-review, both on paths the first draft missed:

  • The window-close and quit prompt saved only the selected tab, exactly as the batch close did. Both now share one walk over the tabs the prompt asked about.
  • A tab holding both structure edits and data-grid edits lost the data edits, because applying the ALTERs returned before the data branch. It falls through now.

Also fixed, found while investigating

  • onTabRetargeted releases the per-tab caches keyed on that tab id. A retarget keeps the id and changes what it means, so the structure session survived and went on raising an unsaved-changes prompt naming the previous table.
  • selectedTabHoldsProtectedContent consults staged structure edits. Single-clicking another table reused a preview tab in place with no prompt of any kind, even one holding a renamed column. This gates FK navigation through the same property.
  • Applying a background tab's ALTERs during a close no longer raises a "Discard Changes?" sheet on the tab still on screen. Every apply broadcasts a data refresh for its scope, and DataRefreshRequest.reaches(tabScope:) matches on connection, database and schema rather than table, so a mounted structure view on the same database answered it by asking whether to throw away the edits the user had just asked to save. Suppressed for the duration of the walk by a defer-scoped flag rather than a latch.
  • TableStructureView.onDisappear and CreateTableView.onDisappear guard their clears by identity, the way the neighbouring inspectorRowSource line in each already did. SwiftUI does not order the outgoing view's onDisappear before the incoming view's onAppear, so the unguarded clear could nil the wiring the incoming tab had just installed and leave its Save, Refresh, Preview SQL, undo and footer buttons dead until the tab was left and returned to. CreateTableView is already on .id(tab.id), so it had this today.

Verification

  • build PASS
  • test 81 of 82 across the affected suites, with the wrapper reporting no unexplained failures. The one failure is CommandActionsDispatchTests/insertQueryFromAI_appendsToExisting, which is quarantined and unrelated. An earlier combined run also tripped MainContentCoordinatorRefreshTests/singleRequestRefreshHasNoTrailing, which passes 13 of 13 when its suite runs alone; it sleeps 400ms and asserts coalescer state, so it is load-sensitive rather than a regression here.
  • lint TablePro TableProTests reports three violations, all pre-existing on main and all fixed by style: satisfy the swiftlint strict gate in the operation reporting code #2293. Nothing in any file this PR touches.

New coverage in StructureEditingSessionTests: two tabs on one table stage into their own managers, a session's grid delegate writes into that session's manager, each session keeps its own place in the editor, the outcome decides whether the close may proceed, and an end-to-end apply through a mock driver with no view mounted. OpenTableTabTests gains the reuse gate and the retarget release.

StructureTabIdentityUITests covers the flow that regressed: open one table in two tabs and confirm the second does not inherit the first's sub-tab. It compiles and CI runs -only-testing:TableProUITests, but it could not be executed on this machine: the runner fails to initialize with "Authentication canceled. System authentication is running." Recording that rather than claiming it ran.

Self-review also caught two things worth naming because they were mine, not pre-existing. Moving gridDelegate onto the session created a reference cycle through the closures the view installs on it, which would have leaked the coordinator and its driver references; releaseViewWiring() breaks it at every site a session is dropped, including teardown(). And a successful apply cleared session.hasLoaded so an unmounted tab would refetch, but the mounted path never set it back, so the next remount re-ran loadInitialData and re-baselined the change manager, discarding anything staged after the save. refreshAfterApply restores it.

One thing left alone: StructureSessionBaseDriver in the new test file duplicates SchemaRoutingBaseDriver in DatabaseManagerSchemaChangeRoutingTests. Extracting a shared stub is test-only cleanup and did not belong in this diff.

Two paths are not unit-tested and cannot easily be: AlertHelper.present falls back to runModal() when no window resolves, so exercising the refused and failed outcomes through applyStagedChanges would block the suite. They are covered through StructureSaveOutcome.allowsClose and the successful end-to-end path instead.

@mintlify

mintlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

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

Project Status Preview Updated (UTC)
TablePro 🟡 Building Aug 20, 2026, 10:17 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.

@datlechin
datlechin merged commit d1f2f8b into main Aug 20, 2026
7 of 8 checks passed
@datlechin
datlechin deleted the fix/structure-editor-tab-identity branch August 20, 2026 10:18
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