diff --git a/CHANGELOG.md b/CHANGELOG.md index 864b3c5f3..b65bef687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed the separate Copilot settings tab and the per-feature routing UI - Existing AI providers are preserved on upgrade; the first one is auto-set as active - Filter value field uses a native SwiftUI suggestion dropdown instead of the AppKit autocomplete popup +- Undo/Redo routes through macOS responder chain, eliminating manual first-responder dispatch +- Native macOS UI patterns: Picker(.menu) for cell editors, native alerts, native List selection, .navigationTitle for sheets, NSSearchField for welcome search, borderless toolbar buttons, chevron indicator on SET picker +- Quit dialog defaults to Cancel on Return key +- Connection form delete button moved to far left +- SSH/SSL browse panels show descriptive message text ### Added diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index 7f2d0473e..72f9c14aa 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -13,8 +13,8 @@ import Observation @Observable @MainActor final class AnyChangeManager { - @ObservationIgnored private var dataManager: DataChangeManager? - @ObservationIgnored private var structureManager: StructureChangeManager? + @ObservationIgnored private(set) var dataManager: DataChangeManager? + @ObservationIgnored private(set) var structureManager: StructureChangeManager? var hasChanges: Bool { dataManager?.hasChanges ?? structureManager?.hasChanges ?? false @@ -44,7 +44,7 @@ final class AnyChangeManager { dataManager.changes } self._canRedo = { - dataManager.canRedo + dataManager.undoManager.canRedo } self._recordCellChange = { rowIndex, columnIndex, columnName, oldValue, newValue, originalRow in dataManager.recordCellChange( @@ -75,7 +75,7 @@ final class AnyChangeManager { Array(structureManager.pendingChanges.values) } self._canRedo = { - structureManager.canRedo + structureManager.undoManager.canRedo } self._recordCellChange = nil // Structure uses custom editing logic self._undoRowDeletion = nil @@ -87,6 +87,12 @@ final class AnyChangeManager { // MARK: - Public API + var undoManager: UndoManager? { + if let dataManager { return dataManager.undoManager } + if let structureManager { return structureManager.undoManager } + return nil + } + var canRedo: Bool { _canRedo() } diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index ffde7d698..ab39adee7 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -5,6 +5,7 @@ // Manager for tracking data changes with O(1) lookups. // Delegates SQL generation to SQLStatementGenerator. // Uses Apple's UndoManager (NSUndoManager) for undo/redo stack management. +// Undo closures mutate both change tracking state AND the RowBuffer directly. // import Foundation @@ -12,13 +13,6 @@ 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) @@ -31,6 +25,8 @@ final class DataChangeManager { private(set) var changedRowIndices: Set = [] + weak var rowBuffer: RowBuffer? + var tableName: String = "" var primaryKeyColumns: [String] = [] /// First PK column, for contexts that need a single column (paste, filters) @@ -51,12 +47,12 @@ final class DataChangeManager { private var modifiedCells: [Int: Set] = [:] private var insertedRowData: [Int: [String?]] = [:] - /// (rowIndex, changeType) → index in `changes` array for O(1) lookup + /// (rowIndex, changeType) -> index in `changes` array for O(1) lookup /// Replaces O(n) `firstIndex(where:)` scans in hot paths like `recordCellChange` private var changeIndex: [RowChangeKey: Int] = [:] /// Rebuild `changeIndex` from the `changes` array. - /// Called only for complex operations (bulk shifts, restoreState, clearChanges). + /// Called only for complex operations (bulk shifts, clearChanges). private func rebuildChangeIndex() { changeIndex.removeAll(keepingCapacity: true) for (index, change) in changes.enumerated() { @@ -86,7 +82,7 @@ final class DataChangeManager { } /// Binary search: count of elements in a sorted array that are strictly less than `target`. - /// Used for O(n log n) batch index shifting instead of O(n²) nested loops. + /// Used for O(n log n) batch index shifting instead of O(n^2) nested loops. private static func countLessThan(_ target: Int, in sorted: [Int]) -> Int { var lo = 0, hi = sorted.count while lo < hi { @@ -100,18 +96,13 @@ final class DataChangeManager { return lo } - private let undoManager: UndoManager = { + let undoManager: UndoManager = { let manager = UndoManager() manager.levelsOfUndo = 100 return manager }() - private var lastUndoResult: UndoResult? - - // MARK: - Undo/Redo Properties - - var canUndo: Bool { undoManager.canUndo } - var canRedo: Bool { undoManager.canRedo } + nonisolated init() {} // MARK: - Helper Methods @@ -232,10 +223,9 @@ final class DataChangeManager { )) } undoManager.registerUndo(withTarget: self) { target in - target.applyDataUndo(.cellEdit( + target.applyCellEditUndo( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - previousValue: oldValue, newValue: newValue - )) + previousValue: oldValue, newValue: newValue ) } undoManager.setActionName(String(localized: "Edit Cell")) changedRowIndices.insert(rowIndex) @@ -287,10 +277,9 @@ final class DataChangeManager { } undoManager.registerUndo(withTarget: self) { target in - target.applyDataUndo(.cellEdit( + target.applyCellEditUndo( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - previousValue: oldValue, newValue: newValue - )) + previousValue: oldValue, newValue: newValue ) } undoManager.setActionName(String(localized: "Edit Cell")) hasChanges = !changes.isEmpty @@ -307,7 +296,7 @@ final class DataChangeManager { deletedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) undoManager.registerUndo(withTarget: self) { target in - target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + target.applyRowDeletionUndo(rowIndex: rowIndex, originalRow: originalRow) } undoManager.setActionName(String(localized: "Delete Row")) hasChanges = true @@ -336,7 +325,7 @@ final class DataChangeManager { batchData.append((rowIndex: rowIndex, originalRow: originalRow)) } undoManager.registerUndo(withTarget: self) { target in - target.applyDataUndo(.batchRowDeletion(rows: batchData)) + target.applyBatchDeletionUndo(rows: batchData) } undoManager.setActionName(String(localized: "Delete Rows")) hasChanges = true @@ -351,7 +340,7 @@ final class DataChangeManager { insertedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) undoManager.registerUndo(withTarget: self) { target in - target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) + target.applyRowInsertionUndo(rowIndex: rowIndex) } undoManager.setActionName(String(localized: "Insert Row")) hasChanges = true @@ -465,7 +454,7 @@ final class DataChangeManager { .map { $0.newValue } rowValues.append(values) } else { - rowValues.append(Array(repeating: nil, count: columns.count)) + rowValues.append(insertedRowData[rowIndex] ?? Array(repeating: nil, count: columns.count)) } } @@ -476,7 +465,8 @@ final class DataChangeManager { } undoManager.registerUndo(withTarget: self) { target in - target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) + target.applyBatchInsertionUndo( + rowIndices: validRows, rowValues: rowValues ) } undoManager.setActionName(String(localized: "Insert Rows")) @@ -499,299 +489,16 @@ final class DataChangeManager { hasChanges = !changes.isEmpty } - // MARK: - Core Undo Application - - // swiftlint:disable:next function_body_length - private func applyDataUndo(_ action: UndoAction) { - switch action { - 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 { - if let cellIndex = changes[changeIdx].cellChanges.firstIndex(where: { - $0.columnIndex == columnIndex - }) { - if changes[changeIdx].type == .update { - let originalValue = changes[changeIdx].cellChanges[cellIndex].oldValue - if previousValue == originalValue { - changes[changeIdx].cellChanges.remove(at: cellIndex) - modifiedCells[rowIndex]?.remove(columnIndex) - if modifiedCells[rowIndex]?.isEmpty == true { - modifiedCells.removeValue(forKey: rowIndex) - } - if changes[changeIdx].cellChanges.isEmpty { - removeChangeAt(changeIdx) - } - } else { - let originalOldValue = changes[changeIdx].cellChanges[cellIndex].oldValue - changes[changeIdx].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: originalOldValue, - newValue: previousValue - ) - } - } else if changes[changeIdx].type == .insert { - changes[changeIdx].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: nil, - newValue: previousValue - ) - if var storedValues = insertedRowData[rowIndex], - columnIndex < storedValues.count { - storedValues[columnIndex] = previousValue - insertedRowData[rowIndex] = storedValues - } - } - } - } else { - recordCellChangeForRedo( - rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - oldValue: newValue, newValue: previousValue - ) - } - changedRowIndices.insert(rowIndex) - lastUndoResult = UndoResult(action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil) - - case .rowInsertion(let rowIndex): - let savedValues = insertedRowData[rowIndex] - undoManager.registerUndo(withTarget: self) { [savedValues] target in - if let savedValues { - target.insertedRowData[rowIndex] = savedValues - } - target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) - } - undoManager.setActionName(String(localized: "Insert Row")) - - 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: index, - columnName: columnName, - oldValue: nil, - newValue: savedValues?[safe: index] ?? nil - ) - } - let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) - changes.append(rowChange) - 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 - ) - } - - 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")) - - 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 - ) - } - - case .batchRowDeletion(let rows): - undoManager.registerUndo(withTarget: self) { target in - target.applyDataUndo(.batchRowDeletion(rows: rows)) - } - undoManager.setActionName(String(localized: "Delete Rows")) - - 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 - ) - } - - 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) - } - - 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 - ) - } - } - - hasChanges = !changes.isEmpty - reloadVersion += 1 - } - - /// 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 - } - - 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) - } - } 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 + func undo() { + guard undoManager.canUndo else { return } undoManager.undo() - return lastUndoResult } - func redoLastChange() -> UndoResult? { - guard undoManager.canRedo else { return nil } - lastUndoResult = nil + func redo() { + guard undoManager.canRedo else { return } undoManager.redo() - return lastUndoResult } // MARK: - SQL Generation @@ -916,34 +623,6 @@ final class DataChangeManager { reloadVersion += 1 } - // MARK: - Per-Tab State Management - - func saveState() -> TabPendingChanges { - var state = TabPendingChanges() - state.changes = changes - state.deletedRowIndices = deletedRowIndices - state.insertedRowIndices = insertedRowIndices - state.modifiedCells = modifiedCells - state.insertedRowData = insertedRowData - state.primaryKeyColumns = primaryKeyColumns - state.columns = columns - return state - } - - func restoreState(from state: TabPendingChanges, tableName: String, databaseType: DatabaseType) { - self.tableName = tableName - self.columns = state.columns - self.primaryKeyColumns = state.primaryKeyColumns - self.databaseType = databaseType - self.changes = state.changes - self.deletedRowIndices = state.deletedRowIndices - self.insertedRowIndices = state.insertedRowIndices - self.modifiedCells = state.modifiedCells - self.insertedRowData = state.insertedRowData - self.hasChanges = !state.changes.isEmpty - rebuildChangeIndex() - } - // MARK: - O(1) Lookups func isRowDeleted(_ rowIndex: Int) -> Bool { @@ -962,3 +641,368 @@ final class DataChangeManager { modifiedCells[rowIndex] ?? [] } } + +// MARK: - Focused Undo Methods + +extension DataChangeManager { + private func applyCellEditUndo( + rowIndex: Int, + columnIndex: Int, + columnName: String, + previousValue: String?, + newValue: String? + ) { + // Register inverse for redo + undoManager.registerUndo(withTarget: self) { target in + target.applyCellEditUndo( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + previousValue: newValue, newValue: previousValue ) + } + undoManager.setActionName(String(localized: "Edit Cell")) + + // Update change tracking state + let matchedIndex = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .update)] + ?? changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] + if let changeIdx = matchedIndex { + if let cellIndex = changes[changeIdx].cellChanges.firstIndex(where: { + $0.columnIndex == columnIndex + }) { + if changes[changeIdx].type == .update { + let originalValue = changes[changeIdx].cellChanges[cellIndex].oldValue + if previousValue == originalValue { + changes[changeIdx].cellChanges.remove(at: cellIndex) + modifiedCells[rowIndex]?.remove(columnIndex) + if modifiedCells[rowIndex]?.isEmpty == true { + modifiedCells.removeValue(forKey: rowIndex) + } + if changes[changeIdx].cellChanges.isEmpty { + removeChangeAt(changeIdx) + } + } else { + let originalOldValue = changes[changeIdx].cellChanges[cellIndex].oldValue + changes[changeIdx].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: originalOldValue, + newValue: previousValue + ) + } + } else if changes[changeIdx].type == .insert { + changes[changeIdx].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: nil, + newValue: previousValue + ) + if var storedValues = insertedRowData[rowIndex], + columnIndex < storedValues.count { + storedValues[columnIndex] = previousValue + insertedRowData[rowIndex] = storedValues + } + } + } + } else { + recordCellChangeForRedo( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: newValue, newValue: previousValue + ) + } + + // Mutate rowBuffer directly + if let rowBuffer, rowIndex < rowBuffer.rows.count { + rowBuffer.rows[rowIndex][columnIndex] = previousValue + } + + changedRowIndices.insert(rowIndex) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + + private func applyRowInsertionUndo(rowIndex: Int) { + let savedValues = insertedRowData[rowIndex] + + // Register inverse for redo + undoManager.registerUndo(withTarget: self) { [savedValues] target in + if let savedValues { + target.insertedRowData[rowIndex] = savedValues + } + target.applyRowInsertionRedo(rowIndex: rowIndex, savedValues: savedValues) + } + undoManager.setActionName(String(localized: "Insert Row")) + + if insertedRowIndices.contains(rowIndex) { + // Undo: remove the inserted row + undoRowInsertion(rowIndex: rowIndex) + + // Remove from rowBuffer + if let rowBuffer, rowIndex < rowBuffer.rows.count { + rowBuffer.rows.remove(at: rowIndex) + } + } else { + // Redo path: re-insert the row + shiftRowIndicesUp(from: rowIndex) + insertedRowIndices.insert(rowIndex) + let cellChanges = columns.enumerated().map { index, colName in + CellChange( + rowIndex: rowIndex, + columnIndex: index, + columnName: colName, + oldValue: nil, + newValue: savedValues?[safe: index] ?? nil + ) + } + let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) + changes.append(rowChange) + changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 + if let savedValues { + insertedRowData[rowIndex] = savedValues + } + + // Re-insert into rowBuffer + if let rowBuffer { + let values = savedValues ?? [String?](repeating: nil, count: rowBuffer.columns.count) + if rowIndex <= rowBuffer.rows.count { + rowBuffer.rows.insert(values, at: rowIndex) + } + } + } + + changedRowIndices.insert(rowIndex) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + + private func applyRowInsertionRedo(rowIndex: Int, savedValues: [String?]?) { + // Register inverse for undo again + undoManager.registerUndo(withTarget: self) { target in + target.applyRowInsertionUndo(rowIndex: rowIndex) + } + undoManager.setActionName(String(localized: "Insert Row")) + + if insertedRowIndices.contains(rowIndex) { + // This row is already inserted, undo it + undoRowInsertion(rowIndex: rowIndex) + + if let rowBuffer, rowIndex < rowBuffer.rows.count { + rowBuffer.rows.remove(at: rowIndex) + } + } else { + // Re-insert + shiftRowIndicesUp(from: rowIndex) + insertedRowIndices.insert(rowIndex) + let cellChanges = columns.enumerated().map { index, colName in + CellChange( + rowIndex: rowIndex, + columnIndex: index, + columnName: colName, + oldValue: nil, + newValue: savedValues?[safe: index] ?? nil + ) + } + let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) + changes.append(rowChange) + changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 + if let savedValues { + insertedRowData[rowIndex] = savedValues + } + + if let rowBuffer { + let values = savedValues ?? [String?](repeating: nil, count: rowBuffer.columns.count) + if rowIndex <= rowBuffer.rows.count { + rowBuffer.rows.insert(values, at: rowIndex) + } + } + } + + changedRowIndices.insert(rowIndex) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + + private func applyRowDeletionUndo(rowIndex: Int, originalRow: [String?]) { + undoManager.registerUndo(withTarget: self) { target in + target.applyRowDeletionUndo(rowIndex: rowIndex, originalRow: originalRow) + } + undoManager.setActionName(String(localized: "Delete Row")) + + if deletedRowIndices.contains(rowIndex) { + undoRowDeletion(rowIndex: rowIndex) + } else { + redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + } + + changedRowIndices.insert(rowIndex) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + + private func applyBatchDeletionUndo(rows: [(rowIndex: Int, originalRow: [String?])]) { + undoManager.registerUndo(withTarget: self) { target in + target.applyBatchDeletionUndo(rows: rows) + } + undoManager.setActionName(String(localized: "Delete Rows")) + + let isUndo = rows.contains { deletedRowIndices.contains($0.rowIndex) } + if isUndo { + for (rowIndex, _) in rows.reversed() { + undoRowDeletion(rowIndex: rowIndex) + changedRowIndices.insert(rowIndex) + } + } else { + for (rowIndex, originalRow) in rows { + redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + changedRowIndices.insert(rowIndex) + } + } + + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + + private func applyBatchInsertionUndo( + rowIndices: [Int], + rowValues: [[String?]] + ) { + // Register inverse + undoManager.registerUndo(withTarget: self) { target in + target.applyBatchInsertionUndo( + rowIndices: rowIndices, rowValues: rowValues ) + } + undoManager.setActionName(String(localized: "Insert Rows")) + + let firstInserted = rowIndices.first.map { insertedRowIndices.contains($0) } ?? false + if firstInserted { + // Undo: remove the inserted rows + for rowIndex in rowIndices { + removeChangeByKey(rowIndex: rowIndex, type: .insert) + insertedRowIndices.remove(rowIndex) + insertedRowData.removeValue(forKey: rowIndex) + changedRowIndices.insert(rowIndex) + } + + // Remove from rowBuffer (descending order to preserve indices) + if let rowBuffer { + for rowIndex in rowIndices.sorted(by: >) { + guard rowIndex < rowBuffer.rows.count else { continue } + rowBuffer.rows.remove(at: rowIndex) + } + } + } else { + // Redo: re-insert the rows + for rowIndex in rowIndices.sorted() { + shiftRowIndicesUp(from: rowIndex) + } + + 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() + + // Re-insert into rowBuffer + if let rowBuffer { + for (index, rowIndex) in rowIndices.enumerated().reversed() { + guard index < rowValues.count else { continue } + guard rowIndex <= rowBuffer.rows.count else { continue } + rowBuffer.rows.insert(rowValues[index], at: rowIndex) + } + } + } + + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + + /// 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 + } + + 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) + } + } 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 + } +} diff --git a/TablePro/Core/ChangeTracking/DataChangeModels.swift b/TablePro/Core/ChangeTracking/DataChangeModels.swift index 7ec13675c..3d0f5004e 100644 --- a/TablePro/Core/ChangeTracking/DataChangeModels.swift +++ b/TablePro/Core/ChangeTracking/DataChangeModels.swift @@ -68,25 +68,6 @@ struct RowChangeKey: Hashable { let type: ChangeType } -/// Represents an action that can be undone -enum UndoAction { - case cellEdit( - rowIndex: Int, - columnIndex: Int, - columnName: String, - previousValue: String?, - newValue: String? - ) - case rowInsertion(rowIndex: Int) - case rowDeletion(rowIndex: Int, originalRow: [String?]) - /// Batch deletion of multiple rows (for undo as a single action) - case batchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) - /// Batch insertion undo - when user deletes multiple inserted rows at once - case batchRowInsertion(rowIndices: [Int], rowValues: [[String?]]) -} - -// Note: TabPendingChanges is defined in QueryTab.swift - // MARK: - Array Extension extension Array { diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index fbd9d2ccf..173757ff2 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -38,7 +38,7 @@ final class StructureChangeManager { // MARK: - Undo/Redo Support - private let undoManager: UndoManager = { + let undoManager: UndoManager = { let manager = UndoManager() manager.levelsOfUndo = 100 return manager diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index dd7c936ef..30c21d9f1 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -365,7 +365,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi tableOperationOptions: sessionTableOperationOptionsBinding, rightPanelState: rightPanelState, tabManager: sessionState.tabManager, - changeManager: sessionState.changeManager, filterStateManager: sessionState.filterStateManager, toolbarState: sessionState.toolbarState, coordinator: sessionState.coordinator diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index b4b8277b2..76d422b7c 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -12,7 +12,6 @@ import Foundation enum SessionStateFactory { struct SessionState { let tabManager: QueryTabManager - let changeManager: DataChangeManager let filterStateManager: FilterStateManager let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState @@ -44,8 +43,6 @@ enum SessionStateFactory { payload: EditorTabPayload? ) -> SessionState { let tabMgr = QueryTabManager() - let changeMgr = DataChangeManager() - changeMgr.databaseType = connection.type let filterMgr = FilterStateManager() let colVisMgr = ColumnVisibilityManager() let toolbarSt = ConnectionToolbarState(connection: connection) @@ -138,7 +135,6 @@ enum SessionStateFactory { let coord = MainContentCoordinator( connection: connection, tabManager: tabMgr, - changeManager: changeMgr, filterStateManager: filterMgr, columnVisibilityManager: colVisMgr, toolbarState: toolbarSt @@ -146,7 +142,6 @@ enum SessionStateFactory { return SessionState( tabManager: tabMgr, - changeManager: changeMgr, filterStateManager: filterMgr, columnVisibilityManager: colVisMgr, toolbarState: toolbarSt, diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index e3c2a324c..0d0743911 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -2,8 +2,8 @@ // RowOperationsManager.swift // TablePro // -// Service responsible for row operations: add, delete, duplicate, undo/redo. -// Extracted from MainContentView for better separation of concerns. +// Service responsible for row operations: add, delete, duplicate. +// Undo/redo is handled entirely by DataChangeManager's UndoManager closures. // import AppKit @@ -30,16 +30,10 @@ final class RowOperationsManager { // MARK: - Add Row - /// Add a new row to a table tab - /// - Parameters: - /// - columns: Column names - /// - columnDefaults: Column default values - /// - resultRows: Current rows (will be mutated) - /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed func addNewRow( columns: [String], columnDefaults: [String: String?], - resultRows: inout [[String?]] + rowBuffer: RowBuffer ) -> (rowIndex: Int, values: [String?])? { var newRowValues: [String?] = [] for column in columns { @@ -50,8 +44,8 @@ final class RowOperationsManager { } } - let newRowIndex = resultRows.count - resultRows.append(newRowValues) + let newRowIndex = rowBuffer.rows.count + rowBuffer.rows.append(newRowValues) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newRowValues) @@ -60,20 +54,14 @@ final class RowOperationsManager { // MARK: - Duplicate Row - /// Duplicate a row with new primary key - /// - Parameters: - /// - sourceRowIndex: Index of row to duplicate - /// - columns: Column names - /// - resultRows: Current rows (will be mutated) - /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed func duplicateRow( sourceRowIndex: Int, columns: [String], - resultRows: inout [[String?]] + rowBuffer: RowBuffer ) -> (rowIndex: Int, values: [String?])? { - guard sourceRowIndex < resultRows.count else { return nil } + guard sourceRowIndex < rowBuffer.rows.count else { return nil } - var newValues = resultRows[sourceRowIndex] + var newValues = rowBuffer.rows[sourceRowIndex] for pkColumn in changeManager.primaryKeyColumns { if let pkIndex = columns.firstIndex(of: pkColumn) { @@ -81,8 +69,8 @@ final class RowOperationsManager { } } - let newRowIndex = resultRows.count - resultRows.append(newValues) + let newRowIndex = rowBuffer.rows.count + rowBuffer.rows.append(newValues) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newValues) @@ -91,14 +79,9 @@ final class RowOperationsManager { // MARK: - Delete Rows - /// Delete selected rows - /// - Parameters: - /// - selectedIndices: Indices of rows to delete - /// - resultRows: Current rows (will be mutated) - /// - Returns: Next row index to select after deletion, or -1 if no rows left func deleteSelectedRows( selectedIndices: Set, - resultRows: inout [[String?]] + rowBuffer: RowBuffer ) -> Int { guard !selectedIndices.isEmpty else { return -1 } @@ -112,33 +95,28 @@ final class RowOperationsManager { if changeManager.isRowInserted(rowIndex) { insertedRowsToDelete.append(rowIndex) } else if !changeManager.isRowDeleted(rowIndex) { - if rowIndex < resultRows.count { - existingRowsToDelete.append((rowIndex: rowIndex, originalRow: resultRows[rowIndex])) + if rowIndex < rowBuffer.rows.count { + existingRowsToDelete.append((rowIndex: rowIndex, originalRow: rowBuffer.rows[rowIndex])) } } } - // Process inserted rows deletion if !insertedRowsToDelete.isEmpty { let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) - // Remove from resultRows first (descending order) for rowIndex in sortedInsertedRows { - guard rowIndex < resultRows.count else { continue } - resultRows.remove(at: rowIndex) + guard rowIndex < rowBuffer.rows.count else { continue } + rowBuffer.rows.remove(at: rowIndex) } - // Update changeManager for ALL deleted inserted rows at once changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) } - // Record batch deletion for existing rows (single undo action for all rows) if !existingRowsToDelete.isEmpty { changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) } - // Calculate next row selection, accounting for deleted inserted rows - let totalRows = resultRows.count + let totalRows = rowBuffer.rows.count let rowsDeleted = insertedRowsToDelete.count let adjustedMaxRow = maxSelectedRow - rowsDeleted let adjustedMinRow = minSelectedRow - insertedRowsToDelete.count(where: { $0 < minSelectedRow }) @@ -154,93 +132,21 @@ final class RowOperationsManager { } } - // MARK: - Undo/Redo - - /// Undo the last change - /// - Parameter resultRows: Current rows (will be mutated) - /// - Returns: Updated selection indices - func undoLastChange(resultRows: inout [[String?]]) -> Set? { - guard let result = changeManager.undoLastChange() else { return nil } - return applyUndoResult(result, resultRows: &resultRows) - } - - /// Redo the last undone change - /// - Parameters: - /// - resultRows: Current rows (will be mutated) - /// - columns: Column names for new row creation - /// - 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 previousValue, _): - if rowIndex < resultRows.count { - resultRows[rowIndex][columnIndex] = previousValue - } - - case .rowInsertion(let 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: - break - - case .batchRowDeletion: - break - - 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) - } - } - } - - return nil - } - // MARK: - Undo Insert Row - /// Remove a row that was inserted (called by undo context menu) - /// - Parameters: - /// - rowIndex: Index of the inserted row - /// - resultRows: Current rows (will be mutated) - /// - selectedIndices: Current selection (will be adjusted) - /// - Returns: Adjusted selection indices func undoInsertRow( at rowIndex: Int, - resultRows: inout [[String?]], + rowBuffer: RowBuffer, selectedIndices: Set ) -> Set { - guard rowIndex >= 0 && rowIndex < resultRows.count else { return selectedIndices } + guard rowIndex >= 0 && rowIndex < rowBuffer.rows.count else { return selectedIndices } - // Remove the row from resultRows - resultRows.remove(at: rowIndex) + rowBuffer.rows.remove(at: rowIndex) - // Adjust selection indices var adjustedSelection = Set() for idx in selectedIndices { if idx == rowIndex { - continue // Skip the removed row + continue } else if idx > rowIndex { adjustedSelection.insert(idx - 1) } else { @@ -253,12 +159,6 @@ final class RowOperationsManager { // MARK: - Copy Rows - /// Copy selected rows to clipboard as tab-separated values - /// - Parameters: - /// - selectedIndices: Indices of rows to copy - /// - resultRows: Current rows - /// - columns: Column names (used when includeHeaders is true) - /// - includeHeaders: Whether to prepend column headers as the first TSV line func copySelectedRowsToClipboard( selectedIndices: Set, resultRows: [[String?]], @@ -309,44 +209,32 @@ final class RowOperationsManager { // MARK: - Paste Rows - /// Paste rows from clipboard (TSV format) and insert into table - /// - Parameters: - /// - columns: Column names for the table - /// - primaryKeyColumns: Primary key column names (will be set to __DEFAULT__) - /// - resultRows: Current rows (will be mutated) - /// - clipboard: Clipboard provider (injectable for testing) - /// - parser: Row data parser (injectable for testing) - /// - Returns: Array of (rowIndex, values) for pasted rows, or empty array on failure @MainActor func pasteRowsFromClipboard( columns: [String], primaryKeyColumns: [String], - resultRows: inout [[String?]], + rowBuffer: RowBuffer, clipboard: ClipboardProvider? = nil, parser: RowDataParser? = nil ) -> [(rowIndex: Int, values: [String?])] { - // Read from clipboard let clipboardProvider = clipboard ?? ClipboardService.shared guard let clipboardText = clipboardProvider.readText() else { return [] } - // Create schema let schema = TableSchema( columns: columns, primaryKeyColumns: primaryKeyColumns ) - // Parse clipboard text (auto-detect CSV vs TSV) let rowParser = parser ?? Self.detectParser(for: clipboardText) let parseResult = rowParser.parse(clipboardText, schema: schema) switch parseResult { case .success(let parsedRows): - return insertParsedRows(parsedRows, into: &resultRows) + return insertParsedRows(parsedRows, into: rowBuffer) case .failure(let error): - // Log error (in production, this could show a user-facing alert) Self.logger.warning("Paste failed: \(error.localizedDescription)") return [] } @@ -354,10 +242,7 @@ final class RowOperationsManager { // MARK: - Parser Detection - /// Auto-detect whether clipboard text is CSV or TSV - /// Heuristic: if tabs appear in most lines, use TSV; otherwise CSV static func detectParser(for text: String) -> RowDataParser { - // Single-pass scan: count non-empty lines containing tabs vs commas var tabLines = 0 var commaLines = 0 var nonEmptyLines = 0 @@ -381,7 +266,6 @@ final class RowOperationsManager { if char == "," { lineHasComma = true } } } - // Handle last line (no trailing newline) if !lineIsEmpty { nonEmptyLines += 1 if lineHasTab { tabLines += 1 } @@ -390,13 +274,9 @@ final class RowOperationsManager { guard nonEmptyLines > 0 else { return TSVRowParser() } - let tabCount = tabLines - let commaCount = commaLines - - // If majority of lines have tabs, use TSV; otherwise CSV - if tabCount > commaCount { + if tabLines > commaLines { return TSVRowParser() - } else if commaCount > 0 { + } else if commaLines > 0 { return CSVRowParser() } return TSVRowParser() @@ -404,22 +284,17 @@ final class RowOperationsManager { // MARK: - Private Helpers - /// Insert parsed rows into the table - /// - Parameters: - /// - parsedRows: Array of parsed rows from clipboard - /// - resultRows: Current rows (will be mutated) - /// - Returns: Array of (rowIndex, values) for inserted rows private func insertParsedRows( _ parsedRows: [ParsedRow], - into resultRows: inout [[String?]] + into rowBuffer: RowBuffer ) -> [(rowIndex: Int, values: [String?])] { var pastedRowInfo: [(Int, [String?])] = [] for parsedRow in parsedRows { let rowValues = parsedRow.values - resultRows.append(rowValues) - let newRowIndex = resultRows.count - 1 + rowBuffer.rows.append(rowValues) + let newRowIndex = rowBuffer.rows.count - 1 changeManager.recordRowInsertion(rowIndex: newRowIndex, values: rowValues) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 18988c058..51c7fa0f4 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -85,8 +85,8 @@ struct QueryTab: Identifiable, Equatable { var explainExecutionTime: TimeInterval? var explainPlan: QueryPlan? - // Per-tab change tracking (preserves changes when switching tabs) - var pendingChanges: TabPendingChanges + // Per-tab change tracking (owns its own DataChangeManager with undo history) + var changeManager: DataChangeManager // Per-tab row selection (preserves selection when switching tabs) var selectedRowIndices: Set @@ -172,7 +172,7 @@ struct QueryTab: Identifiable, Equatable { self.databaseName = "" self.schemaName = nil self.resultsViewMode = .data - self.pendingChanges = TabPendingChanges() + self.changeManager = DataChangeManager() self.selectedRowIndices = [] self.sortState = SortState() self.hasUserInteraction = false @@ -209,7 +209,7 @@ struct QueryTab: Identifiable, Equatable { self.schemaName = persisted.schemaName self.resultsViewMode = .data self.erDiagramSchemaKey = persisted.erDiagramSchemaKey - self.pendingChanges = TabPendingChanges() + self.changeManager = DataChangeManager() self.selectedRowIndices = [] self.sortState = SortState() self.hasUserInteraction = false diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index f0cd51b3f..ea400b47a 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -227,7 +227,7 @@ final class QueryTabManager { tab.resultsViewMode = .data tab.sortState = SortState() tab.selectedRowIndices = [] - tab.pendingChanges = TabPendingChanges() + tab.changeManager.clearChangesAndUndoHistory() tab.hasUserInteraction = false tab.isView = isView tab.isEditable = !isView diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 20bfee89f..10bc6de3b 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -29,31 +29,6 @@ struct PersistedTab: Codable { var erDiagramSchemaKey: String? } -/// Stores pending changes for a tab (used to preserve state when switching tabs) -struct TabPendingChanges: Equatable { - var changes: [RowChange] - var deletedRowIndices: Set - var insertedRowIndices: Set - var modifiedCells: [Int: Set] - var insertedRowData: [Int: [String?]] // Lazy storage for inserted row values - var primaryKeyColumns: [String] - var columns: [String] - - init() { - self.changes = [] - self.deletedRowIndices = [] - self.insertedRowIndices = [] - self.modifiedCells = [:] - self.insertedRowData = [:] - self.primaryKeyColumns = [] - self.columns = [] - } - - var hasChanges: Bool { - !changes.isEmpty || !insertedRowIndices.isEmpty || !deletedRowIndices.isEmpty - } -} - /// Sort direction for column sorting enum SortDirection: Equatable { case ascending diff --git a/TablePro/Models/Query/RowProvider.swift b/TablePro/Models/Query/RowProvider.swift index 874e82807..4a8aeda3d 100644 --- a/TablePro/Models/Query/RowProvider.swift +++ b/TablePro/Models/Query/RowProvider.swift @@ -161,6 +161,15 @@ final class InMemoryRowProvider: RowProvider { displayCache.removeAll() } + func invalidateDisplayCache(for rowIndex: Int) { + let sourceIndex = resolveSourceIndex(rowIndex) + if let bufferIdx = sourceIndex.bufferIndex { + displayCache.removeValue(forKey: bufferIdx) + } else if let appendedIdx = sourceIndex.appendedIndex { + displayCache.removeValue(forKey: bufferRowCount + appendedIdx) + } + } + /// Update a cell value func updateValue(_ value: String?, at rowIndex: Int, columnIndex: Int) { guard rowIndex < totalRowCount else { return } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index aef1975ea..69e87e7d4 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -32553,6 +32553,9 @@ } } } + }, + "Redo %@" : { + }, "Ref Columns" : { "localizations" : { @@ -42737,6 +42740,9 @@ } } } + }, + "Undo %@" : { + }, "Undo Delete" : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index b5d18fdf0..218e29016 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -5,7 +5,6 @@ // Created by Ngo Quoc Dat on 16/12/25. // -import CodeEditTextView import Observation import os import Sparkle @@ -390,32 +389,15 @@ struct AppMenuCommands: Commands { .disabled(!(actions?.isConnected ?? false)) } - // Edit menu - Undo/Redo (smart handling for both text editor and data grid) + // Edit menu - Undo/Redo (routes through responder chain for both editor and data grid) CommandGroup(replacing: .undoRedo) { - Button("Undo") { - // Check if first responder is a text view (SQL editor) - if let firstResponder = NSApp.keyWindow?.firstResponder, - firstResponder is NSTextView || firstResponder is TextView { - // Send undo: (with colon) through responder chain — - // CodeEditTextView.TextView responds to undo: via @objc func undo(_:) - NSApp.sendAction(#selector(TableProResponderActions.undo(_:)), to: nil, from: nil) - } else { - // Data grid undo - actions?.undoChange() - } + Button(actions?.undoActionName ?? String(localized: "Undo")) { + NSApp.sendAction(Selector("undo:"), to: nil, from: nil) } .optionalKeyboardShortcut(shortcut(for: .undo)) - Button("Redo") { - // Check if first responder is a text view (SQL editor) - if let firstResponder = NSApp.keyWindow?.firstResponder, - firstResponder is NSTextView || firstResponder is TextView { - // Send redo: (with colon) through responder chain - NSApp.sendAction(#selector(TableProResponderActions.redo(_:)), to: nil, from: nil) - } else { - // Data grid redo - actions?.redoChange() - } + Button(actions?.redoActionName ?? String(localized: "Redo")) { + NSApp.sendAction(Selector("redo:"), to: nil, from: nil) } .optionalKeyboardShortcut(shortcut(for: .redo)) } diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index bba44f74d..f3cf03c8e 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -70,17 +70,6 @@ final class DataTabGridDelegate: DataGridViewDelegate { NotificationCenter.default.post(name: .pasteRows, object: nil) } - func dataGridUndo() { - guard let selectedRowIndices else { return } - var indices = selectedRowIndices.wrappedValue - coordinator?.undoLastChange(selectedRowIndices: &indices) - selectedRowIndices.wrappedValue = indices - } - - func dataGridRedo() { - coordinator?.redoLastChange() - } - func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) { coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index 70c875992..748f31a47 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -95,10 +95,6 @@ extension MainContentCoordinator { pendingDeletes.removeAll() changeManager.clearChangesAndUndoHistory() - if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].pendingChanges = TabPendingChanges() - } - Task { await refreshTables() } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 63fedfe96..48851eb77 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -303,6 +303,7 @@ extension MainContentCoordinator { primaryKeyColumns: resolvedPKs, databaseType: conn.type ) + changeManager.rowBuffer = tabManager.tabs[idx].rowBuffer } QueryHistoryManager.shared.recordQuery( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index d498fa129..32e5cfeec 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -21,7 +21,7 @@ extension MainContentCoordinator { guard let result = rowOperationsManager.addNewRow( columns: tab.resultColumns, columnDefaults: tab.columnDefaults, - resultRows: &tabManager.tabs[tabIndex].resultRows + rowBuffer: tabManager.tabs[tabIndex].rowBuffer ) else { return } selectedRowIndices = [result.rowIndex] @@ -39,7 +39,7 @@ extension MainContentCoordinator { let nextRow = rowOperationsManager.deleteSelectedRows( selectedIndices: indices, - resultRows: &tabManager.tabs[tabIndex].resultRows + rowBuffer: tabManager.tabs[tabIndex].rowBuffer ) if nextRow >= 0 && nextRow < tabManager.tabs[tabIndex].resultRows.count { @@ -64,7 +64,7 @@ extension MainContentCoordinator { guard let result = rowOperationsManager.duplicateRow( sourceRowIndex: index, columns: tab.resultColumns, - resultRows: &tabManager.tabs[tabIndex].resultRows + rowBuffer: tabManager.tabs[tabIndex].rowBuffer ) else { return } selectedRowIndices = [result.rowIndex] @@ -79,40 +79,12 @@ extension MainContentCoordinator { selectedRowIndices = rowOperationsManager.undoInsertRow( at: rowIndex, - resultRows: &tabManager.tabs[tabIndex].resultRows, + rowBuffer: tabManager.tabs[tabIndex].rowBuffer, selectedIndices: selectedRowIndices ) tabManager.tabs[tabIndex].resultVersion += 1 } - func undoLastChange(selectedRowIndices: inout Set) { - guard let tabIndex = tabManager.selectedTabIndex, - tabIndex < tabManager.tabs.count else { return } - - if let adjustedSelection = rowOperationsManager.undoLastChange( - resultRows: &tabManager.tabs[tabIndex].resultRows - ) { - selectedRowIndices = adjustedSelection - } - - tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 - } - - func redoLastChange() { - guard let tabIndex = tabManager.selectedTabIndex, - tabIndex < tabManager.tabs.count else { return } - - let tab = tabManager.tabs[tabIndex] - _ = rowOperationsManager.redoLastChange( - resultRows: &tabManager.tabs[tabIndex].resultRows, - columns: tab.resultColumns - ) - - tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 - } - func copySelectedRowsToClipboard(indices: Set) { guard let index = tabManager.selectedTabIndex, !indices.isEmpty else { return } @@ -157,33 +129,24 @@ extension MainContentCoordinator { guard !safeModeLevel.blocksAllWrites, let index = tabManager.selectedTabIndex else { return } - var tab = tabManager.tabs[index] + let tab = tabManager.tabs[index] - // Only paste in table tabs (not query tabs) guard tab.tabType == .table else { return } let pastedRows = rowOperationsManager.pasteRowsFromClipboard( columns: tab.resultColumns, primaryKeyColumns: changeManager.primaryKeyColumns, - resultRows: &tab.resultRows + rowBuffer: tabManager.tabs[index].rowBuffer ) - tabManager.tabs[index].resultRows = tab.resultRows tabManager.tabs[index].resultVersion += 1 - // Select pasted rows and scroll to first one if !pastedRows.isEmpty { let newIndices = Set(pastedRows.map { $0.rowIndex }) selectedRowIndices = newIndices tabManager.tabs[index].selectedRowIndices = newIndices tabManager.tabs[index].hasUserInteraction = true - - // Scroll to first pasted row - if pastedRows.first?.rowIndex != nil { - // Trigger scroll via notification if needed - // For now, selection change will handle visibility - } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 12ecfba8e..d71cd4349 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -232,7 +232,6 @@ extension MainContentCoordinator { changeManager.clearChangesAndUndoHistory() if let index = tabManager.selectedTabIndex { - tabManager.tabs[index].pendingChanges = TabPendingChanges() tabManager.tabs[index].errorMessage = nil } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index b5e071122..796ff6ff8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -33,9 +33,6 @@ extension MainContentCoordinator { if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) { - if changeManager.hasChanges { - tabManager.tabs[oldIndex].pendingChanges = changeManager.saveState() - } tabManager.tabs[oldIndex].filterState = filterStateManager.saveToTabState() if let tableName = tabManager.tabs[oldIndex].tableName { filterStateManager.saveLastFilters(for: tableName) @@ -69,22 +66,8 @@ extension MainContentCoordinator { toolbarState.isTableTab = newTab.tabType == .table toolbarState.isResultsCollapsed = newTab.isResultsCollapsed - // Configure change manager without triggering reload yet — we'll fire a single - // reloadVersion bump below after everything is set up. - let pendingState = newTab.pendingChanges - if pendingState.hasChanges { - changeManager.restoreState(from: pendingState, tableName: newTab.tableName ?? "", databaseType: connection.type) - } else { - changeManager.configureForTable( - tableName: newTab.tableName ?? "", - columns: newTab.resultColumns, - primaryKeyColumns: newTab.primaryKeyColumns.isEmpty - ? newTab.resultColumns.prefix(1).map { $0 } - : newTab.primaryKeyColumns, - databaseType: connection.type, - triggerReload: false - ) - } + // Per-tab change managers: the new tab's changeManager is automatically + // used via the computed property. No save/restore needed. let restoreMs = Int(Date().timeIntervalSince(restoreStart) * 1_000) Self.lifecycleLogger.debug( @@ -161,7 +144,7 @@ extension MainContentCoordinator { && !$0.rowBuffer.isEvicted && !$0.resultRows.isEmpty && $0.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + && !$0.changeManager.hasChanges } // Sort by oldest first, breaking ties by largest estimated footprint first diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 40d00cdb2..1f4a25049 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -33,9 +33,7 @@ extension MainContentCoordinator { // Lazy-load: execute query for restored tabs that skipped auto-execute, // or re-query tabs whose row data was evicted while inactive. // Skip if the user has unsaved changes (in-memory or tab-level). - let hasPendingEdits = - changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) + let hasPendingEdits = changeManager.hasChanges let isConnected = DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false let needsLazyLoad = diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index df6560269..d13dbeecb 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -104,6 +104,7 @@ extension MainContentView { primaryKeyColumns: tab.primaryKeyColumns, databaseType: connection.type ) + changeManager.rowBuffer = tab.rowBuffer } func handleTableSelectionChange( diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 1504862d6..ffc09b3c1 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -22,9 +22,7 @@ extension MainContentView { let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } if session.isConnected && coordinator.needsLazyLoad { - let hasPendingEdits = - changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) + let hasPendingEdits = changeManager.hasChanges guard !hasPendingEdits else { return } coordinator.needsLazyLoad = false if let selectedTab = tabManager.selectedTab, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift index 6dc477e02..891fda484 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift @@ -61,7 +61,6 @@ struct FocusedCommandActionsModifier: ViewModifier { tableOperationOptions: .constant([:]), rightPanelState: RightPanelState(), tabManager: state.tabManager, - changeManager: state.changeManager, filterStateManager: state.filterStateManager, toolbarState: state.toolbarState, coordinator: state.coordinator diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index f56e365d1..bd3833e5c 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -730,22 +730,20 @@ final class MainContentCommandActions { // MARK: - Undo/Redo (Group A — Called Directly) - func undoChange() { - if coordinator?.tabManager.selectedTab?.resultsViewMode == .structure { - coordinator?.structureActions?.undo?() - } else { - var indices = selectedRowIndices.wrappedValue - coordinator?.undoLastChange(selectedRowIndices: &indices) - selectedRowIndices.wrappedValue = indices + var undoActionName: String { + let um = coordinator?.changeManager.undoManager + if let name = um?.undoActionName, !name.isEmpty { + return String(format: String(localized: "Undo %@"), name) } + return String(localized: "Undo") } - func redoChange() { - if coordinator?.tabManager.selectedTab?.resultsViewMode == .structure { - coordinator?.structureActions?.redo?() - } else { - coordinator?.redoLastChange() + var redoActionName: String { + let um = coordinator?.changeManager.undoManager + if let name = um?.redoActionName, !name.isEmpty { + return String(format: String(localized: "Redo %@"), name) } + return String(localized: "Redo") } // MARK: - Group B Broadcast Subscribers diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index ffaaf329f..f58cd5baf 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -84,7 +84,10 @@ final class MainContentCoordinator { /// not from the immutable connection snapshot. var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } let tabManager: QueryTabManager - let changeManager: DataChangeManager + var changeManager: DataChangeManager { + tabManager.selectedTab?.changeManager ?? fallbackChangeManager + } + private let fallbackChangeManager = DataChangeManager() let filterStateManager: FilterStateManager let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState @@ -93,9 +96,9 @@ final class MainContentCoordinator { internal var queryBuilder: TableQueryBuilder let persistence: TabPersistenceCoordinator - @ObservationIgnored internal lazy var rowOperationsManager: RowOperationsManager = { + @ObservationIgnored internal var rowOperationsManager: RowOperationsManager { RowOperationsManager(changeManager: changeManager) - }() + } /// Stable identifier for this coordinator's window (set by MainContentView on appear) var windowId: UUID? @@ -253,8 +256,7 @@ final class MainContentCoordinator { /// Check whether any active coordinator has unsaved edits. static func hasAnyUnsavedChanges() -> Bool { activeCoordinators.values.contains { coordinator in - coordinator.changeManager.hasChanges - || coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges } + coordinator.tabManager.tabs.contains { $0.changeManager.hasChanges } } } @@ -335,7 +337,7 @@ final class MainContentCoordinator { let selectedId = tabManager.selectedTabId for tab in tabManager.tabs where !tab.rowBuffer.isEvicted && !tab.resultRows.isEmpty - && !tab.pendingChanges.hasChanges + && !tab.changeManager.hasChanges && tab.id != selectedId { tab.rowBuffer.evict() @@ -358,7 +360,6 @@ final class MainContentCoordinator { init( connection: DatabaseConnection, tabManager: QueryTabManager, - changeManager: DataChangeManager, filterStateManager: FilterStateManager, columnVisibilityManager: ColumnVisibilityManager, toolbarState: ConnectionToolbarState @@ -366,7 +367,6 @@ final class MainContentCoordinator { let initStart = Date() self.connection = connection self.tabManager = tabManager - self.changeManager = changeManager self.filterStateManager = filterStateManager self.columnVisibilityManager = columnVisibilityManager self.toolbarState = toolbarState @@ -421,7 +421,7 @@ final class MainContentCoordinator { setupPluginDriver() startFileWatcherIfNeeded() // Retry when driver becomes available (connection may still be in progress) - if changeManager.pluginDriver == nil { + if DatabaseManager.shared.driver(for: connectionId)?.queryBuildingPluginDriver == nil { pluginDriverObserver = NotificationCenter.default.addObserver( forName: .databaseDidConnect, object: nil, queue: .main ) { [weak self] _ in @@ -477,7 +477,9 @@ final class MainContentCoordinator { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } let pluginDriver = driver.queryBuildingPluginDriver queryBuilder.setPluginDriver(pluginDriver) - changeManager.pluginDriver = pluginDriver + for tab in tabManager.tabs { + tab.changeManager.pluginDriver = pluginDriver + } // Remove observer once successfully set up if pluginDriver != nil, let observer = pluginDriverObserver { NotificationCenter.default.removeObserver(observer) @@ -594,8 +596,12 @@ final class MainContentCoordinator { // Release change manager state — pluginDriver holds a strong reference // to the entire database driver which prevents deallocation - changeManager.clearChanges() - changeManager.pluginDriver = nil + for tab in tabManager.tabs { + tab.changeManager.clearChanges() + tab.changeManager.pluginDriver = nil + } + fallbackChangeManager.clearChanges() + fallbackChangeManager.pluginDriver = nil // Release metadata and filter state tableMetadata = nil diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 7886916c8..9ede1fe17 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -40,7 +40,7 @@ struct MainContentView: View { // MARK: - State Objects let tabManager: QueryTabManager - let changeManager: DataChangeManager + var changeManager: DataChangeManager { coordinator.changeManager } let filterStateManager: FilterStateManager let toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator @@ -75,7 +75,6 @@ struct MainContentView: View { tableOperationOptions: Binding<[String: TableOperationOptions]>, rightPanelState: RightPanelState, tabManager: QueryTabManager, - changeManager: DataChangeManager, filterStateManager: FilterStateManager, toolbarState: ConnectionToolbarState, coordinator: MainContentCoordinator @@ -90,7 +89,6 @@ struct MainContentView: View { self._tableOperationOptions = tableOperationOptions self.rightPanelState = rightPanelState self.tabManager = tabManager - self.changeManager = changeManager self.filterStateManager = filterStateManager self.toolbarState = toolbarState self.coordinator = coordinator diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 8e5028a38..245fc352a 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -31,14 +31,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? - /// Check if undo is available - func canUndo() -> Bool { - changeManager.hasChanges - } - - /// Check if redo is available - func canRedo() -> Bool { - changeManager.canRedo + var tabUndoManager: UndoManager? { + changeManager.undoManager } /// Capture current column widths and order from the live NSTableView diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 2fd0ffb2a..bce13b2ce 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -264,6 +264,7 @@ struct DataGridView: NSViewRepresentable { } let versionChanged = coordinator.lastReloadVersion != changeManager.reloadVersion + var pendingChangedRows: Set? let metadataChanged = previousIdentity.map { $0.metadataVersion != metadataVersion } ?? false let oldRowCount = coordinator.cachedRowCount let oldColumnCount = coordinator.cachedColumnCount @@ -281,6 +282,12 @@ struct DataGridView: NSViewRepresentable { // Re-apply pending cell edits only when changes have been modified if changeManager.reloadVersion != coordinator.lastReapplyVersion { coordinator.lastReapplyVersion = changeManager.reloadVersion + + pendingChangedRows = changeManager.consumeChangedRowIndices() + for rowIndex in pendingChangedRows! { + coordinator.rowProvider.invalidateDisplayCache(for: rowIndex) + } + for change in changeManager.changes { guard let rowChange = change as? RowChange else { continue } for cellChange in rowChange.cellChanges { @@ -352,7 +359,8 @@ struct DataGridView: NSViewRepresentable { needsFullReload: needsFullReload, versionChanged: versionChanged, metadataChanged: metadataChanged, - paginationChanged: paginationChanged + paginationChanged: paginationChanged, + pendingChangedRows: pendingChangedRows ) } @@ -539,7 +547,8 @@ struct DataGridView: NSViewRepresentable { needsFullReload: Bool, versionChanged: Bool, metadataChanged: Bool = false, - paginationChanged: Bool = false + paginationChanged: Bool = false, + pendingChangedRows: Set? = nil ) { if needsFullReload { tableView.reloadData() @@ -566,7 +575,7 @@ struct DataGridView: NSViewRepresentable { } } else if versionChanged { // Granular reload: only reload rows that changed - let changedRows = changeManager.consumeChangedRowIndices() + let changedRows = pendingChangedRows ?? changeManager.consumeChangedRowIndices() if changedRows.count > 500 { // Too many changed rows — full reload is faster than granular tableView.reloadData() diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index bc19fa9b9..4880f2037 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -13,8 +13,6 @@ protocol DataGridViewDelegate: AnyObject { func dataGridDeleteRows(_ indices: Set) func dataGridCopyRows(_ indices: Set) func dataGridPasteRows() - func dataGridUndo() - func dataGridRedo() func dataGridAddRow() func dataGridUndoInsert(at index: Int) func dataGridMoveRow(from source: Int, to destination: Int) @@ -34,8 +32,6 @@ extension DataGridViewDelegate { func dataGridDeleteRows(_ indices: Set) {} func dataGridCopyRows(_ indices: Set) {} func dataGridPasteRows() {} - func dataGridUndo() {} - func dataGridRedo() {} func dataGridAddRow() {} func dataGridUndoInsert(at index: Int) {} func dataGridMoveRow(from source: Int, to destination: Int) {} diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 2054b5e2d..05ca05f48 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -23,6 +23,10 @@ final class KeyHandlingTableView: NSTableView { true } + override var undoManager: UndoManager? { + coordinator?.tabUndoManager + } + /// Currently focused row index (-1 = no focus) var focusedRow: Int = -1 { didSet { @@ -138,16 +142,12 @@ final class KeyHandlingTableView: NSTableView { coordinator?.delegate?.dataGridPasteRows() } - /// Undo last change @objc func undo(_ sender: Any?) { - guard coordinator?.isEditable == true else { return } - coordinator?.delegate?.dataGridUndo() + undoManager?.undo() } - /// Redo last undone change @objc func redo(_ sender: Any?) { - guard coordinator?.isEditable == true else { return } - coordinator?.delegate?.dataGridRedo() + undoManager?.redo() } /// Validate menu items and shortcuts @@ -160,9 +160,9 @@ final class KeyHandlingTableView: NSTableView { case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil case #selector(undo(_:)): - return coordinator?.isEditable == true && (coordinator?.canUndo() ?? false) + return coordinator?.isEditable == true && (undoManager?.canUndo ?? false) case #selector(redo(_:)): - return coordinator?.isEditable == true && (coordinator?.canRedo() ?? false) + return coordinator?.isEditable == true && (undoManager?.canRedo ?? false) case #selector(insertNewline(_:)): return selectedRow >= 0 && focusedColumn >= 1 && coordinator?.isEditable == true case #selector(cancelOperation(_:)): diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index 594573387..3271a56f7 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -71,7 +71,7 @@ struct DataChangeManagerExtendedTests { func recordRowInsertionEnablesUndo() { let manager = makeManager() manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - #expect(manager.canUndo) + #expect(manager.undoManager.canUndo) } @Test("Record row insertion clears redo stack") @@ -81,10 +81,10 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) - _ = manager.undoLastChange() - #expect(manager.canRedo) + _ = manager.undo() + #expect(manager.undoManager.canRedo) manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - #expect(!manager.canRedo) + #expect(!manager.undoManager.canRedo) } @Test("Multiple row insertions tracked separately") @@ -331,10 +331,10 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - _ = manager1.undoLastChange() - #expect(manager1.canRedo) + _ = manager1.undo() + #expect(manager1.undoManager.canRedo) manager1.discardChanges() - #expect(manager1.canRedo) + #expect(manager1.undoManager.canRedo) // clearChanges clears undo/redo let manager2 = makeManager() @@ -342,11 +342,11 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - _ = manager2.undoLastChange() - #expect(manager2.canRedo) + _ = manager2.undo() + #expect(manager2.undoManager.canRedo) manager2.clearChanges() - #expect(!manager2.canUndo) - #expect(!manager2.canRedo) + #expect(!manager2.undoManager.canUndo) + #expect(!manager2.undoManager.canRedo) } @Test("discardChanges increments reloadVersion by 1") @@ -391,8 +391,8 @@ struct DataChangeManagerExtendedTests { rowIndex: 1, columnIndex: 1, columnName: "name", oldValue: "Charlie", newValue: "Dave" ) - _ = manager.undoLastChange() - _ = manager.undoLastChange() + _ = manager.undo() + _ = manager.undo() #expect(manager.changes.isEmpty) #expect(!manager.hasChanges) } @@ -404,9 +404,9 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) - _ = manager.undoLastChange() + _ = manager.undo() #expect(manager.changes.isEmpty) - _ = manager.redoLastChange() + _ = manager.redo() #expect(manager.changes.count == 1) #expect(manager.changes[0].cellChanges[0].newValue == "B") } @@ -415,7 +415,7 @@ struct DataChangeManagerExtendedTests { func undoRowInsertionRemovesFromIndices() { let manager = makeManager() manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - _ = manager.undoLastChange() + _ = manager.undo() #expect(!manager.isRowInserted(5)) } @@ -423,7 +423,7 @@ struct DataChangeManagerExtendedTests { func undoRowDeletionRemovesFromIndices() { let manager = makeManager() manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) - _ = manager.undoLastChange() + _ = manager.undo() #expect(!manager.isRowDeleted(2)) } @@ -431,9 +431,9 @@ struct DataChangeManagerExtendedTests { func undoRowInsertionThenRedoReInserts() { let manager = makeManager() manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - _ = manager.undoLastChange() + _ = manager.undo() #expect(!manager.isRowInserted(5)) - _ = manager.redoLastChange() + _ = manager.redo() #expect(manager.isRowInserted(5)) } @@ -441,9 +441,9 @@ struct DataChangeManagerExtendedTests { func undoRowDeletionThenRedoReDeletes() { let manager = makeManager() manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) - _ = manager.undoLastChange() + _ = manager.undo() #expect(!manager.isRowDeleted(2)) - _ = manager.redoLastChange() + _ = manager.redo() #expect(manager.isRowDeleted(2)) } @@ -460,16 +460,16 @@ struct DataChangeManagerExtendedTests { ) #expect(manager.changes.count == 2) - _ = manager.undoLastChange() + _ = manager.undo() #expect(manager.changes.count == 1) - _ = manager.undoLastChange() + _ = manager.undo() #expect(manager.changes.count == 0) - _ = manager.redoLastChange() + _ = manager.redo() #expect(manager.changes.count == 1) - _ = manager.redoLastChange() + _ = manager.redo() #expect(manager.changes.count == 2) } @@ -480,7 +480,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - let result = manager.undoLastChange() + let result = manager.undo() #expect(result != nil) #expect(result?.needsRowRemoval == false) #expect(result?.needsRowRestore == false) @@ -490,7 +490,7 @@ struct DataChangeManagerExtendedTests { func undoReturnsRowInsertionActionDetails() { let manager = makeManager() manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - let result = manager.undoLastChange() + let result = manager.undo() #expect(result != nil) #expect(result?.needsRowRemoval == true) } @@ -499,7 +499,7 @@ struct DataChangeManagerExtendedTests { func undoReturnsRowDeletionActionDetails() { let manager = makeManager() manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice"]) - let result = manager.undoLastChange() + let result = manager.undo() #expect(result != nil) #expect(result?.needsRowRestore == true) #expect(result?.restoreRow == ["1", "Alice"]) @@ -508,14 +508,14 @@ struct DataChangeManagerExtendedTests { @Test("Undo returns nil when undo stack is empty") func undoReturnsNilWhenStackEmpty() { let manager = makeManager() - let result = manager.undoLastChange() + let result = manager.undo() #expect(result == nil) } @Test("Redo returns nil when redo stack is empty") func redoReturnsNilWhenStackEmpty() { let manager = makeManager() - let result = manager.redoLastChange() + let result = manager.redo() #expect(result == nil) } @@ -556,7 +556,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: nil, newValue: "hello" ) - _ = manager.undoLastChange() + _ = manager.undo() let state = manager.saveState() #expect(state.insertedRowData[0]?[1] == nil) } @@ -629,7 +629,7 @@ struct DataChangeManagerExtendedTests { (rowIndex: 1, originalRow: ["2", "Bob", "b@test.com"]), (rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) ]) - _ = manager.undoLastChange() + _ = manager.undo() #expect(!manager.isRowDeleted(0)) #expect(!manager.isRowDeleted(1)) #expect(!manager.isRowDeleted(2)) @@ -765,7 +765,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" ) - _ = manager.undoLastChange() + _ = manager.undo() #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 2)) #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) } @@ -777,8 +777,8 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - _ = manager.undoLastChange() - _ = manager.redoLastChange() + _ = manager.undo() + _ = manager.redo() #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) #expect(!manager.changes.isEmpty) } diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift index afdc8c7ed..1f55218a1 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift @@ -65,8 +65,8 @@ struct DataChangeManagerTests { #expect(!manager.hasChanges) #expect(manager.changes.isEmpty) - #expect(!manager.canUndo) - #expect(!manager.canRedo) + #expect(!manager.undoManager.canUndo) + #expect(!manager.undoManager.canRedo) } // MARK: - Cell Change Recording Tests @@ -408,8 +408,8 @@ struct DataChangeManagerTests { manager.clearChanges() #expect(manager.changes.isEmpty) - #expect(!manager.canUndo) - #expect(!manager.canRedo) + #expect(!manager.undoManager.canUndo) + #expect(!manager.undoManager.canRedo) } @Test("clearChanges makes hasChanges false") @@ -454,7 +454,7 @@ struct DataChangeManagerTests { newValue: "Bob" ) - #expect(manager.canUndo) + #expect(manager.undoManager.canUndo) } @Test("After undo, the change is reversed") @@ -475,7 +475,7 @@ struct DataChangeManagerTests { ) #expect(manager.changes.count == 1) - _ = manager.undoLastChange() + _ = manager.undo() #expect(manager.changes.isEmpty) #expect(!manager.hasChanges) @@ -498,9 +498,9 @@ struct DataChangeManagerTests { newValue: "Bob" ) - _ = manager.undoLastChange() + _ = manager.undo() - #expect(manager.canRedo) + #expect(manager.undoManager.canRedo) } @Test("New change clears redo stack") @@ -520,8 +520,8 @@ struct DataChangeManagerTests { newValue: "Bob" ) - _ = manager.undoLastChange() - #expect(manager.canRedo) + _ = manager.undo() + #expect(manager.undoManager.canRedo) manager.recordCellChange( rowIndex: 1, @@ -531,15 +531,15 @@ struct DataChangeManagerTests { newValue: "Dave" ) - #expect(!manager.canRedo) + #expect(!manager.undoManager.canRedo) } @Test("Initial state has canUndo false and canRedo false") func initialUndoRedoState() async { let manager = DataChangeManager() - #expect(!manager.canUndo) - #expect(!manager.canRedo) + #expect(!manager.undoManager.canUndo) + #expect(!manager.undoManager.canRedo) } // MARK: - Reload Version Tests diff --git a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift index ffda0bca1..aea589169 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeModelsTests.swift @@ -116,54 +116,7 @@ struct DataChangeModelsTests { #expect(rowChange.type == .insert) } - @Test("TabPendingChanges initializes as empty") - func tabPendingChangesInit() { - let pending = TabPendingChanges() - - #expect(pending.changes.isEmpty) - #expect(pending.deletedRowIndices.isEmpty) - #expect(pending.insertedRowIndices.isEmpty) - #expect(pending.modifiedCells.isEmpty) - #expect(pending.insertedRowData.isEmpty) - #expect(pending.primaryKeyColumns.isEmpty) - #expect(pending.columns.isEmpty) - } - - @Test("TabPendingChanges hasChanges is false when empty") - func tabPendingChangesHasChangesEmpty() { - let pending = TabPendingChanges() - - #expect(!pending.hasChanges) - } - - @Test("TabPendingChanges hasChanges is true with changes") - func tabPendingChangesHasChangesWithChanges() { - let rowChange = RowChange( - rowIndex: 0, - type: .update - ) - var pending = TabPendingChanges() - pending.changes = [rowChange] - - #expect(pending.hasChanges) - } - - @Test("TabPendingChanges hasChanges is true with deletedRowIndices") - func tabPendingChangesHasChangesWithDeleted() { - var pending = TabPendingChanges() - pending.deletedRowIndices = [1, 2, 3] - - #expect(pending.hasChanges) - } - - @Test("TabPendingChanges hasChanges is true with insertedRowIndices") - func tabPendingChangesHasChangesWithInserted() { - var pending = TabPendingChanges() - pending.insertedRowIndices = [0, 1] - - #expect(pending.hasChanges) - } @Test("Array safe subscript with valid index") func arraySafeSubscriptValid() { diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index 6fd041573..c9b815b3b 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -179,14 +179,12 @@ struct CoordinatorConnectionIsolationTests { let connId = UUID() let connection = TestFixtures.makeConnection(id: connId, name: "MySQL", database: "db_a", type: .mysql) let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState @@ -206,7 +204,6 @@ struct CoordinatorConnectionIsolationTests { let coordinator1 = MainContentCoordinator( connection: conn1, tabManager: QueryTabManager(), - changeManager: DataChangeManager(), filterStateManager: FilterStateManager(), columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() @@ -216,7 +213,6 @@ struct CoordinatorConnectionIsolationTests { let coordinator2 = MainContentCoordinator( connection: conn2, tabManager: QueryTabManager(), - changeManager: DataChangeManager(), filterStateManager: FilterStateManager(), columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() @@ -236,7 +232,6 @@ struct CoordinatorConnectionIsolationTests { let coordinator1 = MainContentCoordinator( connection: conn1, tabManager: QueryTabManager(), - changeManager: DataChangeManager(), filterStateManager: FilterStateManager(), columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() @@ -246,7 +241,6 @@ struct CoordinatorConnectionIsolationTests { let coordinator2 = MainContentCoordinator( connection: conn2, tabManager: QueryTabManager(), - changeManager: DataChangeManager(), filterStateManager: FilterStateManager(), columnVisibilityManager: ColumnVisibilityManager(), toolbarState: ConnectionToolbarState() @@ -264,14 +258,12 @@ struct CoordinatorConnectionIsolationTests { let connId = UUID() let connection = TestFixtures.makeConnection(id: connId, name: "MySQL", database: "db_a", type: .mysql) let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index 7ed6f327a..6bdd45c8d 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -19,14 +19,12 @@ struct CoordinatorEditorLoadTests { private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { let connection = TestFixtures.makeConnection(database: "testdb") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift index f92afe6e5..e317dd390 100644 --- a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift +++ b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift @@ -18,14 +18,12 @@ struct CoordinatorRefreshTablesTests { func setsErrorWhenNoDriver() async { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState @@ -44,14 +42,12 @@ struct CoordinatorRefreshTablesTests { func defaultsToIdle() { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift b/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift index 1ae5d99d0..1492ec829 100644 --- a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift +++ b/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift @@ -24,14 +24,12 @@ struct CoordinatorSidebarActionsTests { var connection = TestFixtures.makeConnection(type: type) connection.safeModeLevel = safeModeLevel let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 7be25db17..a9e2bd2ef 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -14,14 +14,12 @@ import Testing struct EvictionTests { private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let connection = TestFixtures.makeConnection() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState @@ -58,7 +56,7 @@ struct EvictionTests { addLoadedTab(to: tabManager, tableName: "users") // Add a pending change - tabManager.tabs[0].pendingChanges.deletedRowIndices = [0] + tabManager.tabs[0].changeManager.recordRowDeletion(rowIndex: 0, originalRow: ["value_0"]) coordinator.evictInactiveRowData() diff --git a/TableProTests/Views/Main/ExtractTableNameTests.swift b/TableProTests/Views/Main/ExtractTableNameTests.swift index ec2766f7c..6553b2f59 100644 --- a/TableProTests/Views/Main/ExtractTableNameTests.swift +++ b/TableProTests/Views/Main/ExtractTableNameTests.swift @@ -17,14 +17,12 @@ struct ExtractTableNameTests { private func makeCoordinator() -> MainContentCoordinator { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() return MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift index 1933b590b..7ac4410f1 100644 --- a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift +++ b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift @@ -25,13 +25,11 @@ struct MultiConnectionNavigationTests { ) -> (coordinator: MainContentCoordinator, tabManager: QueryTabManager) { let connection = TestFixtures.makeConnection(id: id, name: name, database: database, type: type) let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index 31e37aaad..dda0ade25 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -23,14 +23,12 @@ struct OpenTableTabTests { func addsTabDirectlyWhenTabsEmptyNotSwitching() { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index 7fe9e8ac4..8efc1444c 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -18,18 +18,18 @@ struct SaveCompletionTests { private func makeCoordinator( safeModeLevel: SafeModeLevel = .silent, type: DatabaseType = .mysql - ) -> (MainContentCoordinator, QueryTabManager, DataChangeManager) { + ) -> (MainContentCoordinator, QueryTabManager) { var conn = TestFixtures.makeConnection(type: type) conn.safeModeLevel = safeModeLevel let state = SessionStateFactory.create(connection: conn, payload: nil) - return (state.coordinator, state.tabManager, state.changeManager) + return (state.coordinator, state.tabManager) } // MARK: - No Changes @Test("saveChanges with no changes returns immediately without error") func noChanges_returnsWithoutError() { - let (coordinator, tabManager, _) = makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() tabManager.addTab(databaseName: "testdb") var truncates: Set = [] @@ -49,10 +49,10 @@ struct SaveCompletionTests { @Test("saveChanges on read-only connection sets error message") func readOnly_setsErrorMessage() { - let (coordinator, tabManager, changeManager) = makeCoordinator(safeModeLevel: .readOnly) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .readOnly) tabManager.addTab(databaseName: "testdb") - changeManager.hasChanges = true + coordinator.changeManager.hasChanges = true var truncates: Set = [] var deletes: Set = [] @@ -71,9 +71,10 @@ struct SaveCompletionTests { @Test("saveChanges on read-only connection does not clear changes") func readOnly_doesNotClearChanges() { - let (coordinator, _, changeManager) = makeCoordinator(safeModeLevel: .readOnly) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .readOnly) + tabManager.addTab(databaseName: "testdb") - changeManager.hasChanges = true + coordinator.changeManager.hasChanges = true var truncates: Set = [] var deletes: Set = [] @@ -85,17 +86,17 @@ struct SaveCompletionTests { tableOperationOptions: &options ) - #expect(changeManager.hasChanges == true) + #expect(coordinator.changeManager.hasChanges == true) } // MARK: - Empty Generated Statements @Test("saveChanges with hasChanges true but no generated SQL sets error") func hasChangesButNoSQL_setsError() { - let (coordinator, tabManager, changeManager) = makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() tabManager.addTab(databaseName: "testdb") - changeManager.hasChanges = true + coordinator.changeManager.hasChanges = true var truncates: Set = [] var deletes: Set = [] @@ -115,7 +116,7 @@ struct SaveCompletionTests { @Test("saveChanges with pending truncates but read-only sets error") func pendingTruncatesReadOnly_setsError() { - let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .readOnly) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .readOnly) tabManager.addTab(databaseName: "testdb") var truncates: Set = ["users"] @@ -136,8 +137,8 @@ struct SaveCompletionTests { @Test("saveChanges with no tab selected and read-only does not crash") func noTabSelected_readOnly_doesNotCrash() { - let (coordinator, _, changeManager) = makeCoordinator(safeModeLevel: .readOnly) - changeManager.hasChanges = true + let (coordinator, _) = makeCoordinator(safeModeLevel: .readOnly) + coordinator.changeManager.hasChanges = true var truncates: Set = [] var deletes: Set = [] @@ -149,12 +150,12 @@ struct SaveCompletionTests { tableOperationOptions: &options ) - #expect(changeManager.hasChanges == true) + #expect(coordinator.changeManager.hasChanges == true) } @Test("saveChanges with no changes and no pending ops does nothing") func noChangesNoPendingOps_noop() { - let (coordinator, tabManager, _) = makeCoordinator() + let (coordinator, tabManager) = makeCoordinator() tabManager.addTab(databaseName: "testdb") var truncates: Set = [] @@ -176,7 +177,7 @@ struct SaveCompletionTests { @Test("saveChanges with alert level and pending truncates clears inout params immediately") func alertLevel_pendingTruncates_clearsParams() { - let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .alert) tabManager.addTab(databaseName: "testdb") var truncates: Set = ["users"] @@ -195,7 +196,7 @@ struct SaveCompletionTests { @Test("saveChanges with safeMode level and pending deletes clears inout params") func safeModeLevel_pendingDeletes_clearsParams() { - let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .safeMode) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .safeMode) tabManager.addTab(databaseName: "testdb") var truncates: Set = [] @@ -213,7 +214,7 @@ struct SaveCompletionTests { @Test("saveChanges with alert level and no changes does nothing") func alertLevel_noChanges_noop() { - let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .alert) tabManager.addTab(databaseName: "testdb") var truncates: Set = [] @@ -233,7 +234,7 @@ struct SaveCompletionTests { @Test("saveChanges with silent level and pending truncates clears via normal path") func silentLevel_pendingTruncates_clearsViaNormalPath() { - let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .silent) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .silent) tabManager.addTab(databaseName: "testdb") var truncates: Set = ["users"] @@ -254,7 +255,7 @@ struct SaveCompletionTests { @Test("row operations blocked by readOnly level") func rowOperations_blockedByReadOnly() { - let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .readOnly) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .readOnly) tabManager.addTab(databaseName: "testdb") if let index = tabManager.selectedTabIndex { tabManager.tabs[index].isEditable = true @@ -280,7 +281,7 @@ struct SaveCompletionTests { @Test("row operations allowed by alert level") func rowOperations_allowedByAlertLevel() { - let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) + let (coordinator, tabManager) = makeCoordinator(safeModeLevel: .alert) tabManager.addTab(databaseName: "testdb") if let index = tabManager.selectedTabIndex { tabManager.tabs[index].isEditable = true diff --git a/TableProTests/Views/Main/TabEvictionTests.swift b/TableProTests/Views/Main/TabEvictionTests.swift index ce5dbedac..f1b33698b 100644 --- a/TableProTests/Views/Main/TabEvictionTests.swift +++ b/TableProTests/Views/Main/TabEvictionTests.swift @@ -44,7 +44,7 @@ struct TabEvictionTests { } if hasUnsavedChanges { - tab.pendingChanges.deletedRowIndices = [0] + tab.changeManager.recordRowDeletion(rowIndex: 0, originalRow: ["value_0"]) } return tab @@ -118,7 +118,7 @@ struct TabEvictionTests { let isCandidate = !tab.rowBuffer.isEvicted && !tab.resultRows.isEmpty && tab.lastExecutedAt != nil - && !tab.pendingChanges.hasChanges + && !tab.changeManager.hasChanges #expect(isCandidate == false) } @@ -140,7 +140,7 @@ struct TabEvictionTests { && !$0.rowBuffer.isEvicted && !$0.resultRows.isEmpty && $0.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + && !$0.changeManager.hasChanges } #expect(candidates.count == 1) @@ -165,7 +165,7 @@ struct TabEvictionTests { && !$0.rowBuffer.isEvicted && !$0.resultRows.isEmpty && $0.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + && !$0.changeManager.hasChanges } let sorted = candidates.sorted { @@ -211,7 +211,7 @@ struct TabEvictionTests { && !$0.rowBuffer.isEvicted && !$0.resultRows.isEmpty && $0.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + && !$0.changeManager.hasChanges } let sorted = candidates.sorted { diff --git a/TableProTests/Views/Main/TableOperationsPluginTests.swift b/TableProTests/Views/Main/TableOperationsPluginTests.swift index a9ad2329c..b8e2a9feb 100644 --- a/TableProTests/Views/Main/TableOperationsPluginTests.swift +++ b/TableProTests/Views/Main/TableOperationsPluginTests.swift @@ -20,14 +20,12 @@ struct TableOperationsPluginTests { private func makeCoordinator(type: DatabaseType = .mysql) -> MainContentCoordinator { let connection = TestFixtures.makeConnection(database: "testdb", type: type) let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() return MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index 9e41c71fa..b623da42e 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -34,14 +34,12 @@ struct SwitchDatabaseTests { func loadingStateDefaultsToIdle() { let connection = TestFixtures.makeConnection() let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState @@ -58,14 +56,12 @@ struct SwitchDatabaseTests { func openTableTabSkipsNewWindowDuringSwitch() { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState @@ -87,14 +83,12 @@ struct SwitchDatabaseTests { func openTableTabAddsInPlaceWhenSwitchingWithEmptyTabs() { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState @@ -118,14 +112,12 @@ struct SwitchDatabaseTests { func openTableTabSkipsForSameTableSameDatabase() { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState @@ -190,14 +182,12 @@ struct SwitchDatabaseTests { func switchDatabaseSetsLoadingState() async { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() - let changeManager = DataChangeManager() let filterStateManager = FilterStateManager() let toolbarState = ConnectionToolbarState() let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, - changeManager: changeManager, filterStateManager: filterStateManager, columnVisibilityManager: ColumnVisibilityManager(), toolbarState: toolbarState