Skip to content

fix(structure): row visual state, per-cell modified tint, native NSTableView refresh, row-specific undo delete#1189

Merged
datlechin merged 1 commit intomainfrom
fix/structure-tab-comprehensive-bundle
May 10, 2026
Merged

fix(structure): row visual state, per-cell modified tint, native NSTableView refresh, row-specific undo delete#1189
datlechin merged 1 commit intomainfrom
fix/structure-tab-comprehensive-bundle

Conversation

@datlechin
Copy link
Copy Markdown
Member

@datlechin datlechin commented May 10, 2026

Summary

Comprehensive Structure-tab fix bundle. Eight user-visible bugs and the architectural seams they exposed.

User-reported bugs (root cause + fix)

  1. Double-click and Return on dropdown / type-picker columns did nothing. editEligibility blocked dropdown / typePicker columns from inline edit, so the gestures fell through to a no-op. Removed the block; the cell body now opens the inline text editor (so the user can type a value the picker doesn't list, e.g. a custom column type), and the chevron button continues to open the picker. Mirrors NSComboBox semantics: text-editable field plus chevron with predefined options.

  2. Cmd+Z left the yellow modified tint in place after the change reverted. TableStructureView only observed hasChanges; mutations that did not toggle that flag never refreshed the grid. View now also observes reloadVersion and dataGridUndo / dataGridRedo ask NSTableView to redraw visible rows.

  3. Pressing Delete on a row only marked the focused cell red, not the full row. StructureRowViewWithMenu extended NSTableRowView directly and had no applyVisualState / drawBackground. Now subclasses DataGridRowView so it inherits the deleted-row tint, layer-backing optimization, and cell-emphasis invalidation. tableView(_:rowViewForRow:) also applies the visual state to delegate-provided row views so recycled rows pick up state changes.

  4. Cmd+Shift+N added a row to the change manager + SQL Preview but the grid kept the old row count. MainContentCommandActions.addNewRow always routed to the data-tab RowEditingCoordinator. Now branches on resultsViewMode == .structure and dispatches to the structure grid delegate.

  5. Editing a cell did not update the displayed value. tableRowsProvider: { tableRows } captured a snapshot at body-evaluation time. After an edit, tableView.reloadData(forRowIndexes:) re-rendered the cell from the stale source. The provider is now a closure that rebuilds via makeCurrentProvider().asTableRows() on every call (mirrors the data tab's coordinator.tabSessionRegistry.existingTableRows(for:) pattern).

  6. Save and discard left the yellow modified tint in place. loadSchema resets working state and bumps reloadVersion, but DataGridView.updateNSView only calls reloadData when row count or column schema changes. A column rename leaves the row count identical, so cells kept their pre-save visual state. Save and discard paths now call gridDelegate.reloadAllVisibleRows() after the manager resets.

  7. Editing one cell tinted the entire row yellow. StructureChangeManager.getVisualState populated modifiedColumns with a hardcoded Set(0..<6) (over-counting in MySQL with charset/collation, under-counting elsewhere). Replaced with a real per-field diff: StructureEditingSupport gains columnModifiedIndices / indexModifiedIndices / foreignKeyModifiedIndices helpers; the delegate's dataGridVisualState(forRow:) calls them with orderedFields so only the changed cells get the yellow tint.

  8. Right-click "Undo Delete" undid the wrong action. The closure called dataGridUndo() (global Cmd+Z), so if the user deleted column A, then renamed column B, "Undo Delete" on A undid the rename of B. Added StructureChangeManager.undoDelete(for:at:) that clears the per-row deletion mark without touching the global NSUndoManager stack. Mirrors DataChangeManager.undoRowDeletion(rowIndex:). Right-click and Cmd+Z are now independent affordances.

  9. CreateTable pickers rendered as plain text. CreateTableView constructed DataGridConfiguration without customDropdownOptions, so on-delete / on-update / index-type cells showed plain text without the picker. Now passed correctly.

Architectural changes (forced into view by the bugs)

  • Shared NSTableView refresh primitive. TableViewCoordinator now exposes reloadVisibleRowsAndStates() and reloadRowAndState(at:) that pair reloadData(forRowIndexes:) with enumerateAvailableRowViews + applyVisualState. This is Apple's documented two-layer pattern: reloadData(forRowIndexes:) re-fetches cells but does not touch row views, so per-row decoration (deleted/inserted tint, deleted-row context menu state) needs the second pass. Both delegates use the same primitive instead of each duplicating only-half of the contract.

  • RowVisualState as the row-view source of truth. DataGridRowView now stores visualState: RowVisualState and derives rowTint from it. StructureRowViewWithMenu reads visualState.isDeleted directly in menu(for:) instead of caching a shadow isRowDeleted flag that was only assigned on row-view creation. RowVisualState also gains Equatable conformance and applyVisualState short-circuits on no change.

  • Private UndoManager per StructureChangeManager instance. The window has no NSDocument-backed undoManager and no view in the responder chain provides one, so wiring through the responder chain silently no-ops. Cmd+Z is dispatched by the app's own .commands block in TableProApp to MainContentCommandActions.undoChange(), which calls into the manager directly. The private UndoManager dies with the manager, so the prior _NSUndoStack popAndInvoke crash on stale shared state is structurally impossible.

Dead code removed

  • StructureChangeManager.updatePrimaryKey(_:) (zero callers).
  • StructureRowProvider.updateValue(_:at:columnIndex:), appendRow(_:), removeRow(at:) (three stub no-ops with zero callers).
  • StructureChangeManager.getVisualState(for:tab:), visualStateCache, rebuildVisualStateCache, VisualStateCacheKey and 15 cache-rebuild call sites. The cache was load-bearing only for the broken whole-row-tint logic; per-call recomputation is sub-millisecond at expected table sizes.
  • RowVisualState.isFullRowModified (a workaround for the hardcoded Set(0..<6) that the per-field diff replaces).

Tests

StructureEditingSupportFieldDiffTests covers the three diff helpers and undoDelete(for:at:):

  • columnModifiedIndices: identical inputs produce empty set; rename flags only the name index; two-field edit flags exactly those indices; fields missing from orderedFields (e.g., collation in PostgreSQL) are correctly skipped; all 9 cases of StructureColumnField are diffable.
  • indexModifiedIndices: identical inputs empty; column-prefix-only change flags index 1; unique toggle flags index 3; intentionally-excluded isPrimary and comment produce no indices.
  • foreignKeyModifiedIndices: identical inputs empty; on-delete / on-update flag independently; all 7 grid columns are covered.
  • undoDelete(for:at:): clears delete marks; ignores non-delete pending changes; bounds-checks; no-ops on .ddl / .parts.

Out of scope (future PRs)

  • applyColumnDeleteUndo clears pendingChanges entirely, losing a prior .modifyColumn if the user edited a column then deleted it then Cmd+Z'd. The new visual-state logic correctly surfaces the inconsistency (yellow tint on the still-modified cell), but the manager-level undo behavior is the actual bug.
  • ChangeManaging protocol over-specifies for StructureChangeManager (data-specific operations stubbed as no-ops). Should split into base + DataChangeManaging sub-protocol.
  • HIG cleanup: Cmd+C shadow on "Copy Name", "Copy As → SQL" duplicating "Copy Definition", + / - toolbar buttons, ContentUnavailableView for empty Indexes / FK tabs, 7 unlocalized strings, DDLTextView blank-rectangle empty handling.
  • ClickHousePartsView builds raw ALTER TABLE SQL inline, bypassing the plugin driver DDL layer.
  • StructureMenuTarget lives in Views/Structure/ but is referenced from Views/Main/Child/DataTabGridDelegate.swift. Type ownership is in the wrong folder.

Files

File Change
Core/SchemaTracking/StructureChangeManager.swift Drop updatePrimaryKey, getVisualState, visualStateCache, rebuildVisualStateCache. Add deleteInsertState(for:tab:) and undoDelete(for:at:).
Models/Schema/... (no diff) EditableColumnDefinition etc. unchanged; relied on for diff helpers.
Views/Results/DataGridCoordinator.swift Add reloadVisibleRowsAndStates() and reloadRowAndState(at:). invalidateCachesForUndoRedo collapses to one call.
Views/Results/DataGridRowView.swift Drop final. Store visualState. Make applyVisualState idempotent.
Views/Results/DataGridView.swift Drop isFullRowModified. RowVisualState: Equatable.
Views/Results/Cells/DataGridCellView.swift Tint check uses state.visualState.isModified(columnIndex:).
Views/Results/Extensions/DataGridView+Columns.swift tableView(_:rowViewForRow:) applies visual state to delegate row views.
Views/Results/Extensions/DataGridView+Editing.swift Remove dropdown/typePicker block from editEligibility.
Views/Results/KeyHandlingTableView.swift Return on a focused dropdown cell opens the picker instead of falling through to a blocked inline edit.
Views/Structure/StructureChangeManager.swift (see Core)
Views/Structure/StructureEditingSupport.swift Add columnModifiedIndices / indexModifiedIndices / foreignKeyModifiedIndices field-diff helpers.
Views/Structure/StructureGridDelegate.swift Wire attachedCoordinator. dataGridDidEditCell and dataGridDeleteRows reload via shared primitive. dataGridVisualState(forRow:) computes per-cell modified columns. onUndoDelete calls undoDelete(for:at:) then reloadRowAndState(at:).
Views/Structure/CreateTableGridDelegate.swift Same wiring; adds dataGridVisualState(forRow:) for inserted-row tint.
Views/Structure/CreateTableView.swift Pass customDropdownOptions. Rebuild row snapshot fresh per call.
Views/Structure/StructureRowViewWithMenu.swift Subclass DataGridRowView. Drop isRowDeleted shadow flag. Read visualState.isDeleted in menu. onUndoDelete: ((Int) -> Void)? takes the row index.
Views/Structure/StructureRowProvider.swift Drop three stub no-op methods.
Views/Structure/StructureViewActionHandler.swift Add addRow closure.
Views/Structure/TableStructureView.swift Observe reloadVersion. Wire actionHandler.addRow. Rebuild row snapshot fresh per call.
Views/Structure/TableStructureView+Schema.swift Save and discard paths call reloadAllVisibleRows(). Generate-preview clears stale SQL on empty change set.
Views/Main/MainContentCommandActions.swift addNewRow branches on resultsViewMode == .structure.
TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift New: 11 tests covering field-diff helpers + undoDelete(for:at:).

Test plan

Manual:

  • Open a table's Structure tab. Edit each cell type via double-click, Return, click-after-focus. Text columns open the editor; dropdown / picker columns: chevron opens the popup, body opens the editor.
  • Edit user_id to user_idd. Only the Name cell tints yellow.
  • Edit Type only. Only the Type cell tints.
  • Edit Name and Type together. Both cells tint.
  • Cmd+Z. Both cells un-tint.
  • Press Delete on a row. Whole row turns red, all cells gray with strikethrough.
  • Right-click the deleted row, choose "Undo Delete". That specific row un-deletes.
  • Delete column A, rename column B, right-click A, "Undo Delete". A un-deletes; B stays renamed.
  • Cmd+Shift+N when changes already exist. New row appears in the grid.
  • Save changes. Yellow tints clear.
  • Discard changes. Yellow tints clear.
  • Open + New Table. Set type to INT. Set On Delete to CASCADE. Both pickers open as dropdowns.

Automated: xcodebuild test -only-testing:TableProTests/StructureEditingSupportFieldDiffTests and xcodebuild test -only-testing:TableProTests/StructureChangeManagerUndoDeleteTests.

swiftlint lint --strict clean on changed files.

@datlechin datlechin force-pushed the fix/structure-tab-comprehensive-bundle branch 6 times, most recently from 153ae71 to 7df11f3 Compare May 10, 2026 13:32
…nc, Add Row routing, and window NSUndoManager
@datlechin datlechin force-pushed the fix/structure-tab-comprehensive-bundle branch from 7df11f3 to 3ad6a04 Compare May 10, 2026 14:03
@datlechin datlechin changed the title fix(structure): restore edit-mode parity, row visual state, undo desync, Add Row routing, and window NSUndoManager fix(structure): row visual state, per-cell modified tint, native NSTableView refresh, row-specific undo delete May 10, 2026
@datlechin datlechin merged commit 731bf5c into main May 10, 2026
2 checks passed
@datlechin datlechin deleted the fix/structure-tab-comprehensive-bundle branch May 10, 2026 14:10
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