Skip to content

fix(datagrid): keep a tab's grid selection across a tab or result-mode switch - #2679

Merged
datlechin merged 2 commits into
mainfrom
fix/2667-grid-selection-across-tab-switch
Sep 8, 2026
Merged

fix(datagrid): keep a tab's grid selection across a tab or result-mode switch#2679
datlechin merged 2 commits into
mainfrom
fix/2667-grid-selection-across-tab-switch

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2667.

Root cause

handleTabChange is a save-outgoing / restore-incoming pair, and for the grid selection only the restore half was ever written. MainContentCoordinator+TabSwitch.swift:93 has always done selectionState.indices = newTab.selectedRowIndices, but nothing ever wrote that field: an exhaustive grep finds one non-clearing writer, RowEditingCoordinator.pasteRows. Everything else sets it to empty. So the field was permanently empty and every switch back replayed an empty set over whatever the reader had selected.

It shipped untested because the existing test seeds the field by hand (MainContentCoordinatorTabSwitchTests.restoresIncomingSelectedRows), which exercises the half that worked.

The first attempt at a fix captured the selection in the grid's own dismantleNSView. Tracing the running app showed why that is not enough on its own: dismantle: enter fires, the closure is wired, the table view is alive, and selectedRowIndexes is already empty. On a tab switch the shared GridSelectionState is repointed at the incoming tab while the outgoing grid is still mounted and bound to it, so syncSelection clears the outgoing table view before its teardown runs. The capture therefore happens in handleTabChange, before the repoint, and the teardown capture is kept only for the case it is authoritative for: a result-mode switch, which destroys the grid without changing tabs.

What this changes

  • QueryTab gains cellSelection beside selectedRowIndices, and selectedDisplayRows derives the row span from whichever is authoritative. Storing rows alone would be wrong, not merely incomplete: publishRowSelection projects a cell rectangle down to affectedRows, which is lossy and not invertible, so restoring from it would widen a three-column block into whole rows and make Copy copy whole rows.
  • Capture on a tab switch (handleTabChange), on a result-mode switch (dismantleNSView, gated on the tab still being selected), and when a tab is handed to another window (enrichedForPersistence, which snapshots before the grid is torn down and whose own call site already claimed to carry the selection).
  • Restore in updateNSView after applyStructuralUpdate, never before: that pass ends in selectionController.clear() and reloadData().
  • GridSelectionRestore is the pure, tested clamp: rows against the row count, the rectangle against the presented data column count.
  • GridCoord, GridRect and GridSelection move from Views/Results/Selection/ to Models/UI/, since a model type now stores one. They are Foundation-only value types.
  • The viewport and selection handover on TableViewCoordinator moves into DataGridView+MountState.swift; the class was one line under the 1100-line type-body limit.

Invalidation was already correct and is extended rather than replaced: resetSelectionForNewResult now clears both halves, a retarget clears both, a paste supersedes a stored rectangle, and hiding a column clears a stored rectangle whose display positions it renumbers.

Measured AppKit behaviour this rests on

A swiftc probe against the Xcode-beta toolchain, run twice:

  • selectRowIndexes(_:byExtendingSelection:) is all-or-nothing on an out-of-range member. On 20 rows, {5, 20} and {5, 999} both give []. It does not clamp and does not keep the valid members, so an unclamped restore selects nothing at all rather than the rows that do still exist.
  • reloadData() clears selectedRowIndexes unconditionally, including when the row count is unchanged, and fires no tableViewSelectionDidChange. This contradicted the doc comment on TableViewCoordinator.clearRowSelection, which is corrected here: the deselect is what publishes the change, not what makes it.
  • removeFromSuperview and re-adding preserve the selection, so the defect is object destruction, not detachment.
  • autosaveTableColumns covers width, order, sort and hidden state, never selection, and encodeRestorableState is relaunch-scoped. There is no AppKit mechanism to adopt here.

Review

Two codex review passes on the branch produced eleven findings; all were acted on. The substantive ones: the restore read the shared channel rather than the tab's own rows, so a Data → Structure → Data round trip resolved schema-grid positions; it selected every row a rectangle covered, which AppKit fills edge to edge and which DataGridRowView then skips its partial fill on, painting the block as whole rows; and it wrote a GridCoord.displayColumn straight into KeyHandlingTableView.focusedColumn, which indexes tableColumns and so pointed the cell cursor at the row-number column or a spacer, against the CLAUDE.md invariant that no fixed position in tableColumns names a data column.

One finding was not acted on: the teardown capture publishes observable state during SwiftUI reconciliation. flushPendingColumnLayoutPersistence already does exactly this from the same dismantleNSView, reaching the same tabManager.mutate, so this follows the established path rather than opening a new one. The write is also skipped entirely when nothing changed.

Not done

Selection is memory-only; PersistedTab gains no field. Display positions are only meaningful against an identical sort, filter and page, which is the hazard behind the "Selection indices are display positions" invariant (#1837). It is also not converted to stable RowID identity: resetSelectionForNewResult already drops the selection whenever rows are replaced, and a background tab's display order cannot move under it.

Verification

All on this branch, in a worktree at the merge base:

  • build PASS
  • test PASS across the suites owning the changed types: 92 cases over GridSelectionRestoreTests, MainContentCoordinatorGridSelectionTests, MainContentCoordinatorSelectionResetTests, MainContentCoordinatorTabSwitchTests, DataGridSelectionTests, PublishedRowSelectionTests, CoordinatorColumnVisibilityTests, GridSelectionOwnerTests; and 175 cases over those plus the tab-lifecycle neighbours (RecentlyClosedTabStoreTests, TabCloseProtectionTests, MainContentCoordinatorLazyLoadTests, MainContentCoordinatorDisplayStateTests, FKNavigationTests, CancelledExecutionOwnershipTests, CommandActionsDispatchTests, DisplayedResultReaderTests).
  • uitest GridSelectionAcrossTabsUITests PASS, run four times. It failed against the unfixed code with Expected 1 of 1.000 rows selected, got 1-1.000 of 3.503 rows, which is the reported symptom, so it is a real regression guard rather than a test that happens to pass.
  • swiftlint --strict clean for this change. Two legacy_swiftui_aspect_ratio errors remain in ImportFromAppSourcePicker.swift and SupportView.swift; both are pre-existing on main and untouched here.

No screenshots: the change is a state-lifetime fix with no new or altered chrome. What a reviewer would look at is a selection highlight before and after a tab switch, which is what the UI test asserts through the status readout.

https://claude.ai/code/session_01STT2h6Y53XJah8xPXAH42L

@datlechin
datlechin merged commit c07c651 into main Sep 8, 2026
9 checks passed
@datlechin
datlechin deleted the fix/2667-grid-selection-across-tab-switch branch September 8, 2026 17:12
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.

Row selection in tabs disappears

1 participant