diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7f00176..af0222885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use sheet presentation for all file open/save panels instead of free-floating dialogs - Replace event monitor with native SwiftUI .onKeyPress() in connection switcher - Extract reusable SearchFieldView component from 4 custom search field implementations -- Replace custom resize handle with native NSSplitView for inspector panel + +### Changed + +- Migrate undo system from custom stacks to NSUndoManager — Edit menu now shows "Undo Edit Cell", "Undo Delete Row", etc. ### Added diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index becd20620..14195846c 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -226,13 +226,7 @@ struct ContentView: View { } detail: { // MARK: - Detail (Main workspace with optional right sidebar) if let currentSession = currentSession, let rightPanelState, let sessionState { - HorizontalSplitView( - isTrailingCollapsed: !rightPanelState.isPresented, - trailingWidth: Bindable(rightPanelState).panelWidth, - minTrailingWidth: RightPanelState.minWidth, - maxTrailingWidth: RightPanelState.maxWidth, - autosaveName: "InspectorSplit" - ) { + HStack(spacing: 0) { MainContentView( connection: currentSession.connection, payload: payload, @@ -250,15 +244,23 @@ struct ContentView: View { toolbarState: sessionState.toolbarState, coordinator: sessionState.coordinator ) - } trailing: { - UnifiedRightPanelView( - state: rightPanelState, - inspectorContext: inspectorContext, - connection: currentSession.connection, - tables: currentSession.tables - ) - .background(Color(nsColor: .windowBackgroundColor)) + .frame(maxWidth: .infinity) + + if rightPanelState.isPresented { + PanelResizeHandle(panelWidth: Bindable(rightPanelState).panelWidth) + Divider() + UnifiedRightPanelView( + state: rightPanelState, + inspectorContext: inspectorContext, + connection: currentSession.connection, + tables: currentSession.tables + ) + .frame(width: rightPanelState.panelWidth) + .background(Color(nsColor: .windowBackgroundColor)) + .transition(.move(edge: .trailing)) + } } + .animation(.easeInOut(duration: 0.2), value: rightPanelState.isPresented) } else { VStack(spacing: 16) { ProgressView() diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index adb5195bc..0ae7d0c32 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -4,7 +4,7 @@ // // Manager for tracking data changes with O(1) lookups. // Delegates SQL generation to SQLStatementGenerator. -// Delegates undo/redo stack management to DataChangeUndoManager. +// Uses Apple's UndoManager (NSUndoManager) for undo/redo stack management. // import Foundation @@ -12,6 +12,13 @@ import Observation import os import TableProPluginKit +struct UndoResult { + let action: UndoAction + let needsRowRemoval: Bool + let needsRowRestore: Bool + let restoreRow: [String?]? +} + /// Manager for tracking and applying data changes /// @MainActor ensures thread-safe access - critical for avoiding EXC_BAD_ACCESS /// when multiple queries complete simultaneously (e.g., rapid sorting over SSH tunnel) @@ -20,9 +27,8 @@ final class DataChangeManager { private static let logger = Logger(subsystem: "com.TablePro", category: "DataChangeManager") var changes: [RowChange] = [] var hasChanges: Bool = false - var reloadVersion: Int = 0 // Incremented to trigger table reload + var reloadVersion: Int = 0 - // Track which rows changed since last reload for granular updates private(set) var changedRowIndices: Set = [] var tableName: String = "" @@ -30,7 +36,6 @@ final class DataChangeManager { var databaseType: DatabaseType = .mysql var pluginDriver: (any PluginDatabaseDriver)? - // Simple storage with explicit deep copy to avoid memory corruption private var _columnsStorage: [String] = [] var columns: [String] { get { _columnsStorage } @@ -39,16 +44,9 @@ final class DataChangeManager { // MARK: - Cached Lookups for O(1) Performance - /// Set of row indices that are marked for deletion - O(1) lookup private var deletedRowIndices: Set = [] - - /// Set of row indices that are newly inserted - O(1) lookup private(set) var insertedRowIndices: Set = [] - - /// Row index → modified column indices for O(1) per-cell lookup private var modifiedCells: [Int: Set] = [:] - - /// Lazy storage for inserted row values - avoids creating CellChange objects until needed private var insertedRowData: [Int: [String?]] = [:] /// (rowIndex, changeType) → index in `changes` array for O(1) lookup @@ -72,14 +70,11 @@ final class DataChangeManager { changeIndex.removeValue(forKey: removedKey) changes.remove(at: arrayIndex) - // Decrement indices above the removed position for (key, idx) in changeIndex where idx > arrayIndex { changeIndex[key] = idx - 1 } } - /// Remove the change for a given (rowIndex, type) using O(1) index lookup. - /// Returns true if a change was found and removed. @discardableResult private func removeChangeByKey(rowIndex: Int, type: ChangeType) -> Bool { let key = RowChangeKey(rowIndex: rowIndex, type: type) @@ -103,11 +98,13 @@ final class DataChangeManager { return lo } - /// Undo/redo manager - private let undoManager = DataChangeUndoManager() + private let undoManager: UndoManager = { + let manager = UndoManager() + manager.levelsOfUndo = 100 + return manager + }() - /// Flag to prevent clearing redo stack during redo operations - private var isRedoing = false + private var lastUndoResult: UndoResult? // MARK: - Undo/Redo Properties @@ -116,7 +113,6 @@ final class DataChangeManager { // MARK: - Helper Methods - /// Consume and clear changed row indices (for granular table reloads) func consumeChangedRowIndices() -> Set { let indices = changedRowIndices changedRowIndices.removeAll() @@ -125,8 +121,6 @@ final class DataChangeManager { // MARK: - Configuration - /// Clear all tracked changes, preserving undo/redo history. - /// Use when changes are invalidated but undo context may still be relevant. func clearChanges() { changes.removeAll() changeIndex.removeAll() @@ -139,15 +133,11 @@ final class DataChangeManager { reloadVersion += 1 } - /// Clear all tracked changes AND undo/redo history. - /// Use after successful save, explicit discard, or new query execution - /// where undo context is no longer meaningful. func clearChangesAndUndoHistory() { clearChanges() - undoManager.clearAll() + undoManager.removeAllActions() } - /// Atomically configure the manager for a new table func configureForTable( tableName: String, columns: [String], @@ -166,7 +156,7 @@ final class DataChangeManager { modifiedCells.removeAll() insertedRowData.removeAll() changedRowIndices.removeAll() - undoManager.clearAll() + undoManager.removeAllActions() changes.removeAll() hasChanges = false @@ -203,9 +193,6 @@ final class DataChangeManager { return } - // New changes invalidate redo history (standard undo/redo behavior) - if !isRedoing { undoManager.clearRedo() } - let cellChange = CellChange( rowIndex: rowIndex, columnIndex: columnIndex, @@ -214,10 +201,8 @@ final class DataChangeManager { newValue: newValue ) - // Check if this is an edit to an INSERTED row — O(1) dictionary lookup let insertKey = RowChangeKey(rowIndex: rowIndex, type: .insert) if let insertIndex = changeIndex[insertKey] { - // Update stored values directly if var storedValues = insertedRowData[rowIndex] { if columnIndex < storedValues.count { storedValues[columnIndex] = newValue @@ -225,7 +210,6 @@ final class DataChangeManager { } } - // Update/create CellChange for this column if let cellIndex = changes[insertIndex].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) { @@ -245,20 +229,19 @@ final class DataChangeManager { newValue: newValue )) } - pushUndo(.cellEdit( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - previousValue: oldValue, - newValue: newValue - )) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.cellEdit( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + previousValue: oldValue, newValue: newValue + )) + } + undoManager.setActionName(String(localized: "Edit Cell")) changedRowIndices.insert(rowIndex) hasChanges = !changes.isEmpty reloadVersion += 1 return } - // Find existing UPDATE row change or create new one — O(1) dictionary lookup let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) if let existingIndex = changeIndex[updateKey] { if let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { @@ -273,7 +256,6 @@ final class DataChangeManager { newValue: newValue ) - // If value is back to original, remove the change if originalOldValue == newValue { changes[existingIndex].cellChanges.remove(at: cellIndex) modifiedCells[rowIndex]?.remove(columnIndex) @@ -302,22 +284,18 @@ final class DataChangeManager { changedRowIndices.insert(rowIndex) } - pushUndo(.cellEdit( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - previousValue: oldValue, - newValue: newValue - )) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.cellEdit( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + previousValue: oldValue, newValue: newValue + )) + } + undoManager.setActionName(String(localized: "Edit Cell")) hasChanges = !changes.isEmpty reloadVersion += 1 } func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { - // New changes invalidate redo history (standard undo/redo behavior) - if !isRedoing { undoManager.clearRedo() } - - // O(1) lookup + removal instead of linear removeAll removeChangeByKey(rowIndex: rowIndex, type: .update) modifiedCells.removeValue(forKey: rowIndex) @@ -325,16 +303,16 @@ final class DataChangeManager { changes.append(rowChange) changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 deletedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) // Track for granular reload - pushUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + changedRowIndices.insert(rowIndex) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + } + undoManager.setActionName(String(localized: "Delete Row")) hasChanges = true reloadVersion += 1 } func recordBatchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) { - // New changes invalidate redo history (never called from redo path) - undoManager.clearRedo() - guard rows.count > 1 else { if let row = rows.first { recordRowDeletion(rowIndex: row.rowIndex, originalRow: row.originalRow) @@ -352,25 +330,28 @@ final class DataChangeManager { changes.append(rowChange) changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 deletedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) // Track for granular reload + changedRowIndices.insert(rowIndex) batchData.append((rowIndex: rowIndex, originalRow: originalRow)) } - pushUndo(.batchRowDeletion(rows: batchData)) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowDeletion(rows: batchData)) + } + undoManager.setActionName(String(localized: "Delete Rows")) hasChanges = true reloadVersion += 1 } func recordRowInsertion(rowIndex: Int, values: [String?]) { - // New changes invalidate redo history (never called from redo path) - undoManager.clearRedo() - insertedRowData[rowIndex] = values let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: []) changes.append(rowChange) changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 insertedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) // Track for granular reload - pushUndo(.rowInsertion(rowIndex: rowIndex)) + changedRowIndices.insert(rowIndex) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) + } + undoManager.setActionName(String(localized: "Insert Row")) hasChanges = true reloadVersion += 1 } @@ -392,7 +373,6 @@ final class DataChangeManager { insertedRowIndices.remove(rowIndex) insertedRowData.removeValue(forKey: rowIndex) - // Shift down indices for rows after the removed row var shiftedInsertedIndices = Set() for idx in insertedRowIndices { shiftedInsertedIndices.insert(idx > rowIndex ? idx - 1 : idx) @@ -405,18 +385,76 @@ final class DataChangeManager { } } - // Rebuild needed after row index shifts + var newInsertedRowData: [Int: [String?]] = [:] + for (key, value) in insertedRowData { + if key > rowIndex { + newInsertedRowData[key - 1] = value + } else { + newInsertedRowData[key] = value + } + } + insertedRowData = newInsertedRowData + + var newModifiedCells: [Int: Set] = [:] + for (key, value) in modifiedCells { + if key > rowIndex { + newModifiedCells[key - 1] = value + } else if key < rowIndex { + newModifiedCells[key] = value + } + } + modifiedCells = newModifiedCells + rebuildChangeIndex() hasChanges = !changes.isEmpty } + private func shiftRowIndicesUp(from insertionPoint: Int) { + for i in 0..= insertionPoint { + changes[i].rowIndex += 1 + } + } + + var shiftedInserted = Set() + for idx in insertedRowIndices { + shiftedInserted.insert(idx >= insertionPoint ? idx + 1 : idx) + } + insertedRowIndices = shiftedInserted + + var shiftedDeleted = Set() + for idx in deletedRowIndices { + shiftedDeleted.insert(idx >= insertionPoint ? idx + 1 : idx) + } + deletedRowIndices = shiftedDeleted + + var newInsertedRowData: [Int: [String?]] = [:] + for (key, value) in insertedRowData { + newInsertedRowData[key >= insertionPoint ? key + 1 : key] = value + } + insertedRowData = newInsertedRowData + + var newModifiedCells: [Int: Set] = [:] + for (key, value) in modifiedCells { + newModifiedCells[key >= insertionPoint ? key + 1 : key] = value + } + modifiedCells = newModifiedCells + + var newChangedRows = Set() + for idx in changedRowIndices { + newChangedRows.insert(idx >= insertionPoint ? idx + 1 : idx) + } + changedRowIndices = newChangedRows + + rebuildChangeIndex() + } + func undoBatchRowInsertion(rowIndices: [Int]) { guard !rowIndices.isEmpty else { return } let validRows = rowIndices.filter { insertedRowIndices.contains($0) } guard !validRows.isEmpty else { return } - // Collect row values for undo/redo — O(1) lookup via changeIndex var rowValues: [[String?]] = [] for rowIndex in validRows { let key = RowChangeKey(rowIndex: rowIndex, type: .insert) @@ -435,9 +473,11 @@ final class DataChangeManager { insertedRowData.removeValue(forKey: rowIndex) } - pushUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) + } + undoManager.setActionName(String(localized: "Insert Rows")) - // Single-pass shift using binary search instead of O(n²) nested loop let sortedDeleted = validRows.sorted() var newInserted = Set() @@ -457,33 +497,20 @@ final class DataChangeManager { hasChanges = !changes.isEmpty } - // MARK: - Undo/Redo Stack Management - - func pushUndo(_ action: UndoAction) { - undoManager.push(action) - } - - func popUndo() -> UndoAction? { - undoManager.popUndo() - } - - func clearUndoStack() { - undoManager.clearUndo() - } - - func clearRedoStack() { - undoManager.clearRedo() - } - - /// Undo the last change and return details needed to update the UI - func undoLastChange() -> (action: UndoAction, needsRowRemoval: Bool, needsRowRestore: Bool, restoreRow: [String?]?)? { - guard let action = popUndo() else { return nil } - - undoManager.moveToRedo(action) + // MARK: - Core Undo Application + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func applyDataUndo(_ action: UndoAction) { switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, _): - // O(1) lookup: try update first, then insert + case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.cellEdit( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + previousValue: newValue, newValue: previousValue + )) + } + undoManager.setActionName(String(localized: "Edit Cell")) + let matchedIndex = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .update)] ?? changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] if let changeIdx = matchedIndex { @@ -526,123 +553,245 @@ final class DataChangeManager { } } } + } else { + recordCellChangeForRedo( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: newValue, newValue: previousValue + ) } changedRowIndices.insert(rowIndex) - hasChanges = !changes.isEmpty - reloadVersion += 1 - return (action, false, false, nil) + lastUndoResult = UndoResult(action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil) case .rowInsertion(let rowIndex): - undoRowInsertion(rowIndex: rowIndex) - changedRowIndices.insert(rowIndex) - return (action, true, false, nil) - - case .rowDeletion(let rowIndex, let originalRow): - undoRowDeletion(rowIndex: rowIndex) - changedRowIndices.insert(rowIndex) - return (action, false, true, originalRow) - - case .batchRowDeletion(let rows): - for (rowIndex, _) in rows.reversed() { - undoRowDeletion(rowIndex: rowIndex) - changedRowIndices.insert(rowIndex) + let savedValues = insertedRowData[rowIndex] + undoManager.registerUndo(withTarget: self) { [savedValues] target in + if let savedValues { + target.insertedRowData[rowIndex] = savedValues + } + target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) } - return (action, false, true, nil) - - case .batchRowInsertion(let rowIndices, let rowValues): - for (index, rowIndex) in rowIndices.enumerated().reversed() { - guard index < rowValues.count else { continue } - let values = rowValues[index] + undoManager.setActionName(String(localized: "Insert Row")) - let cellChanges = values.enumerated().map { colIndex, value in + if insertedRowIndices.contains(rowIndex) { + undoRowInsertion(rowIndex: rowIndex) + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } else { + shiftRowIndicesUp(from: rowIndex) + insertedRowIndices.insert(rowIndex) + let cellChanges = columns.enumerated().map { index, columnName in CellChange( rowIndex: rowIndex, - columnIndex: colIndex, - columnName: columns[safe: colIndex] ?? "", + columnIndex: index, + columnName: columnName, oldValue: nil, - newValue: value + newValue: savedValues?[safe: index] ?? nil ) } let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) changes.append(rowChange) - insertedRowIndices.insert(rowIndex) + changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 + if let savedValues { + insertedRowData[rowIndex] = savedValues + } + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues + ) } - rebuildChangeIndex() - hasChanges = !changes.isEmpty - reloadVersion += 1 - return (action, true, false, nil) - } - } + case .rowDeletion(let rowIndex, let originalRow): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + } + undoManager.setActionName(String(localized: "Delete Row")) - /// Redo the last undone change - func redoLastChange() -> (action: UndoAction, needsRowInsert: Bool, needsRowDelete: Bool)? { - guard let action = undoManager.popRedo() else { return nil } + if deletedRowIndices.contains(rowIndex) { + undoRowDeletion(rowIndex: rowIndex) + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow + ) + } else { + redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } - isRedoing = true - defer { isRedoing = false } + case .batchRowDeletion(let rows): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowDeletion(rows: rows)) + } + undoManager.setActionName(String(localized: "Delete Rows")) - undoManager.moveToUndo(action) + let isUndo = rows.contains { deletedRowIndices.contains($0.rowIndex) } + if isUndo { + for (rowIndex, _) in rows.reversed() { + undoRowDeletion(rowIndex: rowIndex) + changedRowIndices.insert(rowIndex) + } + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil + ) + } else { + for (rowIndex, originalRow) in rows { + redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + changedRowIndices.insert(rowIndex) + } + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } - switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): - recordCellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: previousValue, - newValue: newValue - ) - _ = undoManager.popUndo() // Remove extra undo - changedRowIndices.insert(rowIndex) - reloadVersion += 1 - return (action, false, false) + case .batchRowInsertion(let rowIndices, let rowValues): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowInsertion(rowIndices: rowIndices, rowValues: rowValues)) + } + undoManager.setActionName(String(localized: "Insert Rows")) + + let firstInserted = rowIndices.first.map { insertedRowIndices.contains($0) } ?? false + if firstInserted { + for rowIndex in rowIndices { + removeChangeByKey(rowIndex: rowIndex, type: .insert) + insertedRowIndices.remove(rowIndex) + insertedRowData.removeValue(forKey: rowIndex) + changedRowIndices.insert(rowIndex) + } + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } else { + // Shift existing rows up for each insertion point (ascending order) + for rowIndex in rowIndices.sorted() { + shiftRowIndicesUp(from: rowIndex) + } - case .rowInsertion(let rowIndex): - insertedRowIndices.insert(rowIndex) - let cellChanges = columns.enumerated().map { index, columnName in - CellChange( - rowIndex: rowIndex, - columnIndex: index, - columnName: columnName, - oldValue: nil, - newValue: nil + for (index, rowIndex) in rowIndices.enumerated().reversed() { + guard index < rowValues.count else { continue } + let values = rowValues[index] + + let cellChanges = values.enumerated().map { colIndex, value in + CellChange( + rowIndex: rowIndex, + columnIndex: colIndex, + columnName: columns[safe: colIndex] ?? "", + oldValue: nil, + newValue: value + ) + } + let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) + changes.append(rowChange) + insertedRowIndices.insert(rowIndex) + insertedRowData[rowIndex] = values + } + + rebuildChangeIndex() + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil ) } - let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) - changes.append(rowChange) - changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 - hasChanges = true - changedRowIndices.insert(rowIndex) - reloadVersion += 1 - return (action, true, false) + } - case .rowDeletion(let rowIndex, let originalRow): - recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) - _ = undoManager.popUndo() - changedRowIndices.insert(rowIndex) - return (action, false, true) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } - case .batchRowDeletion(let rows): - for (rowIndex, originalRow) in rows { - recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) - _ = undoManager.popUndo() - changedRowIndices.insert(rowIndex) + /// Re-apply a cell edit during redo without registering a duplicate undo + private func recordCellChangeForRedo( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String? + ) { + let cellChange = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue + ) + + let insertKey = RowChangeKey(rowIndex: rowIndex, type: .insert) + if let insertIndex = changeIndex[insertKey] { + if var storedValues = insertedRowData[rowIndex] { + if columnIndex < storedValues.count { + storedValues[columnIndex] = newValue + insertedRowData[rowIndex] = storedValues + } + } + if let cellIndex = changes[insertIndex].cellChanges.firstIndex(where: { + $0.columnIndex == columnIndex + }) { + changes[insertIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: nil, newValue: newValue + ) + } else { + changes[insertIndex].cellChanges.append(CellChange( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: nil, newValue: newValue + )) } - return (action, false, true) + return + } - case .batchRowInsertion(let rowIndices, _): - for rowIndex in rowIndices { - removeChangeByKey(rowIndex: rowIndex, type: .insert) - insertedRowIndices.remove(rowIndex) - changedRowIndices.insert(rowIndex) + let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + if let existingIndex = changeIndex[updateKey] { + if let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { + $0.columnIndex == columnIndex + }) { + let originalOldValue = changes[existingIndex].cellChanges[cellIndex].oldValue + changes[existingIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: originalOldValue, newValue: newValue + ) + } else { + changes[existingIndex].cellChanges.append(cellChange) + modifiedCells[rowIndex, default: []].insert(columnIndex) } - hasChanges = !changes.isEmpty - reloadVersion += 1 - return (action, true, false) + } else { + let rowChange = RowChange( + rowIndex: rowIndex, type: .update, cellChanges: [cellChange] + ) + changes.append(rowChange) + changeIndex[updateKey] = changes.count - 1 + modifiedCells[rowIndex, default: []].insert(columnIndex) } } + /// Re-apply a row deletion during redo without registering a duplicate undo + private func redoRowDeletion(rowIndex: Int, originalRow: [String?]) { + removeChangeByKey(rowIndex: rowIndex, type: .update) + modifiedCells.removeValue(forKey: rowIndex) + + let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) + changes.append(rowChange) + changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 + deletedRowIndices.insert(rowIndex) + hasChanges = true + } + + // MARK: - Undo/Redo Public API + + func undoLastChange() -> UndoResult? { + guard undoManager.canUndo else { return nil } + lastUndoResult = nil + undoManager.undo() + return lastUndoResult + } + + func redoLastChange() -> UndoResult? { + guard undoManager.canRedo else { return nil } + lastUndoResult = nil + undoManager.redo() + return lastUndoResult + } + // MARK: - SQL Generation func generateSQL() throws -> [ParameterizedStatement] { @@ -654,15 +803,12 @@ final class DataChangeManager { ) } - /// Unified statement generation for both data grid and sidebar edits. - /// Routes through plugin driver for NoSQL databases, falls back to SQLStatementGenerator for SQL. func generateSQL( for changes: [RowChange], insertedRowData: [Int: [String?]] = [:], deletedRowIndices: Set = [], insertedRowIndices: Set = [] ) throws -> [ParameterizedStatement] { - // Try plugin dispatch first (handles MongoDB, Redis, etcd, and future NoSQL plugins) if let pluginDriver { let pluginChanges = changes.map { change -> PluginRowChange in PluginRowChange( @@ -692,7 +838,6 @@ final class DataChangeManager { } } - // Safety: prevent SQL generation for NoSQL databases if plugin driver is unavailable if PluginManager.shared.editorLanguage(for: databaseType) != .sql { throw DatabaseError.queryFailed( "Cannot generate statements for \(databaseType.rawValue) — plugin driver not initialized" @@ -724,8 +869,6 @@ final class DataChangeManager { ) } - // Validate DELETE coverage: batch DELETE produces 1 statement for N rows when PK exists, - // so count statements != count rows. Instead check that all deletable rows got coverage. let deletableChanges = changes.filter { $0.type == .delete && deletedRowIndices.contains($0.rowIndex) } let deletableWithOriginalRow = deletableChanges.filter { $0.originalRow != nil } diff --git a/TablePro/Core/ChangeTracking/DataChangeUndoManager.swift b/TablePro/Core/ChangeTracking/DataChangeUndoManager.swift deleted file mode 100644 index e473cda9d..000000000 --- a/TablePro/Core/ChangeTracking/DataChangeUndoManager.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// DataChangeUndoManager.swift -// TablePro -// -// Manages undo/redo stacks for data changes. -// Extracted from DataChangeManager to improve separation of concerns. -// - -import Foundation - -/// Manages undo/redo stacks for data changes -final class DataChangeUndoManager { - /// Maximum number of undo/redo actions to retain in memory - private let maxUndoDepth = 100 - - /// Undo stack for reversing changes (LIFO) - private var undoStack: [UndoAction] = [] - - /// Redo stack for re-applying undone changes (LIFO) - private var redoStack: [UndoAction] = [] - - // MARK: - Public API - - /// Check if there are any undo actions available - var canUndo: Bool { - !undoStack.isEmpty - } - - /// Check if there are any redo actions available - var canRedo: Bool { - !redoStack.isEmpty - } - - /// Push an undo action onto the stack - /// Clears the redo stack since new changes invalidate redo history - func push(_ action: UndoAction) { - undoStack.append(action) - trimStack(&undoStack) - // Don't clear redo here - let caller decide when to clear - } - - /// Pop the last undo action from the stack - func popUndo() -> UndoAction? { - undoStack.popLast() - } - - /// Pop the last redo action from the stack - func popRedo() -> UndoAction? { - redoStack.popLast() - } - - /// Move an action from undo to redo stack - func moveToRedo(_ action: UndoAction) { - redoStack.append(action) - trimStack(&redoStack) - } - - /// Move an action from redo to undo stack - func moveToUndo(_ action: UndoAction) { - undoStack.append(action) - trimStack(&undoStack) - } - - /// Clear the undo stack - func clearUndo() { - undoStack.removeAll() - } - - /// Clear the redo stack (called when new changes are made) - func clearRedo() { - redoStack.removeAll() - } - - /// Clear both stacks - func clearAll() { - undoStack.removeAll() - redoStack.removeAll() - } - - /// Get the count of undo actions - var undoCount: Int { - undoStack.count - } - - /// Get the count of redo actions - var redoCount: Int { - redoStack.count - } - - // MARK: - Private Helpers - - /// Trim a stack to the maximum allowed depth, removing oldest entries first - private func trimStack(_ stack: inout [UndoAction]) { - if stack.count > maxUndoDepth { - stack.removeFirst(stack.count - maxUndoDepth) - } - } -} diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index a8fbb7936..96992caf7 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -37,7 +37,11 @@ final class StructureChangeManager { // MARK: - Undo/Redo Support - private let undoManager = StructureUndoManager() + private let undoManager: UndoManager = { + let manager = UndoManager() + manager.levelsOfUndo = 100 + return manager + }() private var visualStateCache: [Int: RowVisualState] = [:] var canUndo: Bool { undoManager.canUndo } @@ -92,9 +96,10 @@ final class StructureChangeManager { // Reset working state resetWorkingState() - // Clear changes + // Clear changes and undo history pendingChanges.removeAll() validationErrors.removeAll() + undoManager.removeAllActions() hasChanges = false // Increment reloadVersion to trigger DataGridView column width recalculation @@ -117,7 +122,10 @@ final class StructureChangeManager { // Mark as pending change so hasChanges = true (even though placeholder is invalid) // This allows Cmd+R to show warning and Cmd+S to trigger validation pendingChanges[.column(placeholder.id)] = .addColumn(placeholder) - undoManager.push(.columnAdd(column: placeholder)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnAdd(column: placeholder)) + } + undoManager.setActionName(String(localized: "Add Column")) validate() hasChanges = true reloadVersion += 1 @@ -128,7 +136,10 @@ final class StructureChangeManager { let placeholder = EditableIndexDefinition.placeholder() workingIndexes.append(placeholder) pendingChanges[.index(placeholder.id)] = .addIndex(placeholder) - undoManager.push(.indexAdd(index: placeholder)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexAdd(index: placeholder)) + } + undoManager.setActionName(String(localized: "Add Index")) validate() hasChanges = true reloadVersion += 1 @@ -139,7 +150,10 @@ final class StructureChangeManager { let placeholder = EditableForeignKeyDefinition.placeholder() workingForeignKeys.append(placeholder) pendingChanges[.foreignKey(placeholder.id)] = .addForeignKey(placeholder) - undoManager.push(.foreignKeyAdd(fk: placeholder)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyAdd(fk: placeholder)) + } + undoManager.setActionName(String(localized: "Add Foreign Key")) validate() hasChanges = true reloadVersion += 1 @@ -151,7 +165,10 @@ final class StructureChangeManager { func addColumn(_ column: EditableColumnDefinition) { workingColumns.append(column) pendingChanges[.column(column.id)] = .addColumn(column) - undoManager.push(.columnAdd(column: column)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnAdd(column: column)) + } + undoManager.setActionName(String(localized: "Add Column")) hasChanges = true reloadVersion += 1 rebuildVisualStateCache() @@ -160,7 +177,10 @@ final class StructureChangeManager { func addIndex(_ index: EditableIndexDefinition) { workingIndexes.append(index) pendingChanges[.index(index.id)] = .addIndex(index) - undoManager.push(.indexAdd(index: index)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexAdd(index: index)) + } + undoManager.setActionName(String(localized: "Add Index")) hasChanges = true reloadVersion += 1 rebuildVisualStateCache() @@ -169,7 +189,10 @@ final class StructureChangeManager { func addForeignKey(_ foreignKey: EditableForeignKeyDefinition) { workingForeignKeys.append(foreignKey) pendingChanges[.foreignKey(foreignKey.id)] = .addForeignKey(foreignKey) - undoManager.push(.foreignKeyAdd(fk: foreignKey)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyAdd(fk: foreignKey)) + } + undoManager.setActionName(String(localized: "Add Foreign Key")) hasChanges = true reloadVersion += 1 rebuildVisualStateCache() @@ -182,7 +205,10 @@ final class StructureChangeManager { if let workingIndex = workingColumns.firstIndex(where: { $0.id == id }) { let oldWorking = workingColumns[workingIndex] if oldWorking != newColumn { - undoManager.push(.columnEdit(id: id, old: oldWorking, new: newColumn)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnEdit(id: id, old: oldWorking, new: newColumn)) + } + undoManager.setActionName(String(localized: "Edit Column")) } } @@ -214,7 +240,10 @@ final class StructureChangeManager { // Check if it's an existing column (from database) or a new column (not yet saved) if let column = currentColumns.first(where: { $0.id == id }) { // Existing column - mark as deleted (keep in workingColumns for visual feedback) - undoManager.push(.columnDelete(column: column)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnDelete(column: column)) + } + undoManager.setActionName(String(localized: "Delete Column")) pendingChanges[.column(id)] = .deleteColumn(column) // Track changed row for reload if let rowIndex = workingColumns.firstIndex(where: { $0.id == id }) { @@ -223,7 +252,10 @@ final class StructureChangeManager { } else { // New column that hasn't been saved yet - remove from list if let column = workingColumns.first(where: { $0.id == id }) { - undoManager.push(.columnDelete(column: column)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnDelete(column: column)) + } + undoManager.setActionName(String(localized: "Delete Column")) } if let rowIndex = workingColumns.firstIndex(where: { $0.id == id }) { // Track ALL rows from this index onwards for reload (indices shift down) @@ -248,7 +280,10 @@ final class StructureChangeManager { if let workingIdx = workingIndexes.firstIndex(where: { $0.id == id }) { let oldWorking = workingIndexes[workingIdx] if oldWorking != newIndex { - undoManager.push(.indexEdit(id: id, old: oldWorking, new: newIndex)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexEdit(id: id, old: oldWorking, new: newIndex)) + } + undoManager.setActionName(String(localized: "Edit Index")) } } @@ -278,7 +313,10 @@ final class StructureChangeManager { // Check if it's an existing index or a new index if let index = currentIndexes.first(where: { $0.id == id }) { // Existing index - mark as deleted (keep in workingIndexes for visual feedback) - undoManager.push(.indexDelete(index: index)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexDelete(index: index)) + } + undoManager.setActionName(String(localized: "Delete Index")) pendingChanges[.index(id)] = .deleteIndex(index) // Track changed row for reload if let rowIndex = workingIndexes.firstIndex(where: { $0.id == id }) { @@ -287,7 +325,10 @@ final class StructureChangeManager { } else { // New index that hasn't been saved yet - remove from list if let index = workingIndexes.first(where: { $0.id == id }) { - undoManager.push(.indexDelete(index: index)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexDelete(index: index)) + } + undoManager.setActionName(String(localized: "Delete Index")) } if let rowIndex = workingIndexes.firstIndex(where: { $0.id == id }) { // Track ALL rows from this index onwards for reload (indices shift down) @@ -312,7 +353,10 @@ final class StructureChangeManager { if let workingIdx = workingForeignKeys.firstIndex(where: { $0.id == id }) { let oldWorking = workingForeignKeys[workingIdx] if oldWorking != newFK { - undoManager.push(.foreignKeyEdit(id: id, old: oldWorking, new: newFK)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyEdit(id: id, old: oldWorking, new: newFK)) + } + undoManager.setActionName(String(localized: "Edit Foreign Key")) } } @@ -342,7 +386,10 @@ final class StructureChangeManager { // Check if it's an existing foreign key or a new foreign key if let fk = currentForeignKeys.first(where: { $0.id == id }) { // Existing FK - mark as deleted (keep in workingForeignKeys for visual feedback) - undoManager.push(.foreignKeyDelete(fk: fk)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyDelete(fk: fk)) + } + undoManager.setActionName(String(localized: "Delete Foreign Key")) pendingChanges[.foreignKey(id)] = .deleteForeignKey(fk) // Track changed row for reload if let rowIndex = workingForeignKeys.firstIndex(where: { $0.id == id }) { @@ -351,7 +398,10 @@ final class StructureChangeManager { } else { // New FK that hasn't been saved yet - remove from list if let fk = workingForeignKeys.first(where: { $0.id == id }) { - undoManager.push(.foreignKeyDelete(fk: fk)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyDelete(fk: fk)) + } + undoManager.setActionName(String(localized: "Delete Foreign Key")) } if let rowIndex = workingForeignKeys.firstIndex(where: { $0.id == id }) { // Track ALL rows from this index onwards for reload (indices shift down) @@ -374,7 +424,11 @@ final class StructureChangeManager { func updatePrimaryKey(_ columns: [String]) { // Push undo action before modifying if columns != workingPrimaryKey { - undoManager.push(.primaryKeyChange(old: workingPrimaryKey, new: columns)) + let oldPK = workingPrimaryKey + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.primaryKeyChange(old: oldPK, new: columns)) + } + undoManager.setActionName(String(localized: "Change Primary Key")) } if columns != currentPrimaryKey { @@ -487,7 +541,7 @@ final class StructureChangeManager { resetWorkingState() reloadVersion += 1 rebuildVisualStateCache() - undoManager.clearAll() + undoManager.removeAllActions() } func getChangesArray() -> [SchemaChange] { @@ -497,148 +551,153 @@ final class StructureChangeManager { // MARK: - Undo/Redo Operations func undo() { - guard let action = undoManager.undo() else { return } - applyUndoAction(action, isRedo: false) + guard undoManager.canUndo else { return } + undoManager.undo() } func redo() { - guard let action = undoManager.redo() else { return } - applyUndoAction(action, isRedo: true) + guard undoManager.canRedo else { return } + undoManager.redo() } - private func applyUndoAction(_ action: SchemaUndoAction, isRedo: Bool) { + private func applySchemaUndo(_ action: SchemaUndoAction) { switch action { case .columnEdit(let id, let old, let new): - let column = isRedo ? new : old + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnEdit(id: id, old: new, new: old)) + } + undoManager.setActionName(String(localized: "Edit Column")) if let index = workingColumns.firstIndex(where: { $0.id == id }) { - workingColumns[index] = column + workingColumns[index] = old if let currentIndex = currentColumns.firstIndex(where: { $0.id == id }) { let current = currentColumns[currentIndex] - if column != current { - pendingChanges[.column(id)] = .modifyColumn(old: current, new: column) + if old != current { + pendingChanges[.column(id)] = .modifyColumn(old: current, new: old) } else { pendingChanges.removeValue(forKey: .column(id)) } + } else { + pendingChanges[.column(id)] = .addColumn(old) } } case .columnAdd(let column): - if isRedo { - workingColumns.append(column) - pendingChanges[.column(column.id)] = .addColumn(column) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnDelete(column: column)) + } + undoManager.setActionName(String(localized: "Add Column")) + if currentColumns.contains(where: { $0.id == column.id }) { + pendingChanges[.column(column.id)] = .deleteColumn(column) } else { workingColumns.removeAll { $0.id == column.id } pendingChanges.removeValue(forKey: .column(column.id)) } case .columnDelete(let column): - if isRedo { - // For existing columns, keep in workingColumns for strikethrough; for new columns, remove - if currentColumns.contains(where: { $0.id == column.id }) { - pendingChanges[.column(column.id)] = .deleteColumn(column) - } else { - workingColumns.removeAll { $0.id == column.id } - pendingChanges.removeValue(forKey: .column(column.id)) - } + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnAdd(column: column)) + } + undoManager.setActionName(String(localized: "Delete Column")) + if currentColumns.contains(where: { $0.id == column.id }) { + pendingChanges.removeValue(forKey: .column(column.id)) } else { - // Undo delete: if column is still in workingColumns (existing, kept for strikethrough), - // just clear pending change. If physically removed (new column), re-add it. - if workingColumns.contains(where: { $0.id == column.id }) { - pendingChanges.removeValue(forKey: .column(column.id)) - } else { - workingColumns.append(column) - pendingChanges[.column(column.id)] = .addColumn(column) - } + workingColumns.append(column) + pendingChanges[.column(column.id)] = .addColumn(column) } case .indexEdit(let id, let old, let new): - let index = isRedo ? new : old + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexEdit(id: id, old: new, new: old)) + } + undoManager.setActionName(String(localized: "Edit Index")) if let idx = workingIndexes.firstIndex(where: { $0.id == id }) { - workingIndexes[idx] = index + workingIndexes[idx] = old if let currentIdx = currentIndexes.firstIndex(where: { $0.id == id }) { let current = currentIndexes[currentIdx] - if index != current { - pendingChanges[.index(id)] = .modifyIndex(old: current, new: index) + if old != current { + pendingChanges[.index(id)] = .modifyIndex(old: current, new: old) } else { pendingChanges.removeValue(forKey: .index(id)) } + } else { + pendingChanges[.index(id)] = .addIndex(old) } } case .indexAdd(let index): - if isRedo { - workingIndexes.append(index) - pendingChanges[.index(index.id)] = .addIndex(index) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexDelete(index: index)) + } + undoManager.setActionName(String(localized: "Add Index")) + if currentIndexes.contains(where: { $0.id == index.id }) { + pendingChanges[.index(index.id)] = .deleteIndex(index) } else { workingIndexes.removeAll { $0.id == index.id } pendingChanges.removeValue(forKey: .index(index.id)) } case .indexDelete(let index): - if isRedo { - // For existing indexes, keep in workingIndexes for strikethrough; for new indexes, remove - if currentIndexes.contains(where: { $0.id == index.id }) { - pendingChanges[.index(index.id)] = .deleteIndex(index) - } else { - workingIndexes.removeAll { $0.id == index.id } - pendingChanges.removeValue(forKey: .index(index.id)) - } + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexAdd(index: index)) + } + undoManager.setActionName(String(localized: "Delete Index")) + if currentIndexes.contains(where: { $0.id == index.id }) { + pendingChanges.removeValue(forKey: .index(index.id)) } else { - // Undo delete: if index is still in workingIndexes (existing, kept for strikethrough), - // just clear pending change. If physically removed (new index), re-add it. - if workingIndexes.contains(where: { $0.id == index.id }) { - pendingChanges.removeValue(forKey: .index(index.id)) - } else { - workingIndexes.append(index) - pendingChanges[.index(index.id)] = .addIndex(index) - } + workingIndexes.append(index) + pendingChanges[.index(index.id)] = .addIndex(index) } case .foreignKeyEdit(let id, let old, let new): - let fk = isRedo ? new : old + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyEdit(id: id, old: new, new: old)) + } + undoManager.setActionName(String(localized: "Edit Foreign Key")) if let idx = workingForeignKeys.firstIndex(where: { $0.id == id }) { - workingForeignKeys[idx] = fk + workingForeignKeys[idx] = old if let currentIdx = currentForeignKeys.firstIndex(where: { $0.id == id }) { let current = currentForeignKeys[currentIdx] - if fk != current { - pendingChanges[.foreignKey(id)] = .modifyForeignKey(old: current, new: fk) + if old != current { + pendingChanges[.foreignKey(id)] = .modifyForeignKey(old: current, new: old) } else { pendingChanges.removeValue(forKey: .foreignKey(id)) } + } else { + pendingChanges[.foreignKey(id)] = .addForeignKey(old) } } case .foreignKeyAdd(let fk): - if isRedo { - workingForeignKeys.append(fk) - pendingChanges[.foreignKey(fk.id)] = .addForeignKey(fk) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyDelete(fk: fk)) + } + undoManager.setActionName(String(localized: "Add Foreign Key")) + if currentForeignKeys.contains(where: { $0.id == fk.id }) { + pendingChanges[.foreignKey(fk.id)] = .deleteForeignKey(fk) } else { workingForeignKeys.removeAll { $0.id == fk.id } pendingChanges.removeValue(forKey: .foreignKey(fk.id)) } case .foreignKeyDelete(let fk): - if isRedo { - // For existing FKs, keep in workingForeignKeys for strikethrough; for new FKs, remove - if currentForeignKeys.contains(where: { $0.id == fk.id }) { - pendingChanges[.foreignKey(fk.id)] = .deleteForeignKey(fk) - } else { - workingForeignKeys.removeAll { $0.id == fk.id } - pendingChanges.removeValue(forKey: .foreignKey(fk.id)) - } + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyAdd(fk: fk)) + } + undoManager.setActionName(String(localized: "Delete Foreign Key")) + if currentForeignKeys.contains(where: { $0.id == fk.id }) { + pendingChanges.removeValue(forKey: .foreignKey(fk.id)) } else { - // Undo delete: if FK is still in workingForeignKeys (existing, kept for strikethrough), - // just clear pending change. If physically removed (new FK), re-add it. - if workingForeignKeys.contains(where: { $0.id == fk.id }) { - pendingChanges.removeValue(forKey: .foreignKey(fk.id)) - } else { - workingForeignKeys.append(fk) - pendingChanges[.foreignKey(fk.id)] = .addForeignKey(fk) - } + workingForeignKeys.append(fk) + pendingChanges[.foreignKey(fk.id)] = .addForeignKey(fk) } - case .primaryKeyChange(let old, let new): - workingPrimaryKey = isRedo ? new : old + case .primaryKeyChange(let old, _): + let current = workingPrimaryKey + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.primaryKeyChange(old: current, new: old)) + } + undoManager.setActionName(String(localized: "Change Primary Key")) + workingPrimaryKey = old if workingPrimaryKey != currentPrimaryKey { pendingChanges[.primaryKey] = .modifyPrimaryKey(old: currentPrimaryKey, new: workingPrimaryKey) } else { @@ -646,6 +705,7 @@ final class StructureChangeManager { } } + validate() hasChanges = !pendingChanges.isEmpty reloadVersion += 1 rebuildVisualStateCache() @@ -730,3 +790,18 @@ final class StructureChangeManager { visualStateCache.removeAll() } } + +// MARK: - Schema Undo Action + +enum SchemaUndoAction { + case columnEdit(id: UUID, old: EditableColumnDefinition, new: EditableColumnDefinition) + case columnAdd(column: EditableColumnDefinition) + case columnDelete(column: EditableColumnDefinition) + case indexEdit(id: UUID, old: EditableIndexDefinition, new: EditableIndexDefinition) + case indexAdd(index: EditableIndexDefinition) + case indexDelete(index: EditableIndexDefinition) + case foreignKeyEdit(id: UUID, old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) + case foreignKeyAdd(fk: EditableForeignKeyDefinition) + case foreignKeyDelete(fk: EditableForeignKeyDefinition) + case primaryKeyChange(old: [String], new: [String]) +} diff --git a/TablePro/Core/SchemaTracking/StructureUndoManager.swift b/TablePro/Core/SchemaTracking/StructureUndoManager.swift deleted file mode 100644 index 0e348db94..000000000 --- a/TablePro/Core/SchemaTracking/StructureUndoManager.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// StructureUndoManager.swift -// TablePro -// -// Undo/redo stack for schema changes - mirrors DataChangeUndoManager pattern -// - -import Foundation - -/// Represents an action that can be undone in schema editing -enum SchemaUndoAction { - case columnEdit(id: UUID, old: EditableColumnDefinition, new: EditableColumnDefinition) - case columnAdd(column: EditableColumnDefinition) - case columnDelete(column: EditableColumnDefinition) - case indexEdit(id: UUID, old: EditableIndexDefinition, new: EditableIndexDefinition) - case indexAdd(index: EditableIndexDefinition) - case indexDelete(index: EditableIndexDefinition) - case foreignKeyEdit(id: UUID, old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) - case foreignKeyAdd(fk: EditableForeignKeyDefinition) - case foreignKeyDelete(fk: EditableForeignKeyDefinition) - case primaryKeyChange(old: [String], new: [String]) -} - -/// Manages undo/redo stack for schema changes -final class StructureUndoManager { - private var undoStack: [SchemaUndoAction] = [] - private var redoStack: [SchemaUndoAction] = [] - - private let maxStackSize = 100 - - // MARK: - Public API - - var canUndo: Bool { - !undoStack.isEmpty - } - - var canRedo: Bool { - !redoStack.isEmpty - } - - /// Push a new action onto the undo stack - func push(_ action: SchemaUndoAction) { - undoStack.append(action) - - // Limit stack size - if undoStack.count > maxStackSize { - undoStack.removeFirst() - } - - // Clear redo stack when new action is performed - redoStack.removeAll() - } - - /// Pop the last action from undo stack - func undo() -> SchemaUndoAction? { - guard let action = undoStack.popLast() else { - return nil - } - - redoStack.append(action) - return action - } - - /// Pop the last action from redo stack - func redo() -> SchemaUndoAction? { - guard let action = redoStack.popLast() else { - return nil - } - - undoStack.append(action) - return action - } - - /// Clear all stacks - func clearAll() { - undoStack.removeAll() - redoStack.removeAll() - } -} diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 4a9784f7e..0512f9ed4 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -160,36 +160,7 @@ final class RowOperationsManager { /// - Returns: Updated selection indices func undoLastChange(resultRows: inout [[String?]]) -> Set? { guard let result = changeManager.undoLastChange() else { return nil } - - var adjustedSelection: Set? - - switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _): - if rowIndex < resultRows.count { - resultRows[rowIndex][columnIndex] = previousValue - } - - case .rowInsertion(let rowIndex): - if rowIndex < resultRows.count { - resultRows.remove(at: rowIndex) - adjustedSelection = Set() - } - - case .rowDeletion: - break - - case .batchRowDeletion: - break - - case .batchRowInsertion(let rowIndices, let rowValues): - for (index, rowIndex) in rowIndices.enumerated().reversed() { - guard index < rowValues.count else { continue } - guard rowIndex <= resultRows.count else { continue } - resultRows.insert(rowValues[index], at: rowIndex) - } - } - - return adjustedSelection + return applyUndoResult(result, resultRows: &resultRows) } /// Redo the last undone change @@ -199,17 +170,27 @@ final class RowOperationsManager { /// - Returns: Updated selection indices func redoLastChange(resultRows: inout [[String?]], columns: [String]) -> Set? { guard let result = changeManager.redoLastChange() else { return nil } + return applyUndoResult(result, resultRows: &resultRows) + } + private func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? { switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, _, let newValue): + case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _): if rowIndex < resultRows.count { - resultRows[rowIndex][columnIndex] = newValue + resultRows[rowIndex][columnIndex] = previousValue } case .rowInsertion(let rowIndex): - let newValues = [String?](repeating: nil, count: columns.count) - if rowIndex <= resultRows.count { - resultRows.insert(newValues, at: rowIndex) + if result.needsRowRemoval { + if rowIndex < resultRows.count { + resultRows.remove(at: rowIndex) + return Set() + } + } else if result.needsRowRestore { + let values = result.restoreRow ?? [String?](repeating: nil, count: resultRows.first?.count ?? 0) + if rowIndex <= resultRows.count { + resultRows.insert(values, at: rowIndex) + } } case .rowDeletion: @@ -218,10 +199,18 @@ final class RowOperationsManager { case .batchRowDeletion: break - case .batchRowInsertion(let rowIndices, _): - for rowIndex in rowIndices.sorted(by: >) { - guard rowIndex < resultRows.count else { continue } - resultRows.remove(at: rowIndex) + case .batchRowInsertion(let rowIndices, let rowValues): + if result.needsRowRemoval { + for rowIndex in rowIndices.sorted(by: >) { + guard rowIndex < resultRows.count else { continue } + resultRows.remove(at: rowIndex) + } + } else if result.needsRowRestore { + for (index, rowIndex) in rowIndices.enumerated().reversed() { + guard index < rowValues.count else { continue } + guard rowIndex <= resultRows.count else { continue } + resultRows.insert(rowValues[index], at: rowIndex) + } } } diff --git a/TablePro/Views/Components/HorizontalSplitView.swift b/TablePro/Views/Components/HorizontalSplitView.swift deleted file mode 100644 index 2217fdada..000000000 --- a/TablePro/Views/Components/HorizontalSplitView.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// HorizontalSplitView.swift -// TablePro -// - -import AppKit -import SwiftUI - -struct HorizontalSplitView: NSViewRepresentable { - var isTrailingCollapsed: Bool - @Binding var trailingWidth: CGFloat - var minTrailingWidth: CGFloat - var maxTrailingWidth: CGFloat - var autosaveName: String - @ViewBuilder var leading: Leading - @ViewBuilder var trailing: Trailing - - func makeCoordinator() -> Coordinator { - Coordinator(trailingWidth: $trailingWidth) - } - - func makeNSView(context: Context) -> NSSplitView { - let splitView = NSSplitView() - splitView.isVertical = true - splitView.dividerStyle = .thin - splitView.autosaveName = autosaveName - splitView.delegate = context.coordinator - - let leadingHosting = NSHostingView(rootView: leading) - leadingHosting.sizingOptions = [.minSize] - - let trailingHosting = NSHostingView(rootView: trailing) - trailingHosting.sizingOptions = [.minSize] - - splitView.addArrangedSubview(leadingHosting) - splitView.addArrangedSubview(trailingHosting) - - context.coordinator.leadingHosting = leadingHosting - context.coordinator.trailingHosting = trailingHosting - context.coordinator.lastCollapsedState = isTrailingCollapsed - context.coordinator.minWidth = minTrailingWidth - context.coordinator.maxWidth = maxTrailingWidth - - if isTrailingCollapsed { - trailingHosting.isHidden = true - } - - return splitView - } - - func updateNSView(_ splitView: NSSplitView, context: Context) { - context.coordinator.leadingHosting?.rootView = leading - context.coordinator.trailingHosting?.rootView = trailing - context.coordinator.trailingWidth = $trailingWidth - context.coordinator.minWidth = minTrailingWidth - context.coordinator.maxWidth = maxTrailingWidth - - guard let trailingView = context.coordinator.trailingHosting else { return } - let wasCollapsed = context.coordinator.lastCollapsedState - - if isTrailingCollapsed != wasCollapsed { - context.coordinator.lastCollapsedState = isTrailingCollapsed - if isTrailingCollapsed { - if splitView.subviews.count >= 2 { - context.coordinator.savedDividerPosition = splitView.subviews[1].frame.width - } - splitView.setPosition(splitView.bounds.width, ofDividerAt: 0) - trailingView.isHidden = true - } else { - trailingView.isHidden = false - let targetWidth = context.coordinator.savedDividerPosition ?? trailingWidth - splitView.adjustSubviews() - splitView.setPosition(splitView.bounds.width - targetWidth, ofDividerAt: 0) - } - } - } - - final class Coordinator: NSObject, NSSplitViewDelegate { - var leadingHosting: NSHostingView? - var trailingHosting: NSHostingView? - var lastCollapsedState = false - var savedDividerPosition: CGFloat? - var minWidth: CGFloat = 0 - var maxWidth: CGFloat = 0 - var trailingWidth: Binding - - init(trailingWidth: Binding) { - self.trailingWidth = trailingWidth - } - - func splitView( - _ splitView: NSSplitView, - constrainMinCoordinate proposedMinimumPosition: CGFloat, - ofSubviewAt dividerIndex: Int - ) -> CGFloat { - splitView.bounds.width - maxWidth - } - - func splitView( - _ splitView: NSSplitView, - constrainMaxCoordinate proposedMaximumPosition: CGFloat, - ofSubviewAt dividerIndex: Int - ) -> CGFloat { - splitView.bounds.width - minWidth - } - - func splitView( - _ splitView: NSSplitView, - canCollapseSubview subview: NSView - ) -> Bool { - subview == trailingHosting - } - - func splitView( - _ splitView: NSSplitView, - effectiveRect proposedEffectiveRect: NSRect, - forDrawnRect drawnRect: NSRect, - ofDividerAt dividerIndex: Int - ) -> NSRect { - if trailingHosting?.isHidden == true { - return .zero - } - return proposedEffectiveRect - } - - func splitView( - _ splitView: NSSplitView, - shouldHideDividerAt dividerIndex: Int - ) -> Bool { - trailingHosting?.isHidden == true - } - - func splitViewDidResizeSubviews(_ notification: Notification) { - guard let splitView = notification.object as? NSSplitView, - splitView.subviews.count >= 2, - trailingHosting?.isHidden != true - else { return } - let width = splitView.subviews[1].frame.width - guard width > 0, abs(width - trailingWidth.wrappedValue) > 0.5 else { return } - trailingWidth.wrappedValue = width - } - } -} diff --git a/TablePro/Views/Components/PanelResizeHandle.swift b/TablePro/Views/Components/PanelResizeHandle.swift new file mode 100644 index 000000000..6f8531c6b --- /dev/null +++ b/TablePro/Views/Components/PanelResizeHandle.swift @@ -0,0 +1,40 @@ +// +// PanelResizeHandle.swift +// TablePro +// +// Draggable resize handle for the right panel. +// + +import SwiftUI + +struct PanelResizeHandle: View { + @Binding var panelWidth: CGFloat + + @State private var isDragging = false + + var body: some View { + Rectangle() + .fill(Color.clear) + .frame(width: 5) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + isDragging = true + // Dragging left increases panel width (handle is on the leading edge) + let newWidth = panelWidth - value.translation.width + panelWidth = min(max(newWidth, RightPanelState.minWidth), RightPanelState.maxWidth) + } + .onEnded { _ in + isDragging = false + } + ) + } +} diff --git a/TableProTests/Core/ChangeTracking/DataChangeUndoManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeUndoManagerTests.swift deleted file mode 100644 index 97ce59bca..000000000 --- a/TableProTests/Core/ChangeTracking/DataChangeUndoManagerTests.swift +++ /dev/null @@ -1,250 +0,0 @@ -// -// DataChangeUndoManagerTests.swift -// TableProTests -// -// Tests for DataChangeUndoManager -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("Data Change Undo Manager") -struct DataChangeUndoManagerTests { - private func makeCellEditAction(row: Int = 0, col: Int = 0) -> UndoAction { - .cellEdit(rowIndex: row, columnIndex: col, columnName: "col\(col)", previousValue: "old", newValue: "new") - } - - // MARK: - Initial State Tests - - @Test("Fresh instance has canUndo == false") - func initialCanUndoFalse() { - let manager = DataChangeUndoManager() - #expect(manager.canUndo == false) - } - - @Test("Fresh instance has canRedo == false") - func initialCanRedoFalse() { - let manager = DataChangeUndoManager() - #expect(manager.canRedo == false) - } - - @Test("Fresh instance has undoCount == 0") - func initialUndoCountZero() { - let manager = DataChangeUndoManager() - #expect(manager.undoCount == 0) - } - - @Test("Fresh instance has redoCount == 0") - func initialRedoCountZero() { - let manager = DataChangeUndoManager() - #expect(manager.redoCount == 0) - } - - // MARK: - Push Tests - - @Test("Push adds action to undo stack") - func pushAddsToUndoStack() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - #expect(manager.canUndo == true) - #expect(manager.undoCount == 1) - } - - // MARK: - Pop Tests - - @Test("Pop undo returns last pushed action (LIFO)") - func popUndoReturnsLastPushedAction() { - let manager = DataChangeUndoManager() - let actionA = makeCellEditAction(row: 0) - let actionB = makeCellEditAction(row: 1) - manager.push(actionA) - manager.push(actionB) - - let first = manager.popUndo() - if case .cellEdit(let rowIndex, _, _, _, _) = first { - #expect(rowIndex == 1) - } else { - Issue.record("Expected cellEdit action") - } - - let second = manager.popUndo() - if case .cellEdit(let rowIndex, _, _, _, _) = second { - #expect(rowIndex == 0) - } else { - Issue.record("Expected cellEdit action") - } - } - - @Test("Pop undo returns nil when stack is empty") - func popUndoReturnsNilWhenEmpty() { - let manager = DataChangeUndoManager() - #expect(manager.popUndo() == nil) - } - - @Test("Pop redo returns nil when stack is empty") - func popRedoReturnsNilWhenEmpty() { - let manager = DataChangeUndoManager() - #expect(manager.popRedo() == nil) - } - - // MARK: - Move Tests - - @Test("moveToRedo adds action to redo stack") - func moveToRedoAddsToRedoStack() { - let manager = DataChangeUndoManager() - manager.moveToRedo(makeCellEditAction()) - #expect(manager.canRedo == true) - #expect(manager.redoCount == 1) - } - - @Test("moveToUndo adds action to undo stack") - func moveToUndoAddsToUndoStack() { - let manager = DataChangeUndoManager() - manager.moveToUndo(makeCellEditAction()) - #expect(manager.canUndo == true) - #expect(manager.undoCount == 1) - } - - // MARK: - Clear Tests - - @Test("clearUndo empties undo stack only, preserves redo") - func clearUndoEmptiesUndoOnly() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - manager.moveToRedo(makeCellEditAction(row: 1)) - - manager.clearUndo() - - #expect(manager.canUndo == false) - #expect(manager.canRedo == true) - } - - @Test("clearRedo empties redo stack only, preserves undo") - func clearRedoEmptiesRedoOnly() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - manager.moveToRedo(makeCellEditAction(row: 1)) - - manager.clearRedo() - - #expect(manager.canUndo == true) - #expect(manager.canRedo == false) - } - - @Test("clearAll empties both stacks") - func clearAllEmptiesBoth() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - manager.moveToRedo(makeCellEditAction(row: 1)) - - manager.clearAll() - - #expect(manager.undoCount == 0) - #expect(manager.redoCount == 0) - } - - // MARK: - Trimming Tests - - @Test("Undo stack trims to 100 when 101 actions pushed") - func stackTrimmingAt101Pushes() { - let manager = DataChangeUndoManager() - for i in 0 ..< 101 { - manager.push(makeCellEditAction(row: i)) - } - #expect(manager.undoCount == 100) - } - - @Test("Redo stack also trims at max depth") - func redoStackAlsoTrimsAtMaxDepth() { - let manager = DataChangeUndoManager() - for i in 0 ..< 101 { - manager.moveToRedo(makeCellEditAction(row: i)) - } - #expect(manager.redoCount == 100) - } - - // MARK: - Order & Fidelity Tests - - @Test("LIFO order is preserved across multiple pops") - func lifoOrderPreserved() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction(row: 0)) - manager.push(makeCellEditAction(row: 1)) - manager.push(makeCellEditAction(row: 2)) - - if case .cellEdit(let row, _, _, _, _) = manager.popUndo() { - #expect(row == 2) - } else { - Issue.record("Expected cellEdit action") - } - - if case .cellEdit(let row, _, _, _, _) = manager.popUndo() { - #expect(row == 1) - } else { - Issue.record("Expected cellEdit action") - } - - if case .cellEdit(let row, _, _, _, _) = manager.popUndo() { - #expect(row == 0) - } else { - Issue.record("Expected cellEdit action") - } - } - - @Test("moveToRedo preserves action fidelity through round-trip") - func moveToRedoPreservesActionFidelity() { - let manager = DataChangeUndoManager() - let action = UndoAction.cellEdit( - rowIndex: 5, - columnIndex: 3, - columnName: "email", - previousValue: "old@test.com", - newValue: "new@test.com" - ) - - manager.push(action) - guard let popped = manager.popUndo() else { - Issue.record("Expected non-nil undo action") - return - } - - manager.moveToRedo(popped) - let restored = manager.popRedo() - - if case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue) = restored { - #expect(rowIndex == 5) - #expect(columnIndex == 3) - #expect(columnName == "email") - #expect(previousValue == "old@test.com") - #expect(newValue == "new@test.com") - } else { - Issue.record("Expected cellEdit action") - } - } - - // MARK: - Mixed Operations Test - - @Test("Mixed operations maintain correct counts") - func mixedOperationsMaintainCorrectCounts() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction(row: 0)) - manager.push(makeCellEditAction(row: 1)) - manager.push(makeCellEditAction(row: 2)) - - guard let action1 = manager.popUndo() else { - Issue.record("Expected non-nil undo action") - return - } - manager.moveToRedo(action1) - - guard let action2 = manager.popUndo() else { - Issue.record("Expected non-nil undo action") - return - } - manager.moveToRedo(action2) - - #expect(manager.undoCount == 1) - #expect(manager.redoCount == 2) - } -} diff --git a/TableProTests/Core/SchemaTracking/StructureUndoManagerTests.swift b/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift similarity index 58% rename from TableProTests/Core/SchemaTracking/StructureUndoManagerTests.swift rename to TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift index 21d47e22d..5b75e83d0 100644 --- a/TableProTests/Core/SchemaTracking/StructureUndoManagerTests.swift +++ b/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift @@ -1,5 +1,5 @@ // -// StructureUndoManagerTests.swift +// StructureChangeManagerUndoTests.swift // TableProTests // // Tests for S-01: Undo/Redo must be functional in StructureChangeManager @@ -9,160 +9,6 @@ import Foundation import Testing @testable import TablePro -// MARK: - StructureUndoManager Unit Tests - -@Suite("Structure Undo Manager") -struct StructureUndoManagerTests { - - // MARK: - Helpers - - private func makeColumn( - name: String = "email", - dataType: String = "VARCHAR(255)" - ) -> EditableColumnDefinition { - EditableColumnDefinition( - id: UUID(), - name: name, - dataType: dataType, - isNullable: true, - defaultValue: nil, - autoIncrement: false, - unsigned: false, - comment: nil, - collation: nil, - onUpdate: nil, - charset: nil, - extra: nil, - isPrimaryKey: false - ) - } - - private func makeIndex( - name: String = "idx_email", - columns: [String] = ["email"] - ) -> EditableIndexDefinition { - EditableIndexDefinition( - id: UUID(), - name: name, - columns: columns, - type: .btree, - isUnique: false, - isPrimary: false, - comment: nil - ) - } - - private func makeFK( - name: String = "fk_role", - columns: [String] = ["role_id"], - refTable: String = "roles", - refColumns: [String] = ["id"] - ) -> EditableForeignKeyDefinition { - EditableForeignKeyDefinition( - id: UUID(), - name: name, - columns: columns, - referencedTable: refTable, - referencedColumns: refColumns, - onDelete: .cascade, - onUpdate: .noAction - ) - } - - // MARK: - Basic Push/Pop Tests - - @Test("Push and undo returns the action") - func pushAndUndo() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - - #expect(manager.canUndo == true) - let action = manager.undo() - #expect(action != nil) - } - - @Test("Undo on empty stack returns nil") - func undoEmpty() { - let manager = StructureUndoManager() - #expect(manager.canUndo == false) - #expect(manager.undo() == nil) - } - - @Test("Redo on empty stack returns nil") - func redoEmpty() { - let manager = StructureUndoManager() - #expect(manager.canRedo == false) - #expect(manager.redo() == nil) - } - - @Test("Undo moves action to redo stack") - func undoMovesToRedo() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - - _ = manager.undo() - #expect(manager.canUndo == false) - #expect(manager.canRedo == true) - } - - @Test("Redo moves action back to undo stack") - func redoMovesBack() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - - _ = manager.undo() - _ = manager.redo() - #expect(manager.canUndo == true) - #expect(manager.canRedo == false) - } - - @Test("New action clears redo stack") - func newActionClearsRedo() { - let manager = StructureUndoManager() - let col1 = makeColumn(name: "a") - let col2 = makeColumn(name: "b") - - manager.push(.columnAdd(column: col1)) - _ = manager.undo() - #expect(manager.canRedo == true) - - manager.push(.columnAdd(column: col2)) - #expect(manager.canRedo == false) - } - - @Test("Max stack size is enforced") - func maxStackSize() { - let manager = StructureUndoManager() - for i in 0..<150 { - let col = makeColumn(name: "col_\(i)") - manager.push(.columnAdd(column: col)) - } - - // Should be capped at 100 - var count = 0 - while manager.undo() != nil { - count += 1 - } - #expect(count == 100) - } - - @Test("clearAll empties both stacks") - func clearAll() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - manager.push(.columnDelete(column: col)) - _ = manager.undo() - - manager.clearAll() - #expect(manager.canUndo == false) - #expect(manager.canRedo == false) - } -} - // MARK: - StructureChangeManager Undo Integration Tests @Suite("Structure Change Manager Undo/Redo Integration") @@ -205,7 +51,6 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - // Edit the "name" column let nameCol = manager.workingColumns[1] var modified = nameCol modified.dataType = "TEXT" @@ -215,7 +60,6 @@ struct StructureChangeManagerUndoTests { #expect(manager.hasChanges == true) #expect(manager.canUndo == true) - // Undo should revert manager.undo() #expect(manager.workingColumns[1].dataType == "VARCHAR(255)") #expect(manager.hasChanges == false) @@ -264,9 +108,8 @@ struct StructureChangeManagerUndoTests { #expect(manager.canUndo == true) manager.undo() - // Column should be restored (no longer marked as deleted) let change = manager.pendingChanges[.column(emailCol.id)] - #expect(change == nil) // No pending change = restored to original + #expect(change == nil) #expect(manager.hasChanges == false) } @@ -275,24 +118,20 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - // Edit 1: change name type let nameCol = manager.workingColumns[1] var mod1 = nameCol mod1.dataType = "TEXT" manager.updateColumn(id: nameCol.id, with: mod1) - // Edit 2: change email type let emailCol = manager.workingColumns[2] var mod2 = emailCol mod2.dataType = "TEXT" manager.updateColumn(id: emailCol.id, with: mod2) - // Undo edit 2 manager.undo() #expect(manager.workingColumns[2].dataType == "VARCHAR(255)") - #expect(manager.workingColumns[1].dataType == "TEXT") // edit 1 still applied + #expect(manager.workingColumns[1].dataType == "TEXT") - // Undo edit 1 manager.undo() #expect(manager.workingColumns[1].dataType == "VARCHAR(255)") #expect(manager.hasChanges == false) @@ -351,17 +190,15 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - let initialCount = manager.workingColumns.count // 3 + let initialCount = manager.workingColumns.count let emailCol = manager.workingColumns[2] - // Delete existing column (kept in workingColumns for strikethrough) manager.deleteColumn(id: emailCol.id) - #expect(manager.workingColumns.count == initialCount) // Still 3 (strikethrough) + #expect(manager.workingColumns.count == initialCount) #expect(manager.hasChanges == true) - // Undo should NOT append a duplicate manager.undo() - #expect(manager.workingColumns.count == initialCount) // Still 3, not 4! + #expect(manager.workingColumns.count == initialCount) #expect(manager.hasChanges == false) } @@ -370,26 +207,21 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - let initialCount = manager.workingColumns.count // 3 + let initialCount = manager.workingColumns.count let nameCol = manager.workingColumns[1] let emailCol = manager.workingColumns[2] - // Delete two existing columns manager.deleteColumn(id: nameCol.id) manager.deleteColumn(id: emailCol.id) - #expect(manager.workingColumns.count == initialCount) // Still 3 (both kept for strikethrough) + #expect(manager.workingColumns.count == initialCount) - // Undo delete of email manager.undo() - #expect(manager.workingColumns.count == initialCount) // Still 3 - // email should no longer be marked as deleted + #expect(manager.workingColumns.count == initialCount) #expect(manager.pendingChanges[.column(emailCol.id)] == nil) - // name should still be marked as deleted #expect(manager.pendingChanges[.column(nameCol.id)] != nil) - // Undo delete of name manager.undo() - #expect(manager.workingColumns.count == initialCount) // Still 3 + #expect(manager.workingColumns.count == initialCount) #expect(manager.pendingChanges[.column(nameCol.id)] == nil) #expect(manager.hasChanges == false) } @@ -399,18 +231,15 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - let initialCount = manager.workingColumns.count // 3 + let initialCount = manager.workingColumns.count - // Add a new column manager.addNewColumn() #expect(manager.workingColumns.count == initialCount + 1) let newCol = manager.workingColumns.last! - // Delete the new column (physically removes it) manager.deleteColumn(id: newCol.id) - #expect(manager.workingColumns.count == initialCount) // Removed + #expect(manager.workingColumns.count == initialCount) - // Undo should re-add the new column manager.undo() #expect(manager.workingColumns.count == initialCount + 1) #expect(manager.workingColumns.contains(where: { $0.id == newCol.id }))