diff --git a/CHANGELOG.md b/CHANGELOG.md index 85699276e..94f553e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Structure tab: double-click and Return on dropdown / type-picker columns (Nullable, Primary Key, Auto Increment, Unique, On Delete, On Update, Type) now enter inline text edit so the user can type a value the picker doesn't list (e.g. a custom column type, a numeric default). Previously these gestures appeared to do nothing because `editEligibility` blocked dropdown/type-picker columns from inline edit. The chevron button continues to open the picker; the cell body opens the text editor. +- Structure tab: pressing Cmd+Shift+N (or any path that adds a column / index / foreign key while changes already exist) now displays the new row in the grid. Previously the row was added to the change manager and the SQL Preview reflected it, but the grid kept the old row count because `TableStructureView` only observed `hasChanges` (which had already been true). The view now also observes `reloadVersion` and re-evaluates the grid snapshot on every mutation. +- Structure tab: pressing Delete on a row now turns the entire row red, not just the focused cell. `StructureRowViewWithMenu` inherits from `DataGridRowView` so it gets the deleted-row tint, layer-backing optimization, and the cell-emphasis invalidation that all data-tab rows already had. The grid's `tableView(_:rowViewForRow:)` also now applies the visual state to delegate-provided row views, so recycled rows pick up state changes. `StructureGridDelegate.dataGridDeleteRows` calls `tableView.reloadData(forRowIndexes:columnIndexes:)` for visible rows so the soft-deleted row repaints (existing-column deletes leave the row in place with a red tint, so row count alone doesn't trigger a reload). +- Structure tab: editing a cell now shows the new value in that cell and tints only the cells the user actually edited, matching the data tab. Previously the change manager recorded the edit (SQL Preview reflected it, partial yellow tint appeared), but the cell still displayed the pre-edit value and the tint cut off mid-row, then the prior fix tinted the entire row even when only one field changed. Two fixes: (1) `tableRowsProvider` is now a closure that rebuilds the snapshot from the change manager on every call (mirrors the data tab's `coordinator.tabSessionRegistry.existingTableRows(for:)` pattern), so `tableView.reloadData(forRowIndexes:)` after an edit re-fetches the post-edit row. (2) Per-cell modified tinting is computed by diffing the working entity against `currentColumns` / `currentIndexes` / `currentForeignKeys` field-by-field, mapped to grid column indices via the tab's `orderedFields`. `StructureEditingSupport` gains `columnModifiedIndices`, `indexModifiedIndices`, and `foreignKeyModifiedIndices` helpers; `StructureGridDelegate.dataGridVisualState(forRow:)` calls them and only the changed cells get the yellow tint. `StructureGridDelegate.dataGridAttach(tableViewCoordinator:)` and `CreateTableGridDelegate.dataGridAttach(tableViewCoordinator:)` store the grid coordinator weakly so the delegate can issue the targeted reload after edits, soft-deletes, undo, and redo. +- Structure tab: Cmd+Z no longer leaves the row's yellow modified background in place after the change reverts. The view observes `reloadVersion` to bump `displayVersion`, and `dataGridUndo` / `dataGridRedo` ask the table view to reload its visible rows so the cell text and modified-tint follow the change manager. The SQL Preview popover also clears its content when the change set returns to empty, so reopening the popover after undo correctly shows "no changes" instead of stale SQL. +- Structure tab: saving or discarding changes now clears the yellow modified tint on the affected rows. After save, `StructureChangeManager.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. The save and discard paths in `TableStructureView+Schema` now call the new `StructureGridDelegate.reloadAllVisibleRows()` after the manager resets. +- Structure tab: deleted rows now paint the red strikethrough tint and right-click "Undo Delete" undeletes the specific row, matching the data tab. `NSTableView.reloadData(forRowIndexes:columnIndexes:)` only refreshes cell views, not row views, so the per-row deleted-state tint and context-menu state went stale after every model mutation. `TableViewCoordinator` now exposes `reloadVisibleRowsAndStates()` and `reloadRowAndState(at:)` that pair `reloadData(forRowIndexes:)` with `enumerateAvailableRowViews` and `applyVisualState`, so cells and row decoration refresh together. `DataGridRowView` stores the full `RowVisualState` as the source of truth, and `StructureRowViewWithMenu` reads `visualState.isDeleted` directly instead of a shadow `isRowDeleted` flag that was only assigned on row-view creation. `StructureChangeManager.undoDelete(for:at:)` clears the per-row deletion mark without touching the global NSUndoManager stack, so the right-click affordance and Cmd+Z stay independent, matching the separation the data tab already uses. +- Cmd+Shift+N (Add Row) on a Structure tab no longer routes to the data-tab row-editing coordinator (which silently did nothing or added a data row). `MainContentCommandActions.addNewRow` now branches on `resultsViewMode == .structure` and dispatches to the Structure grid delegate. +- Create Table: foreign-key on-delete / on-update referential-action dropdowns and the index-type dropdown now render as dropdowns. Previously `CreateTableView` constructed `DataGridConfiguration` without `customDropdownOptions`, so those columns showed plain text values without the picker affordance. - iOS: row detail pager no longer carries the index-based row iteration that caused the build 11 filter crash. A recent refactor reintroduced the pattern; row detail now uses the same identity-based row wrapping the data browser does. - Tables in the sidebar now load automatically after a slow connect. SQL Server connections previously showed "No Tables" until the user manually picked a schema; the same race could affect other engines on slow networks. The post-connect listener now triggers the schema load once the driver is bound. - SQL Server `switchDatabase` now actually switches the database (`USE `) instead of being routed through a schema switch. Switching from a saved tab pointing at a different database used to overwrite the current schema with the database name and leave the table list empty until the user manually re-picked a schema. diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 4a992c33e..48959e163 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -35,12 +35,20 @@ final class StructureChangeManager: ChangeManaging { // MARK: - Undo/Redo Support + /// Private `NSUndoManager` owned by this change manager. Each + /// `StructureChangeManager` instance has its own, so the registered actions + /// can never outlive the manager (the UndoManager is freed when the manager + /// is deallocated, taking its action queue with it). The app does not have + /// an NSDocument-backed `NSWindow.undoManager`, and no view in the + /// responder chain provides one, so wiring this through the window would + /// silently no-op. Cmd+Z is routed by the app's own `.commands` block in + /// `TableProApp` to `MainContentCommandActions.undoChange()`, which checks + /// the active tab's `resultsViewMode` and calls into this manager directly. private let undoManager: UndoManager = { let manager = UndoManager() manager.levelsOfUndo = 100 return manager }() - private var visualStateCache: [VisualStateCacheKey: RowVisualState] = [:] var canUndo: Bool { undoManager.canUndo } var canRedo: Bool { undoManager.canRedo } @@ -129,7 +137,6 @@ final class StructureChangeManager: ChangeManaging { undoManager.setActionName(String(localized: "Add Column")) validate() reloadVersion += 1 - rebuildVisualStateCache() } func addNewIndex() { @@ -144,7 +151,6 @@ final class StructureChangeManager: ChangeManaging { undoManager.setActionName(String(localized: "Add Index")) validate() reloadVersion += 1 - rebuildVisualStateCache() } func addNewForeignKey() { @@ -159,7 +165,6 @@ final class StructureChangeManager: ChangeManaging { undoManager.setActionName(String(localized: "Add Foreign Key")) validate() reloadVersion += 1 - rebuildVisualStateCache() } // MARK: - Paste Operations (public methods for adding copied items) @@ -174,7 +179,6 @@ final class StructureChangeManager: ChangeManaging { } undoManager.setActionName(String(localized: "Add Column")) reloadVersion += 1 - rebuildVisualStateCache() } func addIndex(_ index: EditableIndexDefinition) { @@ -187,7 +191,6 @@ final class StructureChangeManager: ChangeManaging { } undoManager.setActionName(String(localized: "Add Index")) reloadVersion += 1 - rebuildVisualStateCache() } func addForeignKey(_ foreignKey: EditableForeignKeyDefinition) { @@ -200,7 +203,6 @@ final class StructureChangeManager: ChangeManaging { } undoManager.setActionName(String(localized: "Add Foreign Key")) reloadVersion += 1 - rebuildVisualStateCache() } // MARK: - Column Operations @@ -238,7 +240,6 @@ final class StructureChangeManager: ChangeManaging { validate() reloadVersion += 1 - rebuildVisualStateCache() } func deleteColumn(id: UUID) { @@ -265,7 +266,6 @@ final class StructureChangeManager: ChangeManaging { validate() reloadVersion += 1 - rebuildVisualStateCache() } // MARK: - Index Operations @@ -303,7 +303,6 @@ final class StructureChangeManager: ChangeManaging { validate() reloadVersion += 1 - rebuildVisualStateCache() } func deleteIndex(id: UUID) { @@ -330,7 +329,6 @@ final class StructureChangeManager: ChangeManaging { validate() reloadVersion += 1 - rebuildVisualStateCache() } // MARK: - Foreign Key Operations @@ -368,7 +366,6 @@ final class StructureChangeManager: ChangeManaging { validate() reloadVersion += 1 - rebuildVisualStateCache() } func deleteForeignKey(id: UUID) { @@ -395,32 +392,38 @@ final class StructureChangeManager: ChangeManaging { validate() reloadVersion += 1 - rebuildVisualStateCache() } - // MARK: - Primary Key Operations + // MARK: - Row-Specific Undo Delete - func updatePrimaryKey(_ columns: [String]) { - // Push undo action before modifying - if columns != workingPrimaryKey { - let oldPK = workingPrimaryKey - undoManager.registerUndo(withTarget: self) { target in - target.applySchemaUndo(.primaryKeyChange(old: oldPK, new: columns)) - } - undoManager.setActionName(String(localized: "Change Primary Key")) - } - - let key = SchemaChangeIdentifier.primaryKey - if columns != currentPrimaryKey { - pendingChanges[key] = .modifyPrimaryKey(old: currentPrimaryKey, new: columns) - trackChangeKey(key) - } else { - pendingChanges.removeValue(forKey: key) - untrackChangeKey(key) - } - - workingPrimaryKey = columns + /// Clear the deletion mark for the entity at `row` in `tab`. Mirrors + /// `DataChangeManager.undoRowDeletion(rowIndex:)`: the global NSUndoManager + /// stack is intentionally left alone. The original `applySchemaUndo(...)` + /// handler the deletion registered remains on the stack; if global Cmd+Z + /// later invokes it, the handler finds `pendingChanges` no longer marks + /// this row as deleted and treats the redo as a no-op for this entity. The + /// row-specific affordance and the global undo stack are independent + /// affordances. The data tab uses the same separation. + func undoDelete(for tab: StructureTab, at row: Int) { + let key: SchemaChangeIdentifier + switch tab { + case .columns: + guard row < workingColumns.count else { return } + key = .column(workingColumns[row].id) + case .indexes: + guard row < workingIndexes.count else { return } + key = .index(workingIndexes[row].id) + case .foreignKeys: + guard row < workingForeignKeys.count else { return } + key = .foreignKey(workingForeignKeys[row].id) + case .ddl, .parts: + return + } + guard pendingChanges[key]?.isDelete == true else { return } + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) validate() + reloadVersion += 1 } // MARK: - Validation @@ -520,7 +523,6 @@ final class StructureChangeManager: ChangeManaging { validationErrors.removeAll() resetWorkingState() reloadVersion += 1 - rebuildVisualStateCache() undoManager.removeAllActions() } @@ -566,7 +568,6 @@ final class StructureChangeManager: ChangeManaging { validate() reloadVersion += 1 - rebuildVisualStateCache() } private func applyColumnEditUndo(id: UUID, old: EditableColumnDefinition, new: EditableColumnDefinition) { @@ -775,77 +776,38 @@ final class StructureChangeManager: ChangeManaging { // MARK: - Visual State Management - func getVisualState(for row: Int, tab: StructureTab) -> RowVisualState { - let cacheKey = VisualStateCacheKey(tab: tab, row: row) - if let cached = visualStateCache[cacheKey] { - return cached - } - - let state: RowVisualState - + /// Per-row delete/insert flags. Modified-column tinting is computed by the + /// `StructureGridDelegate` because it requires the tab's `orderedFields` + /// (which depends on the database type and is a UI concern). The delegate + /// merges the result of this method with `modifiedColumns` from + /// `StructureEditingSupport` field-diff helpers to build the final + /// `RowVisualState`. + func deleteInsertState(for row: Int, tab: StructureTab) -> (isDeleted: Bool, isInserted: Bool) { switch tab { case .columns: - guard row < workingColumns.count else { return .empty } + guard row < workingColumns.count else { return (false, false) } let column = workingColumns[row] let change = pendingChanges[.column(column.id)] - let isDeleted = change?.isDelete ?? false let isInserted = !currentColumns.contains(where: { $0.id == column.id }) - let isModified = change != nil && !isDeleted && !isInserted - - state = RowVisualState( - isDeleted: isDeleted, - isInserted: isInserted, - modifiedColumns: isModified ? Set(0..<6) : [] - ) - + return (isDeleted, isInserted) case .indexes: - guard row < workingIndexes.count else { return .empty } + guard row < workingIndexes.count else { return (false, false) } let index = workingIndexes[row] let change = pendingChanges[.index(index.id)] - let isDeleted = change?.isDelete ?? false let isInserted = !currentIndexes.contains(where: { $0.id == index.id }) - let isModified = change != nil && !isDeleted && !isInserted - - state = RowVisualState( - isDeleted: isDeleted, - isInserted: isInserted, - modifiedColumns: isModified ? Set(0..<5) : [] - ) - + return (isDeleted, isInserted) case .foreignKeys: - guard row < workingForeignKeys.count else { return .empty } + guard row < workingForeignKeys.count else { return (false, false) } let fk = workingForeignKeys[row] let change = pendingChanges[.foreignKey(fk.id)] - let isDeleted = change?.isDelete ?? false let isInserted = !currentForeignKeys.contains(where: { $0.id == fk.id }) - let isModified = change != nil && !isDeleted && !isInserted - - state = RowVisualState( - isDeleted: isDeleted, - isInserted: isInserted, - modifiedColumns: isModified ? Set(0..<7) : [] - ) - - case .ddl: - state = .empty - case .parts: - state = .empty + return (isDeleted, isInserted) + case .ddl, .parts: + return (false, false) } - - visualStateCache[cacheKey] = state - return state - } - - func rebuildVisualStateCache() { - visualStateCache.removeAll() - } - - private struct VisualStateCacheKey: Hashable { - let tab: StructureTab - let row: Int } // MARK: - ChangeManaging Conformance (Data-Specific No-Ops) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 5f31c267e..29324a8dd 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -185,7 +185,15 @@ final class MainContentCommandActions { // MARK: - Row Operations (Group A — Called Directly) func addNewRow() { - coordinator?.addNewRow() + // The structure tab routes through StructureGridDelegate, which inserts + // a column / index / FK row depending on the active Structure sub-tab. + // The data tab routes through MainContentCoordinator.addNewRow which + // calls RowEditingCoordinator.addNewRow (data-only). + if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { + coordinator?.structureActions?.addRow?() + } else { + coordinator?.addNewRow() + } } func deleteSelectedRows(rowIndices: Set? = nil) { diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 346ca1035..96523c294 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -139,7 +139,7 @@ final class DataGridCellView: NSView { let nextTint: NSColor? if state.visualState.isDeleted || state.visualState.isInserted { nextTint = nil - } else if state.visualState.modifiedColumns.contains(state.columnIndex) { + } else if state.visualState.isModified(columnIndex: state.columnIndex) { nextTint = palette.modifiedColumnTint } else { nextTint = nil diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 4133984dc..70d2ce004 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -423,6 +423,17 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func invalidateCachesForUndoRedo() { invalidateAllDisplayCaches() updateCache() + reloadVisibleRowsAndStates() + } + + /// Repaint visible rows in two layers Apple's NSTableView contract requires: + /// `reloadData(forRowIndexes:columnIndexes:)` re-fetches cells via + /// `tableView(_:viewFor:row:)` but does not touch row views, so per-row + /// decoration (deleted/inserted tint, deleted-row context menu state) goes + /// stale. `enumerateAvailableRowViews` then visits each live `NSTableRowView` + /// so `applyVisualState` can mutate row-level state without recreating views. + /// Both delegates call this after model mutations that don't change row count. + func reloadVisibleRowsAndStates() { guard let tableView else { return } let visibleRange = tableView.rows(in: tableView.visibleRect) guard visibleRange.length > 0 else { return } @@ -433,6 +444,18 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData refreshVisibleRowVisualStates() } + /// Single-row equivalent of `reloadVisibleRowsAndStates` for cases where + /// only one row's content + visual state changed (cell edit, single-row + /// undo delete). + func reloadRowAndState(at row: Int) { + guard let tableView, row >= 0, row < tableView.numberOfRows else { return } + tableView.reloadData( + forRowIndexes: IndexSet(integer: row), + columnIndexes: IndexSet(integersIn: 0.. + func isModified(columnIndex: Int) -> Bool { + modifiedColumns.contains(columnIndex) + } + static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index f224a2b93..40d221adf 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -117,6 +117,13 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { if let delegateRowView = delegate?.dataGridRowView(for: tableView, row: row, coordinator: self) { + // Delegate-provided row views (e.g. StructureRowViewWithMenu) must still + // pick up the deleted/inserted/modified tint. Apply the visual state if + // the row view subclasses DataGridRowView; otherwise the delegate is + // responsible for its own visual state. + if let dataGridRow = delegateRowView as? DataGridRowView { + dataGridRow.applyVisualState(visualState(for: row)) + } return delegateRowView } let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? DataGridRowView) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index c06105fbc..8f4eaee79 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -32,8 +32,12 @@ extension TableViewCoordinator { } } - if dropdownColumns?.contains(columnIndex) == true { return .blocked } - if typePickerColumns?.contains(columnIndex) == true { return .blocked } + // Note: dropdown / type-picker columns are deliberately *not* blocked + // here. The chevron button opens the popup, while the cell body still + // accepts inline text edit so the user can type a value the picker + // doesn't list (custom column type, numeric default, etc). Compare + // NSComboBox: the field is text-editable AND the chevron lists + // predefined options. let value: String if let displayRow = displayRow(at: row), diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index eb0a35a64..31bd3a997 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -200,17 +200,27 @@ final class KeyHandlingTableView: NSTableView { DataGridView.isDataTableColumn(focusedColumn), coordinator?.isEditable == true, let schema = coordinator?.identitySchema, - let columnIndex = DataGridView.dataColumnIndex(for: focusedColumn, in: self, schema: schema) else { + let columnIndex = DataGridView.dataColumnIndex(for: focusedColumn, in: self, schema: schema), + let coordinator else { return } - if let value = coordinator?.cellValue(at: row, column: columnIndex), + // Dropdown / type-picker columns: Return opens the popup, matching the + // chevron and double-click paths. Without this branch, Return on a focused + // dropdown cell does nothing because beginCellEdit is blocked by editEligibility. + if coordinator.dropdownColumns?.contains(columnIndex) == true || + coordinator.typePickerColumns?.contains(columnIndex) == true { + coordinator.handleChevronAction(row: row, columnIndex: columnIndex) + return + } + + if let value = coordinator.cellValue(at: row, column: columnIndex), value.containsLineBreak { - coordinator?.showOverlayEditor(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex, value: value) + coordinator.showOverlayEditor(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex, value: value) return } - coordinator?.beginCellEdit(row: row, tableColumnIndex: focusedColumn) + coordinator.beginCellEdit(row: row, tableColumnIndex: focusedColumn) } @objc override func cancelOperation(_ sender: Any?) { diff --git a/TablePro/Views/Structure/CreateTableGridDelegate.swift b/TablePro/Views/Structure/CreateTableGridDelegate.swift index a21ff862f..a018d0d54 100644 --- a/TablePro/Views/Structure/CreateTableGridDelegate.swift +++ b/TablePro/Views/Structure/CreateTableGridDelegate.swift @@ -17,6 +17,13 @@ final class CreateTableGridDelegate: DataGridViewDelegate { var onSelectedRowsChanged: ((Set) -> Void)? var orderedFields: [StructureColumnField] = [] + /// Captured from `DataGridView.updateNSView` so we can ask `NSTableView` to + /// reload affected rows after a state mutation. Required because the + /// SwiftUI re-render driven by `reloadVersion` only triggers a full + /// `reloadData` when row count or column schema changes; cell-content edits + /// alone won't redraw without this targeted reload. + private weak var attachedCoordinator: TableViewCoordinator? + init( structureChangeManager: StructureChangeManager, structureTab: StructureTab, @@ -29,6 +36,10 @@ final class CreateTableGridDelegate: DataGridViewDelegate { // MARK: - DataGridViewDelegate + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + attachedCoordinator = tableViewCoordinator + } + func dataGridDidEditCell(row: Int, column: Int, newValue: String?) { guard column >= 0 else { return } @@ -54,6 +65,21 @@ final class CreateTableGridDelegate: DataGridViewDelegate { default: break } + + reloadDisplayRow(row) + } + + private func reloadDisplayRow(_ displayRow: Int) { + attachedCoordinator?.reloadRowAndState(at: displayRow) + } + + private func reloadAllVisibleRows() { + attachedCoordinator?.reloadVisibleRowsAndStates() + } + + func dataGridVisualState(forRow row: Int) -> RowVisualState? { + let (isDeleted, isInserted) = structureChangeManager.deleteInsertState(for: row, tab: structureTab) + return RowVisualState(isDeleted: isDeleted, isInserted: isInserted, modifiedColumns: []) } func dataGridDeleteRows(_ rows: Set) { @@ -105,10 +131,12 @@ final class CreateTableGridDelegate: DataGridViewDelegate { func dataGridUndo() { structureChangeManager.undo() + reloadAllVisibleRows() } func dataGridRedo() { structureChangeManager.redo() + reloadAllVisibleRows() } func dataGridAddRow() { diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 76f08b3a4..02ca4d7c9 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -236,14 +236,28 @@ struct CreateTableView: View { additionalFields: [.primaryKey] ) - let tableRows = provider.asTableRows() + // Rebuild the row snapshot fresh on every call so cell edits made + // through the delegate are visible to the next reloadData. Capturing + // a snapshot here would let the cell view re-render with the pre-edit + // value. Same rationale as `TableStructureView.structureGrid`. + let manager = structureChangeManager + let tab = structureTab + let dbType = connection.type return DataGridView( - tableRowsProvider: { tableRows }, + tableRowsProvider: { + StructureRowProvider( + changeManager: manager, + tab: tab, + databaseType: dbType, + additionalFields: [.primaryKey] + ).asTableRows() + }, changeManager: wrappedChangeManager, isEditable: true, configuration: DataGridConfiguration( dropdownColumns: provider.dropdownColumns, typePickerColumns: provider.typePickerColumns, + customDropdownOptions: provider.customDropdownOptions, connectionId: connection.id, databaseType: connection.type ), diff --git a/TablePro/Views/Structure/StructureEditingSupport.swift b/TablePro/Views/Structure/StructureEditingSupport.swift index 37ee4c495..c8ee8fa38 100644 --- a/TablePro/Views/Structure/StructureEditingSupport.swift +++ b/TablePro/Views/Structure/StructureEditingSupport.swift @@ -77,4 +77,82 @@ enum StructureEditingSupport { default: break } } + + // MARK: - Field-Level Diff + + /// Per-cell modified-column tinting needs to know which display columns of + /// a row actually changed. Each helper compares two entity values and + /// returns the set of grid column indices whose value differs. Using these + /// in `dataGridVisualState(forRow:)` lets the structure tab tint only the + /// edited cells, mirroring the data tab's per-cell tinting instead of + /// flagging the whole row when one field changed. + + static func columnModifiedIndices( + old: EditableColumnDefinition, + new: EditableColumnDefinition, + orderedFields: [StructureColumnField] + ) -> Set { + var indices: Set = [] + for (index, field) in orderedFields.enumerated() where columnFieldDiffers(field, old: old, new: new) { + indices.insert(index) + } + return indices + } + + /// Grid columns: 0 Name, 1 Columns, 2 Type, 3 Unique, 4 Condition. Index 1 + /// covers `columns` and `columnPrefixes` together because prefixes render + /// inline with the column list (`email(10)`). `isPrimary` and `comment` are + /// intentionally excluded; neither has a grid column on the Indexes tab, + /// so changes to them produce no tint. Matches the data-tab convention of + /// only tinting fields the user can actually see. + static func indexModifiedIndices( + old: EditableIndexDefinition, + new: EditableIndexDefinition + ) -> Set { + var indices: Set = [] + if old.name != new.name { indices.insert(0) } + if old.columns != new.columns || old.columnPrefixes != new.columnPrefixes { indices.insert(1) } + if old.type != new.type { indices.insert(2) } + if old.isUnique != new.isUnique { indices.insert(3) } + if old.whereClause != new.whereClause { indices.insert(4) } + return indices + } + + /// Grid columns: 0 Name, 1 Columns, 2 Ref Table, 3 Ref Columns, 4 Ref + /// Schema, 5 On Delete, 6 On Update. Every field on + /// `EditableForeignKeyDefinition` (except `id`) maps to a displayed column, + /// so this diff is exhaustive. Adding a new field to the struct will need + /// a new grid column AND a new comparison here. + static func foreignKeyModifiedIndices( + old: EditableForeignKeyDefinition, + new: EditableForeignKeyDefinition + ) -> Set { + var indices: Set = [] + if old.name != new.name { indices.insert(0) } + if old.columns != new.columns { indices.insert(1) } + if old.referencedTable != new.referencedTable { indices.insert(2) } + if old.referencedColumns != new.referencedColumns { indices.insert(3) } + if old.referencedSchema != new.referencedSchema { indices.insert(4) } + if old.onDelete != new.onDelete { indices.insert(5) } + if old.onUpdate != new.onUpdate { indices.insert(6) } + return indices + } + + private static func columnFieldDiffers( + _ field: StructureColumnField, + old: EditableColumnDefinition, + new: EditableColumnDefinition + ) -> Bool { + switch field { + case .name: return old.name != new.name + case .type: return old.dataType != new.dataType + case .nullable: return old.isNullable != new.isNullable + case .defaultValue: return old.defaultValue != new.defaultValue + case .primaryKey: return old.isPrimaryKey != new.isPrimaryKey + case .autoIncrement: return old.autoIncrement != new.autoIncrement + case .comment: return old.comment != new.comment + case .charset: return old.charset != new.charset + case .collation: return old.collation != new.collation + } + } } diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index cdbdce984..6f53a4c2e 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -29,6 +29,14 @@ final class StructureGridDelegate: DataGridViewDelegate { // Ordered fields for column editing (updated when currentProvider is set) var orderedFields: [StructureColumnField] = [] + // Stored when DataGridView calls `dataGridAttach(tableViewCoordinator:)` on + // every updateNSView. Lets us tell `NSTableView` which rows to reload after + // an edit / soft-delete / undo so the displayed cell value and visual-state + // tint stay in sync with the change manager. Without this, edits inside a + // row that does not change the row count never trigger `reloadData` because + // the SwiftUI re-render only invalidates layout-affecting properties. + private weak var attachedCoordinator: TableViewCoordinator? + init( structureChangeManager: StructureChangeManager, selectedTab: StructureTab, @@ -59,32 +67,58 @@ final class StructureGridDelegate: DataGridViewDelegate { // MARK: - DataGridViewDelegate - func dataGridDidEditCell(row: Int, column: Int, newValue: String?) { + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + attachedCoordinator = tableViewCoordinator + } + + func dataGridDidEditCell(row displayRow: Int, column: Int, newValue: String?) { guard column >= 0 else { return } - let row = sourceRow(for: row) + let sourceRowIndex = sourceRow(for: displayRow) switch selectedTab { case .columns: - guard row < structureChangeManager.workingColumns.count else { return } - var col = structureChangeManager.workingColumns[row] + guard sourceRowIndex < structureChangeManager.workingColumns.count else { return } + var col = structureChangeManager.workingColumns[sourceRowIndex] StructureEditingSupport.updateColumn(&col, at: column, with: newValue ?? "", orderedFields: orderedFields) structureChangeManager.updateColumn(id: col.id, with: col) case .indexes: - guard row < structureChangeManager.workingIndexes.count else { return } - var idx = structureChangeManager.workingIndexes[row] + guard sourceRowIndex < structureChangeManager.workingIndexes.count else { return } + var idx = structureChangeManager.workingIndexes[sourceRowIndex] StructureEditingSupport.updateIndex(&idx, at: column, with: newValue ?? "") structureChangeManager.updateIndex(id: idx.id, with: idx) case .foreignKeys: - guard row < structureChangeManager.workingForeignKeys.count else { return } - var fk = structureChangeManager.workingForeignKeys[row] + guard sourceRowIndex < structureChangeManager.workingForeignKeys.count else { return } + var fk = structureChangeManager.workingForeignKeys[sourceRowIndex] StructureEditingSupport.updateForeignKey(&fk, at: column, with: newValue ?? "") structureChangeManager.updateForeignKey(id: fk.id, with: fk) case .ddl, .parts: break } + + // Standard NSTableView contract after a data-source mutation: tell the + // table view which row to redraw so the cell shows the new value AND + // the modified-row visual state tint takes effect. The SwiftUI re-render + // alone is not enough because `updateNSView` only triggers `reloadData` + // when the row count or column schema changes. + reloadDisplayRow(displayRow) + } + + private func reloadDisplayRow(_ displayRow: Int) { + attachedCoordinator?.reloadRowAndState(at: displayRow) + } + + /// Repaint every visible cell + row view from the current change-manager + /// state. `TableStructureView` calls this after save/discard, since those + /// reset working state outside the delegate without changing row count, so + /// `DataGridView.updateNSView` would otherwise leave cells and tints stale. + /// Forwards to the shared `TableViewCoordinator` primitive so data tab and + /// structure tab use the same Apple-blessed two-layer refresh + /// (`reloadData(forRowIndexes:)` + `enumerateAvailableRowViews`). + func reloadAllVisibleRows() { + attachedCoordinator?.reloadVisibleRowsAndStates() } func dataGridDeleteRows(_ rows: Set) { @@ -131,6 +165,12 @@ final class StructureGridDelegate: DataGridViewDelegate { } else { onSelectedRowsChanged?([]) } + + // Existing-column deletes leave the row in `workingColumns` and only + // mark it as `pendingChanges[.deleteColumn]`, so the row count is + // unchanged. Without a forced reload the row's deleted-state tint and + // text color never paint on the live row view. + reloadAllVisibleRows() } func dataGridCopyRows(_ indices: Set) { @@ -274,11 +314,17 @@ final class StructureGridDelegate: DataGridViewDelegate { func dataGridUndo() { guard selectedTab != .ddl else { return } structureChangeManager.undo() + // Undo can revert any row's content and visual state. The SwiftUI + // re-render driven by `reloadVersion` only invalidates the snapshot; + // ask `NSTableView` to redraw visible rows so the cell text and + // modified-tint actually update on screen. + reloadAllVisibleRows() } func dataGridRedo() { guard selectedTab != .ddl else { return } structureChangeManager.redo() + reloadAllVisibleRows() } func dataGridAddRow() { @@ -310,7 +356,39 @@ final class StructureGridDelegate: DataGridViewDelegate { } func dataGridVisualState(forRow row: Int) -> RowVisualState? { - structureChangeManager.getVisualState(for: sourceRow(for: row), tab: selectedTab) + let src = sourceRow(for: row) + let (isDeleted, isInserted) = structureChangeManager.deleteInsertState(for: src, tab: selectedTab) + let modified = isDeleted || isInserted ? [] : modifiedColumns(at: src) + return RowVisualState(isDeleted: isDeleted, isInserted: isInserted, modifiedColumns: modified) + } + + /// Diff the working entity against the original (`currentColumns` etc.) to + /// find which display columns the user actually edited. Returning a real + /// per-cell set lets the cell view tint only the touched cells, mirroring + /// the data tab's behavior, instead of flagging the whole row when one + /// field changed. + private func modifiedColumns(at sourceRow: Int) -> Set { + switch selectedTab { + case .columns: + guard sourceRow < structureChangeManager.workingColumns.count else { return [] } + let working = structureChangeManager.workingColumns[sourceRow] + guard let original = structureChangeManager.currentColumns.first(where: { $0.id == working.id }) else { return [] } + return StructureEditingSupport.columnModifiedIndices( + old: original, new: working, orderedFields: orderedFields + ) + case .indexes: + guard sourceRow < structureChangeManager.workingIndexes.count else { return [] } + let working = structureChangeManager.workingIndexes[sourceRow] + guard let original = structureChangeManager.currentIndexes.first(where: { $0.id == working.id }) else { return [] } + return StructureEditingSupport.indexModifiedIndices(old: original, new: working) + case .foreignKeys: + guard sourceRow < structureChangeManager.workingForeignKeys.count else { return [] } + let working = structureChangeManager.workingForeignKeys[sourceRow] + guard let original = structureChangeManager.currentForeignKeys.first(where: { $0.id == working.id }) else { return [] } + return StructureEditingSupport.foreignKeyModifiedIndices(old: original, new: working) + case .ddl, .parts: + return [] + } } func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { @@ -337,7 +415,13 @@ final class StructureGridDelegate: DataGridViewDelegate { rowView.isStructureEditable = connection.type.supportsSchemaEditing let src = sourceRow(for: row) - rowView.isRowDeleted = structureChangeManager.getVisualState(for: src, tab: selectedTab).isDeleted + // Don't set `isDeleted` / visual state here. `DataGridView+Columns` + // calls `applyVisualState(visualState(for: row))` on every row view it + // returns from `tableView(_:rowViewForRow:)`. Setting it twice is a + // smell that previously hid the bug: when `applyVisualState` was a + // tint-only setter, this line was the only place the menu's + // `isDeleted` flag was assigned, and it was assigned only on row-view + // creation. Single source of truth now is `DataGridRowView.visualState`. if selectedTab == .foreignKeys, src < structureChangeManager.workingForeignKeys.count { rowView.referencedTableName = structureChangeManager.workingForeignKeys[src].referencedTable @@ -368,7 +452,12 @@ final class StructureGridDelegate: DataGridViewDelegate { self.handleDuplicateItems(self.sourceRows(for: indices)) } rowView.onDelete = { [weak self] indices in self?.dataGridDeleteRows(indices) } - rowView.onUndoDelete = { [weak self] _ in self?.dataGridUndo() } + rowView.onUndoDelete = { [weak self] displayRow in + guard let self else { return } + let src = self.sourceRow(for: displayRow) + self.structureChangeManager.undoDelete(for: self.selectedTab, at: src) + self.attachedCoordinator?.reloadRowAndState(at: displayRow) + } return rowView } diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index c351563e5..9d5d51835 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -155,18 +155,6 @@ final class StructureRowProvider { return cachedRows[index].row } - func updateValue(_ newValue: String?, at rowIndex: Int, columnIndex: Int) { - // Updates are handled by the onCellEdit callback in TableStructureView - } - - func appendRow(_ row: [String?]) { - // Handled by changeManager.addNewColumn/Index/ForeignKey - } - - func removeRow(at index: Int) { - // Handled by changeManager.deleteColumn/Index/ForeignKey - } - // MARK: - Private Helpers private struct IndexedRow { diff --git a/TablePro/Views/Structure/StructureRowViewWithMenu.swift b/TablePro/Views/Structure/StructureRowViewWithMenu.swift index 545c624d0..fb9e6e860 100644 --- a/TablePro/Views/Structure/StructureRowViewWithMenu.swift +++ b/TablePro/Views/Structure/StructureRowViewWithMenu.swift @@ -8,13 +8,14 @@ import AppKit -/// Row view providing a context menu tailored to the Structure tab -final class StructureRowViewWithMenu: NSTableRowView { - weak var coordinator: TableViewCoordinator? - var rowIndex: Int = 0 +/// Row view providing a context menu tailored to the Structure tab. Inherits +/// selection/emphasis cell invalidation, deleted/inserted-row tint, and the +/// `RowVisualState` source-of-truth from `DataGridRowView`. The context menu +/// reads `visualState.isDeleted` directly, so a single `applyVisualState` call +/// updates both the tint and the menu without a shadow flag to keep in sync. +final class StructureRowViewWithMenu: DataGridRowView { var structureTab: StructureTab = .columns var isStructureEditable: Bool = true - var isRowDeleted: Bool = false var referencedTableName: String? var onCopyName: ((Set) -> Void)? @@ -31,7 +32,7 @@ final class StructureRowViewWithMenu: NSTableRowView { let menu = NSMenu() - if isRowDeleted { + if visualState.isDeleted { let undoItem = NSMenuItem( title: String(localized: "Undo Delete"), action: #selector(handleUndoDelete), diff --git a/TablePro/Views/Structure/StructureViewActionHandler.swift b/TablePro/Views/Structure/StructureViewActionHandler.swift index 6426c99d3..dce43383c 100644 --- a/TablePro/Views/Structure/StructureViewActionHandler.swift +++ b/TablePro/Views/Structure/StructureViewActionHandler.swift @@ -18,4 +18,5 @@ final class StructureViewActionHandler { var pasteRows: (() -> Void)? var undo: (() -> Void)? var redo: (() -> Void)? + var addRow: (() -> Void)? } diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index 69abef764..002f7fc46 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -18,6 +18,10 @@ extension TableStructureView { func generateStructurePreviewSQL() { let changes = structureChangeManager.getChangesArray() guard !changes.isEmpty else { + // After undo brings the working copy back to a clean state, the popover + // would otherwise retain the last-generated SQL. Clear it so reopening + // the popover correctly shows "no changes". + toolbarState.previewStatements = [] return } @@ -120,6 +124,15 @@ extension TableStructureView { // Force clear state after reload (in case it got set during the async process) structureChangeManager.discardChanges() + // Save resets the manager (pendingChanges cleared, working state + // refetched from DB) but row count is usually unchanged after a + // rename / type-change, so `DataGridView.updateNSView` does not + // call `reloadData` on its own. Ask the grid to repaint visible + // cells so the modified yellow tint clears and any value the DB + // round-trip changed (collation defaults, etc.) shows the canonical + // post-save value. + gridDelegate.reloadAllVisibleRows() + lastSaveTime = Date() isReloadingAfterSave = false } catch { @@ -134,6 +147,10 @@ extension TableStructureView { func discardChanges() { structureChangeManager.discardChanges() + // Mirror the save path: discard reverts working state without changing + // row count, so the grid needs an explicit reload to drop the yellow + // modified tint and revert any displayed value. + gridDelegate.reloadAllVisibleRows() } // MARK: - DDL View diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index f5c78b494..b2b0bf691 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -108,6 +108,7 @@ struct TableStructureView: View { actionHandler.pasteRows = { self.gridDelegate.dataGridPasteRows() } actionHandler.undo = { self.gridDelegate.dataGridUndo() } actionHandler.redo = { self.gridDelegate.dataGridRedo() } + actionHandler.addRow = { self.gridDelegate.dataGridAddRow() } coordinator?.structureActions = actionHandler } .onDisappear { @@ -118,6 +119,15 @@ struct TableStructureView: View { coordinator?.toolbarState.hasStructureChanges = newValue updateGridDelegate() } + .onChange(of: structureChangeManager.reloadVersion) { _, _ in + // Any mutation that does not toggle hasChanges (add row when changes + // already exist, undo to a still-dirty state) only bumps reloadVersion. + // Bump displayVersion so SwiftUI re-evaluates structureGrid with a fresh + // tableRows snapshot, which lets DataGridView see the new row count and + // call reloadData(). Without this, Cmd+Shift+N adds the row to the change + // manager but the grid never displays it. + displayVersion += 1 + } .onReceive(AppCommands.shared.refreshData) { _ in onRefreshData() } } @@ -275,9 +285,17 @@ struct TableStructureView: View { let customOptions = provider.customDropdownOptions let allDropdownColumns = provider.dropdownColumns.union(Set(customOptions.keys)) - let tableRows = provider.asTableRows() + // Build the row snapshot fresh on every call rather than capturing it + // once at body-evaluation time. After a cell edit / undo / redo the + // change manager's working state is updated synchronously, but a + // captured snapshot would still hold the pre-edit value, so the + // `tableView.reloadData(forRowIndexes:)` issued by the delegate would + // re-render the cell from a stale source. Mirror the data tab's pattern + // (`MainEditorContentView` rebuilds via `coordinator.tabSessionRegistry` + // on every call). `makeCurrentProvider` is cheap because the working + // arrays are small (typically <100 entries). return DataGridView( - tableRowsProvider: { tableRows }, + tableRowsProvider: { makeCurrentProvider().asTableRows() }, changeManager: wrappedChangeManager, isEditable: canEdit, configuration: DataGridConfiguration( diff --git a/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift b/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift new file mode 100644 index 000000000..f8e2d47fd --- /dev/null +++ b/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift @@ -0,0 +1,305 @@ +// +// StructureEditingSupportFieldDiffTests.swift +// TableProTests +// +// Tests for the field-by-field diff helpers that drive per-cell modified-column +// tinting on the Structure tab. The grid reads `RowVisualState.modifiedColumns` +// to decide which cells get the yellow tint; these helpers compute that set +// from the working/original entity pair stored on `StructureChangeManager`. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("StructureEditingSupport Field Diff") +@MainActor +struct StructureEditingSupportFieldDiffTests { + // MARK: - Fixtures + + private static let mysqlOrderedFields: [StructureColumnField] = [ + .name, .type, .nullable, .defaultValue, .primaryKey, + .autoIncrement, .comment, .charset, .collation + ] + + private static let postgresOrderedFields: [StructureColumnField] = [ + .name, .type, .nullable, .defaultValue, .primaryKey, .autoIncrement, .comment + ] + + private func makeColumn(name: String = "id", dataType: String = "INT") -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), + name: name, + dataType: dataType, + isNullable: false, + defaultValue: nil, + autoIncrement: false, + unsigned: false, + comment: nil, + collation: nil, + onUpdate: nil, + charset: nil, + extra: nil, + isPrimaryKey: false + ) + } + + private func makeIndex(name: String = "idx_users_email") -> EditableIndexDefinition { + EditableIndexDefinition( + id: UUID(), + name: name, + columns: ["email"], + type: .btree, + isUnique: false, + isPrimary: false, + comment: nil, + columnPrefixes: [:], + whereClause: nil + ) + } + + private func makeForeignKey(name: String = "fk_orders_user") -> EditableForeignKeyDefinition { + EditableForeignKeyDefinition( + id: UUID(), + name: name, + columns: ["user_id"], + referencedTable: "users", + referencedColumns: ["id"], + referencedSchema: nil, + onDelete: .noAction, + onUpdate: .noAction + ) + } + + // MARK: - columnModifiedIndices + + @Test("Identical columns produce empty diff") + func columnIdentical() { + let column = makeColumn() + let result = StructureEditingSupport.columnModifiedIndices( + old: column, + new: column, + orderedFields: Self.mysqlOrderedFields + ) + #expect(result.isEmpty) + } + + @Test("Renaming a column flags only the name index") + func columnNameChanged() { + let original = makeColumn(name: "user_id") + var renamed = original + renamed.name = "user_idd" + + let result = StructureEditingSupport.columnModifiedIndices( + old: original, + new: renamed, + orderedFields: Self.mysqlOrderedFields + ) + #expect(result == [0]) + } + + @Test("Editing two unrelated fields flags exactly those indices") + func columnTwoFieldsChanged() { + let original = makeColumn() + var changed = original + changed.dataType = "BIGINT" + changed.comment = "primary identifier" + + let result = StructureEditingSupport.columnModifiedIndices( + old: original, + new: changed, + orderedFields: Self.mysqlOrderedFields + ) + // .type is at index 1, .comment is at index 6 in mysqlOrderedFields. + #expect(result == [1, 6]) + } + + @Test("Diff respects orderedFields and skips fields not displayed by the database type") + func columnFieldsMissingFromOrderedFields() { + let original = makeColumn() + var changed = original + changed.collation = "utf8mb4_general_ci" + + // Postgres ordered fields exclude `.collation`, so the change is + // invisible to the grid and must not produce an index. + let result = StructureEditingSupport.columnModifiedIndices( + old: original, + new: changed, + orderedFields: Self.postgresOrderedFields + ) + #expect(result.isEmpty) + } + + @Test("All nine StructureColumnField cases are diffable") + func columnEveryFieldDetected() { + let original = makeColumn(name: "a", dataType: "INT") + var changed = original + changed.name = "b" + changed.dataType = "BIGINT" + changed.isNullable.toggle() + changed.defaultValue = "0" + changed.isPrimaryKey.toggle() + changed.autoIncrement.toggle() + changed.comment = "x" + changed.charset = "utf8mb4" + changed.collation = "utf8mb4_general_ci" + + let result = StructureEditingSupport.columnModifiedIndices( + old: original, + new: changed, + orderedFields: Self.mysqlOrderedFields + ) + #expect(result == Set(0.. StructureChangeManager { + let manager = StructureChangeManager() + let columns: [ColumnInfo] = [ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "email", dataType: "VARCHAR(255)", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + ] + manager.loadSchema( + tableName: "users", + columns: columns, + indexes: [], + foreignKeys: [], + primaryKey: ["id"], + databaseType: .mysql + ) + return manager + } + + @Test("undoDelete clears the deletion mark for an existing column") + func undoDeleteExistingColumn() { + let manager = makeManagerWithSchema() + let emailColumn = manager.workingColumns[1] + manager.deleteColumn(id: emailColumn.id) + #expect(manager.deleteInsertState(for: 1, tab: .columns).isDeleted) + + manager.undoDelete(for: .columns, at: 1) + #expect(!manager.deleteInsertState(for: 1, tab: .columns).isDeleted) + #expect(manager.pendingChanges[.column(emailColumn.id)] == nil) + } + + @Test("undoDelete is a no-op for rows whose pending change is not a delete") + func undoDeleteIgnoresNonDeleteChanges() { + let manager = makeManagerWithSchema() + var renamed = manager.workingColumns[1] + renamed.name = "email_address" + manager.updateColumn(id: renamed.id, with: renamed) + let beforeChanges = manager.pendingChanges + + manager.undoDelete(for: .columns, at: 1) + + #expect(manager.pendingChanges == beforeChanges) + } + + @Test("undoDelete bounds-checks the row index") + func undoDeleteOutOfRange() { + let manager = makeManagerWithSchema() + manager.undoDelete(for: .columns, at: 99) + manager.undoDelete(for: .indexes, at: 0) + manager.undoDelete(for: .foreignKeys, at: 0) + #expect(manager.pendingChanges.isEmpty) + } + + @Test("undoDelete on .ddl / .parts tabs is a no-op") + func undoDeleteDDLAndParts() { + let manager = makeManagerWithSchema() + let emailColumn = manager.workingColumns[1] + manager.deleteColumn(id: emailColumn.id) + + manager.undoDelete(for: .ddl, at: 1) + manager.undoDelete(for: .parts, at: 1) + + #expect(manager.deleteInsertState(for: 1, tab: .columns).isDeleted) + } +}