From bf5d423d7eec0ae594a3c375c99bee3725332a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 14:52:18 +0700 Subject: [PATCH 01/11] refactor: migrate StructureChangeManager from custom undo stack to NSUndoManager --- .../StructureChangeManager.swift | 260 +++++++++++------- .../SchemaTracking/StructureUndoManager.swift | 79 ------ ... => StructureChangeManagerUndoTests.swift} | 195 +------------ 3 files changed, 169 insertions(+), 365 deletions(-) delete mode 100644 TablePro/Core/SchemaTracking/StructureUndoManager.swift rename TableProTests/Core/SchemaTracking/{StructureUndoManagerTests.swift => StructureChangeManagerUndoTests.swift} (58%) diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index a8fbb7936..635c5e419 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -37,7 +37,11 @@ final class StructureChangeManager { // MARK: - Undo/Redo Support - private let undoManager = StructureUndoManager() + private let undoManager: UndoManager = { + let manager = UndoManager() + manager.levelsOfUndo = 100 + return manager + }() private var visualStateCache: [Int: RowVisualState] = [:] var canUndo: Bool { undoManager.canUndo } @@ -117,7 +121,10 @@ final class StructureChangeManager { // Mark as pending change so hasChanges = true (even though placeholder is invalid) // This allows Cmd+R to show warning and Cmd+S to trigger validation pendingChanges[.column(placeholder.id)] = .addColumn(placeholder) - undoManager.push(.columnAdd(column: placeholder)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnAdd(column: placeholder)) + } + undoManager.setActionName(String(localized: "Add Column")) validate() hasChanges = true reloadVersion += 1 @@ -128,7 +135,10 @@ final class StructureChangeManager { let placeholder = EditableIndexDefinition.placeholder() workingIndexes.append(placeholder) pendingChanges[.index(placeholder.id)] = .addIndex(placeholder) - undoManager.push(.indexAdd(index: placeholder)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexAdd(index: placeholder)) + } + undoManager.setActionName(String(localized: "Add Index")) validate() hasChanges = true reloadVersion += 1 @@ -139,7 +149,10 @@ final class StructureChangeManager { let placeholder = EditableForeignKeyDefinition.placeholder() workingForeignKeys.append(placeholder) pendingChanges[.foreignKey(placeholder.id)] = .addForeignKey(placeholder) - undoManager.push(.foreignKeyAdd(fk: placeholder)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyAdd(fk: placeholder)) + } + undoManager.setActionName(String(localized: "Add Foreign Key")) validate() hasChanges = true reloadVersion += 1 @@ -151,7 +164,10 @@ final class StructureChangeManager { func addColumn(_ column: EditableColumnDefinition) { workingColumns.append(column) pendingChanges[.column(column.id)] = .addColumn(column) - undoManager.push(.columnAdd(column: column)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnAdd(column: column)) + } + undoManager.setActionName(String(localized: "Add Column")) hasChanges = true reloadVersion += 1 rebuildVisualStateCache() @@ -160,7 +176,10 @@ final class StructureChangeManager { func addIndex(_ index: EditableIndexDefinition) { workingIndexes.append(index) pendingChanges[.index(index.id)] = .addIndex(index) - undoManager.push(.indexAdd(index: index)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexAdd(index: index)) + } + undoManager.setActionName(String(localized: "Add Index")) hasChanges = true reloadVersion += 1 rebuildVisualStateCache() @@ -169,7 +188,10 @@ final class StructureChangeManager { func addForeignKey(_ foreignKey: EditableForeignKeyDefinition) { workingForeignKeys.append(foreignKey) pendingChanges[.foreignKey(foreignKey.id)] = .addForeignKey(foreignKey) - undoManager.push(.foreignKeyAdd(fk: foreignKey)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyAdd(fk: foreignKey)) + } + undoManager.setActionName(String(localized: "Add Foreign Key")) hasChanges = true reloadVersion += 1 rebuildVisualStateCache() @@ -182,7 +204,10 @@ final class StructureChangeManager { if let workingIndex = workingColumns.firstIndex(where: { $0.id == id }) { let oldWorking = workingColumns[workingIndex] if oldWorking != newColumn { - undoManager.push(.columnEdit(id: id, old: oldWorking, new: newColumn)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnEdit(id: id, old: oldWorking, new: newColumn)) + } + undoManager.setActionName(String(localized: "Edit Column")) } } @@ -214,7 +239,10 @@ final class StructureChangeManager { // Check if it's an existing column (from database) or a new column (not yet saved) if let column = currentColumns.first(where: { $0.id == id }) { // Existing column - mark as deleted (keep in workingColumns for visual feedback) - undoManager.push(.columnDelete(column: column)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnDelete(column: column)) + } + undoManager.setActionName(String(localized: "Delete Column")) pendingChanges[.column(id)] = .deleteColumn(column) // Track changed row for reload if let rowIndex = workingColumns.firstIndex(where: { $0.id == id }) { @@ -223,7 +251,10 @@ final class StructureChangeManager { } else { // New column that hasn't been saved yet - remove from list if let column = workingColumns.first(where: { $0.id == id }) { - undoManager.push(.columnDelete(column: column)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnDelete(column: column)) + } + undoManager.setActionName(String(localized: "Delete Column")) } if let rowIndex = workingColumns.firstIndex(where: { $0.id == id }) { // Track ALL rows from this index onwards for reload (indices shift down) @@ -248,7 +279,10 @@ final class StructureChangeManager { if let workingIdx = workingIndexes.firstIndex(where: { $0.id == id }) { let oldWorking = workingIndexes[workingIdx] if oldWorking != newIndex { - undoManager.push(.indexEdit(id: id, old: oldWorking, new: newIndex)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexEdit(id: id, old: oldWorking, new: newIndex)) + } + undoManager.setActionName(String(localized: "Edit Index")) } } @@ -278,7 +312,10 @@ final class StructureChangeManager { // Check if it's an existing index or a new index if let index = currentIndexes.first(where: { $0.id == id }) { // Existing index - mark as deleted (keep in workingIndexes for visual feedback) - undoManager.push(.indexDelete(index: index)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexDelete(index: index)) + } + undoManager.setActionName(String(localized: "Delete Index")) pendingChanges[.index(id)] = .deleteIndex(index) // Track changed row for reload if let rowIndex = workingIndexes.firstIndex(where: { $0.id == id }) { @@ -287,7 +324,10 @@ final class StructureChangeManager { } else { // New index that hasn't been saved yet - remove from list if let index = workingIndexes.first(where: { $0.id == id }) { - undoManager.push(.indexDelete(index: index)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexDelete(index: index)) + } + undoManager.setActionName(String(localized: "Delete Index")) } if let rowIndex = workingIndexes.firstIndex(where: { $0.id == id }) { // Track ALL rows from this index onwards for reload (indices shift down) @@ -312,7 +352,10 @@ final class StructureChangeManager { if let workingIdx = workingForeignKeys.firstIndex(where: { $0.id == id }) { let oldWorking = workingForeignKeys[workingIdx] if oldWorking != newFK { - undoManager.push(.foreignKeyEdit(id: id, old: oldWorking, new: newFK)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyEdit(id: id, old: oldWorking, new: newFK)) + } + undoManager.setActionName(String(localized: "Edit Foreign Key")) } } @@ -342,7 +385,10 @@ final class StructureChangeManager { // Check if it's an existing foreign key or a new foreign key if let fk = currentForeignKeys.first(where: { $0.id == id }) { // Existing FK - mark as deleted (keep in workingForeignKeys for visual feedback) - undoManager.push(.foreignKeyDelete(fk: fk)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyDelete(fk: fk)) + } + undoManager.setActionName(String(localized: "Delete Foreign Key")) pendingChanges[.foreignKey(id)] = .deleteForeignKey(fk) // Track changed row for reload if let rowIndex = workingForeignKeys.firstIndex(where: { $0.id == id }) { @@ -351,7 +397,10 @@ final class StructureChangeManager { } else { // New FK that hasn't been saved yet - remove from list if let fk = workingForeignKeys.first(where: { $0.id == id }) { - undoManager.push(.foreignKeyDelete(fk: fk)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyDelete(fk: fk)) + } + undoManager.setActionName(String(localized: "Delete Foreign Key")) } if let rowIndex = workingForeignKeys.firstIndex(where: { $0.id == id }) { // Track ALL rows from this index onwards for reload (indices shift down) @@ -374,7 +423,11 @@ final class StructureChangeManager { func updatePrimaryKey(_ columns: [String]) { // Push undo action before modifying if columns != workingPrimaryKey { - undoManager.push(.primaryKeyChange(old: workingPrimaryKey, new: columns)) + let oldPK = workingPrimaryKey + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.primaryKeyChange(old: oldPK, new: columns)) + } + undoManager.setActionName(String(localized: "Change Primary Key")) } if columns != currentPrimaryKey { @@ -487,7 +540,7 @@ final class StructureChangeManager { resetWorkingState() reloadVersion += 1 rebuildVisualStateCache() - undoManager.clearAll() + undoManager.removeAllActions() } func getChangesArray() -> [SchemaChange] { @@ -497,25 +550,28 @@ final class StructureChangeManager { // MARK: - Undo/Redo Operations func undo() { - guard let action = undoManager.undo() else { return } - applyUndoAction(action, isRedo: false) + guard undoManager.canUndo else { return } + undoManager.undo() } func redo() { - guard let action = undoManager.redo() else { return } - applyUndoAction(action, isRedo: true) + guard undoManager.canRedo else { return } + undoManager.redo() } - private func applyUndoAction(_ action: SchemaUndoAction, isRedo: Bool) { + private func applySchemaUndo(_ action: SchemaUndoAction) { switch action { case .columnEdit(let id, let old, let new): - let column = isRedo ? new : old + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnEdit(id: id, old: new, new: old)) + } + undoManager.setActionName(String(localized: "Edit Column")) if let index = workingColumns.firstIndex(where: { $0.id == id }) { - workingColumns[index] = column + workingColumns[index] = old if let currentIndex = currentColumns.firstIndex(where: { $0.id == id }) { let current = currentColumns[currentIndex] - if column != current { - pendingChanges[.column(id)] = .modifyColumn(old: current, new: column) + if old != current { + pendingChanges[.column(id)] = .modifyColumn(old: current, new: old) } else { pendingChanges.removeValue(forKey: .column(id)) } @@ -523,42 +579,36 @@ final class StructureChangeManager { } case .columnAdd(let column): - if isRedo { - workingColumns.append(column) - pendingChanges[.column(column.id)] = .addColumn(column) - } else { - workingColumns.removeAll { $0.id == column.id } - pendingChanges.removeValue(forKey: .column(column.id)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnDelete(column: column)) } + undoManager.setActionName(String(localized: "Add Column")) + workingColumns.removeAll { $0.id == column.id } + pendingChanges.removeValue(forKey: .column(column.id)) case .columnDelete(let column): - if isRedo { - // For existing columns, keep in workingColumns for strikethrough; for new columns, remove - if currentColumns.contains(where: { $0.id == column.id }) { - pendingChanges[.column(column.id)] = .deleteColumn(column) - } else { - workingColumns.removeAll { $0.id == column.id } - pendingChanges.removeValue(forKey: .column(column.id)) - } + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.columnAdd(column: column)) + } + undoManager.setActionName(String(localized: "Delete Column")) + if workingColumns.contains(where: { $0.id == column.id }) { + pendingChanges.removeValue(forKey: .column(column.id)) } else { - // Undo delete: if column is still in workingColumns (existing, kept for strikethrough), - // just clear pending change. If physically removed (new column), re-add it. - if workingColumns.contains(where: { $0.id == column.id }) { - pendingChanges.removeValue(forKey: .column(column.id)) - } else { - workingColumns.append(column) - pendingChanges[.column(column.id)] = .addColumn(column) - } + workingColumns.append(column) + pendingChanges[.column(column.id)] = .addColumn(column) } case .indexEdit(let id, let old, let new): - let index = isRedo ? new : old + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexEdit(id: id, old: new, new: old)) + } + undoManager.setActionName(String(localized: "Edit Index")) if let idx = workingIndexes.firstIndex(where: { $0.id == id }) { - workingIndexes[idx] = index + workingIndexes[idx] = old if let currentIdx = currentIndexes.firstIndex(where: { $0.id == id }) { let current = currentIndexes[currentIdx] - if index != current { - pendingChanges[.index(id)] = .modifyIndex(old: current, new: index) + if old != current { + pendingChanges[.index(id)] = .modifyIndex(old: current, new: old) } else { pendingChanges.removeValue(forKey: .index(id)) } @@ -566,42 +616,36 @@ final class StructureChangeManager { } case .indexAdd(let index): - if isRedo { - workingIndexes.append(index) - pendingChanges[.index(index.id)] = .addIndex(index) - } else { - workingIndexes.removeAll { $0.id == index.id } - pendingChanges.removeValue(forKey: .index(index.id)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexDelete(index: index)) } + undoManager.setActionName(String(localized: "Add Index")) + workingIndexes.removeAll { $0.id == index.id } + pendingChanges.removeValue(forKey: .index(index.id)) case .indexDelete(let index): - if isRedo { - // For existing indexes, keep in workingIndexes for strikethrough; for new indexes, remove - if currentIndexes.contains(where: { $0.id == index.id }) { - pendingChanges[.index(index.id)] = .deleteIndex(index) - } else { - workingIndexes.removeAll { $0.id == index.id } - pendingChanges.removeValue(forKey: .index(index.id)) - } + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.indexAdd(index: index)) + } + undoManager.setActionName(String(localized: "Delete Index")) + if workingIndexes.contains(where: { $0.id == index.id }) { + pendingChanges.removeValue(forKey: .index(index.id)) } else { - // Undo delete: if index is still in workingIndexes (existing, kept for strikethrough), - // just clear pending change. If physically removed (new index), re-add it. - if workingIndexes.contains(where: { $0.id == index.id }) { - pendingChanges.removeValue(forKey: .index(index.id)) - } else { - workingIndexes.append(index) - pendingChanges[.index(index.id)] = .addIndex(index) - } + workingIndexes.append(index) + pendingChanges[.index(index.id)] = .addIndex(index) } case .foreignKeyEdit(let id, let old, let new): - let fk = isRedo ? new : old + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyEdit(id: id, old: new, new: old)) + } + undoManager.setActionName(String(localized: "Edit Foreign Key")) if let idx = workingForeignKeys.firstIndex(where: { $0.id == id }) { - workingForeignKeys[idx] = fk + workingForeignKeys[idx] = old if let currentIdx = currentForeignKeys.firstIndex(where: { $0.id == id }) { let current = currentForeignKeys[currentIdx] - if fk != current { - pendingChanges[.foreignKey(id)] = .modifyForeignKey(old: current, new: fk) + if old != current { + pendingChanges[.foreignKey(id)] = .modifyForeignKey(old: current, new: old) } else { pendingChanges.removeValue(forKey: .foreignKey(id)) } @@ -609,36 +653,31 @@ final class StructureChangeManager { } case .foreignKeyAdd(let fk): - if isRedo { - workingForeignKeys.append(fk) - pendingChanges[.foreignKey(fk.id)] = .addForeignKey(fk) - } else { - workingForeignKeys.removeAll { $0.id == fk.id } - pendingChanges.removeValue(forKey: .foreignKey(fk.id)) + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyDelete(fk: fk)) } + undoManager.setActionName(String(localized: "Add Foreign Key")) + workingForeignKeys.removeAll { $0.id == fk.id } + pendingChanges.removeValue(forKey: .foreignKey(fk.id)) case .foreignKeyDelete(let fk): - if isRedo { - // For existing FKs, keep in workingForeignKeys for strikethrough; for new FKs, remove - if currentForeignKeys.contains(where: { $0.id == fk.id }) { - pendingChanges[.foreignKey(fk.id)] = .deleteForeignKey(fk) - } else { - workingForeignKeys.removeAll { $0.id == fk.id } - pendingChanges.removeValue(forKey: .foreignKey(fk.id)) - } + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.foreignKeyAdd(fk: fk)) + } + undoManager.setActionName(String(localized: "Delete Foreign Key")) + if workingForeignKeys.contains(where: { $0.id == fk.id }) { + pendingChanges.removeValue(forKey: .foreignKey(fk.id)) } else { - // Undo delete: if FK is still in workingForeignKeys (existing, kept for strikethrough), - // just clear pending change. If physically removed (new FK), re-add it. - if workingForeignKeys.contains(where: { $0.id == fk.id }) { - pendingChanges.removeValue(forKey: .foreignKey(fk.id)) - } else { - workingForeignKeys.append(fk) - pendingChanges[.foreignKey(fk.id)] = .addForeignKey(fk) - } + workingForeignKeys.append(fk) + pendingChanges[.foreignKey(fk.id)] = .addForeignKey(fk) } - case .primaryKeyChange(let old, let new): - workingPrimaryKey = isRedo ? new : old + case .primaryKeyChange(let old, _): + undoManager.registerUndo(withTarget: self) { target in + target.applySchemaUndo(.primaryKeyChange(old: self.workingPrimaryKey, new: old)) + } + undoManager.setActionName(String(localized: "Change Primary Key")) + workingPrimaryKey = old if workingPrimaryKey != currentPrimaryKey { pendingChanges[.primaryKey] = .modifyPrimaryKey(old: currentPrimaryKey, new: workingPrimaryKey) } else { @@ -730,3 +769,18 @@ final class StructureChangeManager { visualStateCache.removeAll() } } + +// MARK: - Schema Undo Action + +enum SchemaUndoAction { + case columnEdit(id: UUID, old: EditableColumnDefinition, new: EditableColumnDefinition) + case columnAdd(column: EditableColumnDefinition) + case columnDelete(column: EditableColumnDefinition) + case indexEdit(id: UUID, old: EditableIndexDefinition, new: EditableIndexDefinition) + case indexAdd(index: EditableIndexDefinition) + case indexDelete(index: EditableIndexDefinition) + case foreignKeyEdit(id: UUID, old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) + case foreignKeyAdd(fk: EditableForeignKeyDefinition) + case foreignKeyDelete(fk: EditableForeignKeyDefinition) + case primaryKeyChange(old: [String], new: [String]) +} diff --git a/TablePro/Core/SchemaTracking/StructureUndoManager.swift b/TablePro/Core/SchemaTracking/StructureUndoManager.swift deleted file mode 100644 index 0e348db94..000000000 --- a/TablePro/Core/SchemaTracking/StructureUndoManager.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// StructureUndoManager.swift -// TablePro -// -// Undo/redo stack for schema changes - mirrors DataChangeUndoManager pattern -// - -import Foundation - -/// Represents an action that can be undone in schema editing -enum SchemaUndoAction { - case columnEdit(id: UUID, old: EditableColumnDefinition, new: EditableColumnDefinition) - case columnAdd(column: EditableColumnDefinition) - case columnDelete(column: EditableColumnDefinition) - case indexEdit(id: UUID, old: EditableIndexDefinition, new: EditableIndexDefinition) - case indexAdd(index: EditableIndexDefinition) - case indexDelete(index: EditableIndexDefinition) - case foreignKeyEdit(id: UUID, old: EditableForeignKeyDefinition, new: EditableForeignKeyDefinition) - case foreignKeyAdd(fk: EditableForeignKeyDefinition) - case foreignKeyDelete(fk: EditableForeignKeyDefinition) - case primaryKeyChange(old: [String], new: [String]) -} - -/// Manages undo/redo stack for schema changes -final class StructureUndoManager { - private var undoStack: [SchemaUndoAction] = [] - private var redoStack: [SchemaUndoAction] = [] - - private let maxStackSize = 100 - - // MARK: - Public API - - var canUndo: Bool { - !undoStack.isEmpty - } - - var canRedo: Bool { - !redoStack.isEmpty - } - - /// Push a new action onto the undo stack - func push(_ action: SchemaUndoAction) { - undoStack.append(action) - - // Limit stack size - if undoStack.count > maxStackSize { - undoStack.removeFirst() - } - - // Clear redo stack when new action is performed - redoStack.removeAll() - } - - /// Pop the last action from undo stack - func undo() -> SchemaUndoAction? { - guard let action = undoStack.popLast() else { - return nil - } - - redoStack.append(action) - return action - } - - /// Pop the last action from redo stack - func redo() -> SchemaUndoAction? { - guard let action = redoStack.popLast() else { - return nil - } - - undoStack.append(action) - return action - } - - /// Clear all stacks - func clearAll() { - undoStack.removeAll() - redoStack.removeAll() - } -} diff --git a/TableProTests/Core/SchemaTracking/StructureUndoManagerTests.swift b/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift similarity index 58% rename from TableProTests/Core/SchemaTracking/StructureUndoManagerTests.swift rename to TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift index 21d47e22d..5b75e83d0 100644 --- a/TableProTests/Core/SchemaTracking/StructureUndoManagerTests.swift +++ b/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift @@ -1,5 +1,5 @@ // -// StructureUndoManagerTests.swift +// StructureChangeManagerUndoTests.swift // TableProTests // // Tests for S-01: Undo/Redo must be functional in StructureChangeManager @@ -9,160 +9,6 @@ import Foundation import Testing @testable import TablePro -// MARK: - StructureUndoManager Unit Tests - -@Suite("Structure Undo Manager") -struct StructureUndoManagerTests { - - // MARK: - Helpers - - private func makeColumn( - name: String = "email", - dataType: String = "VARCHAR(255)" - ) -> EditableColumnDefinition { - EditableColumnDefinition( - id: UUID(), - name: name, - dataType: dataType, - isNullable: true, - defaultValue: nil, - autoIncrement: false, - unsigned: false, - comment: nil, - collation: nil, - onUpdate: nil, - charset: nil, - extra: nil, - isPrimaryKey: false - ) - } - - private func makeIndex( - name: String = "idx_email", - columns: [String] = ["email"] - ) -> EditableIndexDefinition { - EditableIndexDefinition( - id: UUID(), - name: name, - columns: columns, - type: .btree, - isUnique: false, - isPrimary: false, - comment: nil - ) - } - - private func makeFK( - name: String = "fk_role", - columns: [String] = ["role_id"], - refTable: String = "roles", - refColumns: [String] = ["id"] - ) -> EditableForeignKeyDefinition { - EditableForeignKeyDefinition( - id: UUID(), - name: name, - columns: columns, - referencedTable: refTable, - referencedColumns: refColumns, - onDelete: .cascade, - onUpdate: .noAction - ) - } - - // MARK: - Basic Push/Pop Tests - - @Test("Push and undo returns the action") - func pushAndUndo() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - - #expect(manager.canUndo == true) - let action = manager.undo() - #expect(action != nil) - } - - @Test("Undo on empty stack returns nil") - func undoEmpty() { - let manager = StructureUndoManager() - #expect(manager.canUndo == false) - #expect(manager.undo() == nil) - } - - @Test("Redo on empty stack returns nil") - func redoEmpty() { - let manager = StructureUndoManager() - #expect(manager.canRedo == false) - #expect(manager.redo() == nil) - } - - @Test("Undo moves action to redo stack") - func undoMovesToRedo() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - - _ = manager.undo() - #expect(manager.canUndo == false) - #expect(manager.canRedo == true) - } - - @Test("Redo moves action back to undo stack") - func redoMovesBack() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - - _ = manager.undo() - _ = manager.redo() - #expect(manager.canUndo == true) - #expect(manager.canRedo == false) - } - - @Test("New action clears redo stack") - func newActionClearsRedo() { - let manager = StructureUndoManager() - let col1 = makeColumn(name: "a") - let col2 = makeColumn(name: "b") - - manager.push(.columnAdd(column: col1)) - _ = manager.undo() - #expect(manager.canRedo == true) - - manager.push(.columnAdd(column: col2)) - #expect(manager.canRedo == false) - } - - @Test("Max stack size is enforced") - func maxStackSize() { - let manager = StructureUndoManager() - for i in 0..<150 { - let col = makeColumn(name: "col_\(i)") - manager.push(.columnAdd(column: col)) - } - - // Should be capped at 100 - var count = 0 - while manager.undo() != nil { - count += 1 - } - #expect(count == 100) - } - - @Test("clearAll empties both stacks") - func clearAll() { - let manager = StructureUndoManager() - let col = makeColumn() - manager.push(.columnAdd(column: col)) - manager.push(.columnDelete(column: col)) - _ = manager.undo() - - manager.clearAll() - #expect(manager.canUndo == false) - #expect(manager.canRedo == false) - } -} - // MARK: - StructureChangeManager Undo Integration Tests @Suite("Structure Change Manager Undo/Redo Integration") @@ -205,7 +51,6 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - // Edit the "name" column let nameCol = manager.workingColumns[1] var modified = nameCol modified.dataType = "TEXT" @@ -215,7 +60,6 @@ struct StructureChangeManagerUndoTests { #expect(manager.hasChanges == true) #expect(manager.canUndo == true) - // Undo should revert manager.undo() #expect(manager.workingColumns[1].dataType == "VARCHAR(255)") #expect(manager.hasChanges == false) @@ -264,9 +108,8 @@ struct StructureChangeManagerUndoTests { #expect(manager.canUndo == true) manager.undo() - // Column should be restored (no longer marked as deleted) let change = manager.pendingChanges[.column(emailCol.id)] - #expect(change == nil) // No pending change = restored to original + #expect(change == nil) #expect(manager.hasChanges == false) } @@ -275,24 +118,20 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - // Edit 1: change name type let nameCol = manager.workingColumns[1] var mod1 = nameCol mod1.dataType = "TEXT" manager.updateColumn(id: nameCol.id, with: mod1) - // Edit 2: change email type let emailCol = manager.workingColumns[2] var mod2 = emailCol mod2.dataType = "TEXT" manager.updateColumn(id: emailCol.id, with: mod2) - // Undo edit 2 manager.undo() #expect(manager.workingColumns[2].dataType == "VARCHAR(255)") - #expect(manager.workingColumns[1].dataType == "TEXT") // edit 1 still applied + #expect(manager.workingColumns[1].dataType == "TEXT") - // Undo edit 1 manager.undo() #expect(manager.workingColumns[1].dataType == "VARCHAR(255)") #expect(manager.hasChanges == false) @@ -351,17 +190,15 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - let initialCount = manager.workingColumns.count // 3 + let initialCount = manager.workingColumns.count let emailCol = manager.workingColumns[2] - // Delete existing column (kept in workingColumns for strikethrough) manager.deleteColumn(id: emailCol.id) - #expect(manager.workingColumns.count == initialCount) // Still 3 (strikethrough) + #expect(manager.workingColumns.count == initialCount) #expect(manager.hasChanges == true) - // Undo should NOT append a duplicate manager.undo() - #expect(manager.workingColumns.count == initialCount) // Still 3, not 4! + #expect(manager.workingColumns.count == initialCount) #expect(manager.hasChanges == false) } @@ -370,26 +207,21 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - let initialCount = manager.workingColumns.count // 3 + let initialCount = manager.workingColumns.count let nameCol = manager.workingColumns[1] let emailCol = manager.workingColumns[2] - // Delete two existing columns manager.deleteColumn(id: nameCol.id) manager.deleteColumn(id: emailCol.id) - #expect(manager.workingColumns.count == initialCount) // Still 3 (both kept for strikethrough) + #expect(manager.workingColumns.count == initialCount) - // Undo delete of email manager.undo() - #expect(manager.workingColumns.count == initialCount) // Still 3 - // email should no longer be marked as deleted + #expect(manager.workingColumns.count == initialCount) #expect(manager.pendingChanges[.column(emailCol.id)] == nil) - // name should still be marked as deleted #expect(manager.pendingChanges[.column(nameCol.id)] != nil) - // Undo delete of name manager.undo() - #expect(manager.workingColumns.count == initialCount) // Still 3 + #expect(manager.workingColumns.count == initialCount) #expect(manager.pendingChanges[.column(nameCol.id)] == nil) #expect(manager.hasChanges == false) } @@ -399,18 +231,15 @@ struct StructureChangeManagerUndoTests { let manager = makeManager() loadSampleSchema(manager) - let initialCount = manager.workingColumns.count // 3 + let initialCount = manager.workingColumns.count - // Add a new column manager.addNewColumn() #expect(manager.workingColumns.count == initialCount + 1) let newCol = manager.workingColumns.last! - // Delete the new column (physically removes it) manager.deleteColumn(id: newCol.id) - #expect(manager.workingColumns.count == initialCount) // Removed + #expect(manager.workingColumns.count == initialCount) - // Undo should re-add the new column manager.undo() #expect(manager.workingColumns.count == initialCount + 1) #expect(manager.workingColumns.contains(where: { $0.id == newCol.id })) From dc3a49fde060276dd5189bca0509df3c75851126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 14:52:33 +0700 Subject: [PATCH 02/11] refactor: migrate DataChangeManager from custom undo stack to NSUndoManager --- .../ChangeTracking/DataChangeManager.swift | 461 +++++++++++------- .../DataChangeUndoManager.swift | 98 ---- .../Services/Query/RowOperationsManager.swift | 67 ++- .../DataChangeUndoManagerTests.swift | 250 ---------- 4 files changed, 301 insertions(+), 575 deletions(-) delete mode 100644 TablePro/Core/ChangeTracking/DataChangeUndoManager.swift delete mode 100644 TableProTests/Core/ChangeTracking/DataChangeUndoManagerTests.swift diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index adb5195bc..4a4b53657 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -4,7 +4,7 @@ // // Manager for tracking data changes with O(1) lookups. // Delegates SQL generation to SQLStatementGenerator. -// Delegates undo/redo stack management to DataChangeUndoManager. +// Uses Apple's UndoManager (NSUndoManager) for undo/redo stack management. // import Foundation @@ -12,6 +12,13 @@ import Observation import os import TableProPluginKit +struct UndoResult { + let action: UndoAction + let needsRowRemoval: Bool + let needsRowRestore: Bool + let restoreRow: [String?]? +} + /// Manager for tracking and applying data changes /// @MainActor ensures thread-safe access - critical for avoiding EXC_BAD_ACCESS /// when multiple queries complete simultaneously (e.g., rapid sorting over SSH tunnel) @@ -20,9 +27,8 @@ final class DataChangeManager { private static let logger = Logger(subsystem: "com.TablePro", category: "DataChangeManager") var changes: [RowChange] = [] var hasChanges: Bool = false - var reloadVersion: Int = 0 // Incremented to trigger table reload + var reloadVersion: Int = 0 - // Track which rows changed since last reload for granular updates private(set) var changedRowIndices: Set = [] var tableName: String = "" @@ -30,7 +36,6 @@ final class DataChangeManager { var databaseType: DatabaseType = .mysql var pluginDriver: (any PluginDatabaseDriver)? - // Simple storage with explicit deep copy to avoid memory corruption private var _columnsStorage: [String] = [] var columns: [String] { get { _columnsStorage } @@ -39,16 +44,9 @@ final class DataChangeManager { // MARK: - Cached Lookups for O(1) Performance - /// Set of row indices that are marked for deletion - O(1) lookup private var deletedRowIndices: Set = [] - - /// Set of row indices that are newly inserted - O(1) lookup private(set) var insertedRowIndices: Set = [] - - /// Row index → modified column indices for O(1) per-cell lookup private var modifiedCells: [Int: Set] = [:] - - /// Lazy storage for inserted row values - avoids creating CellChange objects until needed private var insertedRowData: [Int: [String?]] = [:] /// (rowIndex, changeType) → index in `changes` array for O(1) lookup @@ -72,14 +70,11 @@ final class DataChangeManager { changeIndex.removeValue(forKey: removedKey) changes.remove(at: arrayIndex) - // Decrement indices above the removed position for (key, idx) in changeIndex where idx > arrayIndex { changeIndex[key] = idx - 1 } } - /// Remove the change for a given (rowIndex, type) using O(1) index lookup. - /// Returns true if a change was found and removed. @discardableResult private func removeChangeByKey(rowIndex: Int, type: ChangeType) -> Bool { let key = RowChangeKey(rowIndex: rowIndex, type: type) @@ -103,11 +98,14 @@ final class DataChangeManager { return lo } - /// Undo/redo manager - private let undoManager = DataChangeUndoManager() + let undoManager: UndoManager = { + let manager = UndoManager() + manager.levelsOfUndo = 100 + manager.groupsByEvent = false + return manager + }() - /// Flag to prevent clearing redo stack during redo operations - private var isRedoing = false + private var lastUndoResult: UndoResult? // MARK: - Undo/Redo Properties @@ -116,7 +114,6 @@ final class DataChangeManager { // MARK: - Helper Methods - /// Consume and clear changed row indices (for granular table reloads) func consumeChangedRowIndices() -> Set { let indices = changedRowIndices changedRowIndices.removeAll() @@ -125,8 +122,6 @@ final class DataChangeManager { // MARK: - Configuration - /// Clear all tracked changes, preserving undo/redo history. - /// Use when changes are invalidated but undo context may still be relevant. func clearChanges() { changes.removeAll() changeIndex.removeAll() @@ -139,15 +134,11 @@ final class DataChangeManager { reloadVersion += 1 } - /// Clear all tracked changes AND undo/redo history. - /// Use after successful save, explicit discard, or new query execution - /// where undo context is no longer meaningful. func clearChangesAndUndoHistory() { clearChanges() - undoManager.clearAll() + undoManager.removeAllActions() } - /// Atomically configure the manager for a new table func configureForTable( tableName: String, columns: [String], @@ -166,7 +157,7 @@ final class DataChangeManager { modifiedCells.removeAll() insertedRowData.removeAll() changedRowIndices.removeAll() - undoManager.clearAll() + undoManager.removeAllActions() changes.removeAll() hasChanges = false @@ -203,9 +194,6 @@ final class DataChangeManager { return } - // New changes invalidate redo history (standard undo/redo behavior) - if !isRedoing { undoManager.clearRedo() } - let cellChange = CellChange( rowIndex: rowIndex, columnIndex: columnIndex, @@ -214,10 +202,8 @@ final class DataChangeManager { newValue: newValue ) - // Check if this is an edit to an INSERTED row — O(1) dictionary lookup let insertKey = RowChangeKey(rowIndex: rowIndex, type: .insert) if let insertIndex = changeIndex[insertKey] { - // Update stored values directly if var storedValues = insertedRowData[rowIndex] { if columnIndex < storedValues.count { storedValues[columnIndex] = newValue @@ -225,7 +211,6 @@ final class DataChangeManager { } } - // Update/create CellChange for this column if let cellIndex = changes[insertIndex].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) { @@ -245,20 +230,19 @@ final class DataChangeManager { newValue: newValue )) } - pushUndo(.cellEdit( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - previousValue: oldValue, - newValue: newValue - )) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.cellEdit( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + previousValue: oldValue, newValue: newValue + )) + } + undoManager.setActionName(String(localized: "Edit Cell")) changedRowIndices.insert(rowIndex) hasChanges = !changes.isEmpty reloadVersion += 1 return } - // Find existing UPDATE row change or create new one — O(1) dictionary lookup let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) if let existingIndex = changeIndex[updateKey] { if let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { @@ -273,7 +257,6 @@ final class DataChangeManager { newValue: newValue ) - // If value is back to original, remove the change if originalOldValue == newValue { changes[existingIndex].cellChanges.remove(at: cellIndex) modifiedCells[rowIndex]?.remove(columnIndex) @@ -302,22 +285,18 @@ final class DataChangeManager { changedRowIndices.insert(rowIndex) } - pushUndo(.cellEdit( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - previousValue: oldValue, - newValue: newValue - )) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.cellEdit( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + previousValue: oldValue, newValue: newValue + )) + } + undoManager.setActionName(String(localized: "Edit Cell")) hasChanges = !changes.isEmpty reloadVersion += 1 } func recordRowDeletion(rowIndex: Int, originalRow: [String?]) { - // New changes invalidate redo history (standard undo/redo behavior) - if !isRedoing { undoManager.clearRedo() } - - // O(1) lookup + removal instead of linear removeAll removeChangeByKey(rowIndex: rowIndex, type: .update) modifiedCells.removeValue(forKey: rowIndex) @@ -325,16 +304,16 @@ final class DataChangeManager { changes.append(rowChange) changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 deletedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) // Track for granular reload - pushUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + changedRowIndices.insert(rowIndex) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + } + undoManager.setActionName(String(localized: "Delete Row")) hasChanges = true reloadVersion += 1 } func recordBatchRowDeletion(rows: [(rowIndex: Int, originalRow: [String?])]) { - // New changes invalidate redo history (never called from redo path) - undoManager.clearRedo() - guard rows.count > 1 else { if let row = rows.first { recordRowDeletion(rowIndex: row.rowIndex, originalRow: row.originalRow) @@ -352,25 +331,28 @@ final class DataChangeManager { changes.append(rowChange) changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 deletedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) // Track for granular reload + changedRowIndices.insert(rowIndex) batchData.append((rowIndex: rowIndex, originalRow: originalRow)) } - pushUndo(.batchRowDeletion(rows: batchData)) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowDeletion(rows: batchData)) + } + undoManager.setActionName(String(localized: "Delete Rows")) hasChanges = true reloadVersion += 1 } func recordRowInsertion(rowIndex: Int, values: [String?]) { - // New changes invalidate redo history (never called from redo path) - undoManager.clearRedo() - insertedRowData[rowIndex] = values let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: []) changes.append(rowChange) changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 insertedRowIndices.insert(rowIndex) - changedRowIndices.insert(rowIndex) // Track for granular reload - pushUndo(.rowInsertion(rowIndex: rowIndex)) + changedRowIndices.insert(rowIndex) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) + } + undoManager.setActionName(String(localized: "Insert Row")) hasChanges = true reloadVersion += 1 } @@ -392,7 +374,6 @@ final class DataChangeManager { insertedRowIndices.remove(rowIndex) insertedRowData.removeValue(forKey: rowIndex) - // Shift down indices for rows after the removed row var shiftedInsertedIndices = Set() for idx in insertedRowIndices { shiftedInsertedIndices.insert(idx > rowIndex ? idx - 1 : idx) @@ -405,7 +386,6 @@ final class DataChangeManager { } } - // Rebuild needed after row index shifts rebuildChangeIndex() hasChanges = !changes.isEmpty } @@ -416,7 +396,6 @@ final class DataChangeManager { let validRows = rowIndices.filter { insertedRowIndices.contains($0) } guard !validRows.isEmpty else { return } - // Collect row values for undo/redo — O(1) lookup via changeIndex var rowValues: [[String?]] = [] for rowIndex in validRows { let key = RowChangeKey(rowIndex: rowIndex, type: .insert) @@ -435,9 +414,11 @@ final class DataChangeManager { insertedRowData.removeValue(forKey: rowIndex) } - pushUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) + } + undoManager.setActionName(String(localized: "Insert Rows")) - // Single-pass shift using binary search instead of O(n²) nested loop let sortedDeleted = validRows.sorted() var newInserted = Set() @@ -457,33 +438,20 @@ final class DataChangeManager { hasChanges = !changes.isEmpty } - // MARK: - Undo/Redo Stack Management - - func pushUndo(_ action: UndoAction) { - undoManager.push(action) - } - - func popUndo() -> UndoAction? { - undoManager.popUndo() - } - - func clearUndoStack() { - undoManager.clearUndo() - } - - func clearRedoStack() { - undoManager.clearRedo() - } - - /// Undo the last change and return details needed to update the UI - func undoLastChange() -> (action: UndoAction, needsRowRemoval: Bool, needsRowRestore: Bool, restoreRow: [String?]?)? { - guard let action = popUndo() else { return nil } - - undoManager.moveToRedo(action) + // MARK: - Core Undo Application + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func applyDataUndo(_ action: UndoAction) { switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, _): - // O(1) lookup: try update first, then insert + case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.cellEdit( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + previousValue: newValue, newValue: previousValue + )) + } + undoManager.setActionName(String(localized: "Edit Cell")) + let matchedIndex = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .update)] ?? changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] if let changeIdx = matchedIndex { @@ -526,123 +494,246 @@ final class DataChangeManager { } } } + } else { + // Redo path: no existing change record, re-apply the edit. + // Cell currently holds newValue, changing to previousValue. + recordCellChangeForRedo( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: newValue, newValue: previousValue + ) } changedRowIndices.insert(rowIndex) - hasChanges = !changes.isEmpty - reloadVersion += 1 - return (action, false, false, nil) + lastUndoResult = UndoResult(action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil) case .rowInsertion(let rowIndex): - undoRowInsertion(rowIndex: rowIndex) - changedRowIndices.insert(rowIndex) - return (action, true, false, nil) - - case .rowDeletion(let rowIndex, let originalRow): - undoRowDeletion(rowIndex: rowIndex) - changedRowIndices.insert(rowIndex) - return (action, false, true, originalRow) - - case .batchRowDeletion(let rows): - for (rowIndex, _) in rows.reversed() { - undoRowDeletion(rowIndex: rowIndex) - changedRowIndices.insert(rowIndex) + let savedValues = insertedRowData[rowIndex] + undoManager.registerUndo(withTarget: self) { [savedValues] target in + // Restore saved values so the reverse operation can access them + if let savedValues { + target.insertedRowData[rowIndex] = savedValues + } + target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) } - return (action, false, true, nil) - - case .batchRowInsertion(let rowIndices, let rowValues): - for (index, rowIndex) in rowIndices.enumerated().reversed() { - guard index < rowValues.count else { continue } - let values = rowValues[index] + undoManager.setActionName(String(localized: "Insert Row")) - let cellChanges = values.enumerated().map { colIndex, value in + if insertedRowIndices.contains(rowIndex) { + undoRowInsertion(rowIndex: rowIndex) + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } else { + insertedRowIndices.insert(rowIndex) + let cellChanges = columns.enumerated().map { index, columnName in CellChange( rowIndex: rowIndex, - columnIndex: colIndex, - columnName: columns[safe: colIndex] ?? "", + columnIndex: index, + columnName: columnName, oldValue: nil, - newValue: value + newValue: savedValues?[safe: index] ?? nil ) } let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) changes.append(rowChange) - insertedRowIndices.insert(rowIndex) + changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 + if let savedValues { + insertedRowData[rowIndex] = savedValues + } + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues + ) } - rebuildChangeIndex() - hasChanges = !changes.isEmpty - reloadVersion += 1 - return (action, true, false, nil) - } - } + case .rowDeletion(let rowIndex, let originalRow): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) + } + undoManager.setActionName(String(localized: "Delete Row")) - /// Redo the last undone change - func redoLastChange() -> (action: UndoAction, needsRowInsert: Bool, needsRowDelete: Bool)? { - guard let action = undoManager.popRedo() else { return nil } + if deletedRowIndices.contains(rowIndex) { + // Undo: restore the deleted row + undoRowDeletion(rowIndex: rowIndex) + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow + ) + } else { + // Redo: re-delete the row + redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + changedRowIndices.insert(rowIndex) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } - isRedoing = true - defer { isRedoing = false } + case .batchRowDeletion(let rows): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowDeletion(rows: rows)) + } + undoManager.setActionName(String(localized: "Delete Rows")) - undoManager.moveToUndo(action) + let firstRowDeleted = rows.first.map { deletedRowIndices.contains($0.rowIndex) } ?? false + if firstRowDeleted { + // Undo: restore all deleted rows + for (rowIndex, _) in rows.reversed() { + undoRowDeletion(rowIndex: rowIndex) + changedRowIndices.insert(rowIndex) + } + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil + ) + } else { + // Redo: re-delete all rows + for (rowIndex, originalRow) in rows { + redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) + changedRowIndices.insert(rowIndex) + } + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } - switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): - recordCellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: previousValue, - newValue: newValue - ) - _ = undoManager.popUndo() // Remove extra undo - changedRowIndices.insert(rowIndex) - reloadVersion += 1 - return (action, false, false) + case .batchRowInsertion(let rowIndices, let rowValues): + undoManager.registerUndo(withTarget: self) { target in + target.applyDataUndo(.batchRowInsertion(rowIndices: rowIndices, rowValues: rowValues)) + } + undoManager.setActionName(String(localized: "Insert Rows")) + + let firstInserted = rowIndices.first.map { insertedRowIndices.contains($0) } ?? false + if firstInserted { + // Undo: remove the inserted rows + for rowIndex in rowIndices { + removeChangeByKey(rowIndex: rowIndex, type: .insert) + insertedRowIndices.remove(rowIndex) + changedRowIndices.insert(rowIndex) + } + lastUndoResult = UndoResult( + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + ) + } else { + // Redo: re-insert the rows + for (index, rowIndex) in rowIndices.enumerated().reversed() { + guard index < rowValues.count else { continue } + let values = rowValues[index] - case .rowInsertion(let rowIndex): - insertedRowIndices.insert(rowIndex) - let cellChanges = columns.enumerated().map { index, columnName in - CellChange( - rowIndex: rowIndex, - columnIndex: index, - columnName: columnName, - oldValue: nil, - newValue: nil + 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) + } + + rebuildChangeIndex() + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil ) } - let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) - changes.append(rowChange) - changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 - hasChanges = true - changedRowIndices.insert(rowIndex) - reloadVersion += 1 - return (action, true, false) + } - case .rowDeletion(let rowIndex, let originalRow): - recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) - _ = undoManager.popUndo() - changedRowIndices.insert(rowIndex) - return (action, false, true) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } - case .batchRowDeletion(let rows): - for (rowIndex, originalRow) in rows { - recordRowDeletion(rowIndex: rowIndex, originalRow: originalRow) - _ = undoManager.popUndo() - changedRowIndices.insert(rowIndex) + /// Re-apply a cell edit during redo without registering a duplicate undo + private func recordCellChangeForRedo( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String? + ) { + let cellChange = CellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue + ) + + let insertKey = RowChangeKey(rowIndex: rowIndex, type: .insert) + if let insertIndex = changeIndex[insertKey] { + if var storedValues = insertedRowData[rowIndex] { + if columnIndex < storedValues.count { + storedValues[columnIndex] = newValue + insertedRowData[rowIndex] = storedValues + } } - return (action, false, true) + 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 + } - case .batchRowInsertion(let rowIndices, _): - for rowIndex in rowIndices { - removeChangeByKey(rowIndex: rowIndex, type: .insert) - insertedRowIndices.remove(rowIndex) - changedRowIndices.insert(rowIndex) + let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + if let existingIndex = changeIndex[updateKey] { + if let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { + $0.columnIndex == columnIndex + }) { + let originalOldValue = changes[existingIndex].cellChanges[cellIndex].oldValue + changes[existingIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: originalOldValue, newValue: newValue + ) + } else { + changes[existingIndex].cellChanges.append(cellChange) + modifiedCells[rowIndex, default: []].insert(columnIndex) } - hasChanges = !changes.isEmpty - reloadVersion += 1 - return (action, true, false) + } else { + let rowChange = RowChange( + rowIndex: rowIndex, type: .update, cellChanges: [cellChange] + ) + changes.append(rowChange) + changeIndex[updateKey] = changes.count - 1 + modifiedCells[rowIndex, default: []].insert(columnIndex) } } + /// Re-apply a row deletion during redo without registering a duplicate undo + private func redoRowDeletion(rowIndex: Int, originalRow: [String?]) { + removeChangeByKey(rowIndex: rowIndex, type: .update) + modifiedCells.removeValue(forKey: rowIndex) + + let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow) + changes.append(rowChange) + changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 + deletedRowIndices.insert(rowIndex) + hasChanges = true + } + + // MARK: - Undo/Redo Public API + + func undoLastChange() -> UndoResult? { + guard undoManager.canUndo else { return nil } + lastUndoResult = nil + undoManager.undo() + return lastUndoResult + } + + func redoLastChange() -> UndoResult? { + guard undoManager.canRedo else { return nil } + lastUndoResult = nil + undoManager.redo() + return lastUndoResult + } + // MARK: - SQL Generation func generateSQL() throws -> [ParameterizedStatement] { @@ -654,15 +745,12 @@ final class DataChangeManager { ) } - /// Unified statement generation for both data grid and sidebar edits. - /// Routes through plugin driver for NoSQL databases, falls back to SQLStatementGenerator for SQL. func generateSQL( for changes: [RowChange], insertedRowData: [Int: [String?]] = [:], deletedRowIndices: Set = [], insertedRowIndices: Set = [] ) throws -> [ParameterizedStatement] { - // Try plugin dispatch first (handles MongoDB, Redis, etcd, and future NoSQL plugins) if let pluginDriver { let pluginChanges = changes.map { change -> PluginRowChange in PluginRowChange( @@ -692,7 +780,6 @@ final class DataChangeManager { } } - // Safety: prevent SQL generation for NoSQL databases if plugin driver is unavailable if PluginManager.shared.editorLanguage(for: databaseType) != .sql { throw DatabaseError.queryFailed( "Cannot generate statements for \(databaseType.rawValue) — plugin driver not initialized" @@ -724,8 +811,6 @@ final class DataChangeManager { ) } - // Validate DELETE coverage: batch DELETE produces 1 statement for N rows when PK exists, - // so count statements != count rows. Instead check that all deletable rows got coverage. let deletableChanges = changes.filter { $0.type == .delete && deletedRowIndices.contains($0.rowIndex) } let deletableWithOriginalRow = deletableChanges.filter { $0.originalRow != nil } diff --git a/TablePro/Core/ChangeTracking/DataChangeUndoManager.swift b/TablePro/Core/ChangeTracking/DataChangeUndoManager.swift deleted file mode 100644 index e473cda9d..000000000 --- a/TablePro/Core/ChangeTracking/DataChangeUndoManager.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// DataChangeUndoManager.swift -// TablePro -// -// Manages undo/redo stacks for data changes. -// Extracted from DataChangeManager to improve separation of concerns. -// - -import Foundation - -/// Manages undo/redo stacks for data changes -final class DataChangeUndoManager { - /// Maximum number of undo/redo actions to retain in memory - private let maxUndoDepth = 100 - - /// Undo stack for reversing changes (LIFO) - private var undoStack: [UndoAction] = [] - - /// Redo stack for re-applying undone changes (LIFO) - private var redoStack: [UndoAction] = [] - - // MARK: - Public API - - /// Check if there are any undo actions available - var canUndo: Bool { - !undoStack.isEmpty - } - - /// Check if there are any redo actions available - var canRedo: Bool { - !redoStack.isEmpty - } - - /// Push an undo action onto the stack - /// Clears the redo stack since new changes invalidate redo history - func push(_ action: UndoAction) { - undoStack.append(action) - trimStack(&undoStack) - // Don't clear redo here - let caller decide when to clear - } - - /// Pop the last undo action from the stack - func popUndo() -> UndoAction? { - undoStack.popLast() - } - - /// Pop the last redo action from the stack - func popRedo() -> UndoAction? { - redoStack.popLast() - } - - /// Move an action from undo to redo stack - func moveToRedo(_ action: UndoAction) { - redoStack.append(action) - trimStack(&redoStack) - } - - /// Move an action from redo to undo stack - func moveToUndo(_ action: UndoAction) { - undoStack.append(action) - trimStack(&undoStack) - } - - /// Clear the undo stack - func clearUndo() { - undoStack.removeAll() - } - - /// Clear the redo stack (called when new changes are made) - func clearRedo() { - redoStack.removeAll() - } - - /// Clear both stacks - func clearAll() { - undoStack.removeAll() - redoStack.removeAll() - } - - /// Get the count of undo actions - var undoCount: Int { - undoStack.count - } - - /// Get the count of redo actions - var redoCount: Int { - redoStack.count - } - - // MARK: - Private Helpers - - /// Trim a stack to the maximum allowed depth, removing oldest entries first - private func trimStack(_ stack: inout [UndoAction]) { - if stack.count > maxUndoDepth { - stack.removeFirst(stack.count - maxUndoDepth) - } - } -} diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 4a9784f7e..0512f9ed4 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -160,36 +160,7 @@ final class RowOperationsManager { /// - Returns: Updated selection indices func undoLastChange(resultRows: inout [[String?]]) -> Set? { guard let result = changeManager.undoLastChange() else { return nil } - - var adjustedSelection: Set? - - switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _): - if rowIndex < resultRows.count { - resultRows[rowIndex][columnIndex] = previousValue - } - - case .rowInsertion(let rowIndex): - if rowIndex < resultRows.count { - resultRows.remove(at: rowIndex) - adjustedSelection = Set() - } - - case .rowDeletion: - break - - case .batchRowDeletion: - break - - case .batchRowInsertion(let rowIndices, let rowValues): - for (index, rowIndex) in rowIndices.enumerated().reversed() { - guard index < rowValues.count else { continue } - guard rowIndex <= resultRows.count else { continue } - resultRows.insert(rowValues[index], at: rowIndex) - } - } - - return adjustedSelection + return applyUndoResult(result, resultRows: &resultRows) } /// Redo the last undone change @@ -199,17 +170,27 @@ final class RowOperationsManager { /// - Returns: Updated selection indices func redoLastChange(resultRows: inout [[String?]], columns: [String]) -> Set? { guard let result = changeManager.redoLastChange() else { return nil } + return applyUndoResult(result, resultRows: &resultRows) + } + private func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? { switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, _, let newValue): + case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _): if rowIndex < resultRows.count { - resultRows[rowIndex][columnIndex] = newValue + resultRows[rowIndex][columnIndex] = previousValue } case .rowInsertion(let rowIndex): - let newValues = [String?](repeating: nil, count: columns.count) - if rowIndex <= resultRows.count { - resultRows.insert(newValues, at: rowIndex) + if result.needsRowRemoval { + if rowIndex < resultRows.count { + resultRows.remove(at: rowIndex) + return Set() + } + } else if result.needsRowRestore { + let values = result.restoreRow ?? [String?](repeating: nil, count: resultRows.first?.count ?? 0) + if rowIndex <= resultRows.count { + resultRows.insert(values, at: rowIndex) + } } case .rowDeletion: @@ -218,10 +199,18 @@ final class RowOperationsManager { case .batchRowDeletion: break - case .batchRowInsertion(let rowIndices, _): - for rowIndex in rowIndices.sorted(by: >) { - guard rowIndex < resultRows.count else { continue } - resultRows.remove(at: rowIndex) + case .batchRowInsertion(let rowIndices, let rowValues): + if result.needsRowRemoval { + for rowIndex in rowIndices.sorted(by: >) { + guard rowIndex < resultRows.count else { continue } + resultRows.remove(at: rowIndex) + } + } else if result.needsRowRestore { + for (index, rowIndex) in rowIndices.enumerated().reversed() { + guard index < rowValues.count else { continue } + guard rowIndex <= resultRows.count else { continue } + resultRows.insert(rowValues[index], at: rowIndex) + } } } diff --git a/TableProTests/Core/ChangeTracking/DataChangeUndoManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeUndoManagerTests.swift deleted file mode 100644 index 97ce59bca..000000000 --- a/TableProTests/Core/ChangeTracking/DataChangeUndoManagerTests.swift +++ /dev/null @@ -1,250 +0,0 @@ -// -// DataChangeUndoManagerTests.swift -// TableProTests -// -// Tests for DataChangeUndoManager -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("Data Change Undo Manager") -struct DataChangeUndoManagerTests { - private func makeCellEditAction(row: Int = 0, col: Int = 0) -> UndoAction { - .cellEdit(rowIndex: row, columnIndex: col, columnName: "col\(col)", previousValue: "old", newValue: "new") - } - - // MARK: - Initial State Tests - - @Test("Fresh instance has canUndo == false") - func initialCanUndoFalse() { - let manager = DataChangeUndoManager() - #expect(manager.canUndo == false) - } - - @Test("Fresh instance has canRedo == false") - func initialCanRedoFalse() { - let manager = DataChangeUndoManager() - #expect(manager.canRedo == false) - } - - @Test("Fresh instance has undoCount == 0") - func initialUndoCountZero() { - let manager = DataChangeUndoManager() - #expect(manager.undoCount == 0) - } - - @Test("Fresh instance has redoCount == 0") - func initialRedoCountZero() { - let manager = DataChangeUndoManager() - #expect(manager.redoCount == 0) - } - - // MARK: - Push Tests - - @Test("Push adds action to undo stack") - func pushAddsToUndoStack() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - #expect(manager.canUndo == true) - #expect(manager.undoCount == 1) - } - - // MARK: - Pop Tests - - @Test("Pop undo returns last pushed action (LIFO)") - func popUndoReturnsLastPushedAction() { - let manager = DataChangeUndoManager() - let actionA = makeCellEditAction(row: 0) - let actionB = makeCellEditAction(row: 1) - manager.push(actionA) - manager.push(actionB) - - let first = manager.popUndo() - if case .cellEdit(let rowIndex, _, _, _, _) = first { - #expect(rowIndex == 1) - } else { - Issue.record("Expected cellEdit action") - } - - let second = manager.popUndo() - if case .cellEdit(let rowIndex, _, _, _, _) = second { - #expect(rowIndex == 0) - } else { - Issue.record("Expected cellEdit action") - } - } - - @Test("Pop undo returns nil when stack is empty") - func popUndoReturnsNilWhenEmpty() { - let manager = DataChangeUndoManager() - #expect(manager.popUndo() == nil) - } - - @Test("Pop redo returns nil when stack is empty") - func popRedoReturnsNilWhenEmpty() { - let manager = DataChangeUndoManager() - #expect(manager.popRedo() == nil) - } - - // MARK: - Move Tests - - @Test("moveToRedo adds action to redo stack") - func moveToRedoAddsToRedoStack() { - let manager = DataChangeUndoManager() - manager.moveToRedo(makeCellEditAction()) - #expect(manager.canRedo == true) - #expect(manager.redoCount == 1) - } - - @Test("moveToUndo adds action to undo stack") - func moveToUndoAddsToUndoStack() { - let manager = DataChangeUndoManager() - manager.moveToUndo(makeCellEditAction()) - #expect(manager.canUndo == true) - #expect(manager.undoCount == 1) - } - - // MARK: - Clear Tests - - @Test("clearUndo empties undo stack only, preserves redo") - func clearUndoEmptiesUndoOnly() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - manager.moveToRedo(makeCellEditAction(row: 1)) - - manager.clearUndo() - - #expect(manager.canUndo == false) - #expect(manager.canRedo == true) - } - - @Test("clearRedo empties redo stack only, preserves undo") - func clearRedoEmptiesRedoOnly() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - manager.moveToRedo(makeCellEditAction(row: 1)) - - manager.clearRedo() - - #expect(manager.canUndo == true) - #expect(manager.canRedo == false) - } - - @Test("clearAll empties both stacks") - func clearAllEmptiesBoth() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction()) - manager.moveToRedo(makeCellEditAction(row: 1)) - - manager.clearAll() - - #expect(manager.undoCount == 0) - #expect(manager.redoCount == 0) - } - - // MARK: - Trimming Tests - - @Test("Undo stack trims to 100 when 101 actions pushed") - func stackTrimmingAt101Pushes() { - let manager = DataChangeUndoManager() - for i in 0 ..< 101 { - manager.push(makeCellEditAction(row: i)) - } - #expect(manager.undoCount == 100) - } - - @Test("Redo stack also trims at max depth") - func redoStackAlsoTrimsAtMaxDepth() { - let manager = DataChangeUndoManager() - for i in 0 ..< 101 { - manager.moveToRedo(makeCellEditAction(row: i)) - } - #expect(manager.redoCount == 100) - } - - // MARK: - Order & Fidelity Tests - - @Test("LIFO order is preserved across multiple pops") - func lifoOrderPreserved() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction(row: 0)) - manager.push(makeCellEditAction(row: 1)) - manager.push(makeCellEditAction(row: 2)) - - if case .cellEdit(let row, _, _, _, _) = manager.popUndo() { - #expect(row == 2) - } else { - Issue.record("Expected cellEdit action") - } - - if case .cellEdit(let row, _, _, _, _) = manager.popUndo() { - #expect(row == 1) - } else { - Issue.record("Expected cellEdit action") - } - - if case .cellEdit(let row, _, _, _, _) = manager.popUndo() { - #expect(row == 0) - } else { - Issue.record("Expected cellEdit action") - } - } - - @Test("moveToRedo preserves action fidelity through round-trip") - func moveToRedoPreservesActionFidelity() { - let manager = DataChangeUndoManager() - let action = UndoAction.cellEdit( - rowIndex: 5, - columnIndex: 3, - columnName: "email", - previousValue: "old@test.com", - newValue: "new@test.com" - ) - - manager.push(action) - guard let popped = manager.popUndo() else { - Issue.record("Expected non-nil undo action") - return - } - - manager.moveToRedo(popped) - let restored = manager.popRedo() - - if case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue) = restored { - #expect(rowIndex == 5) - #expect(columnIndex == 3) - #expect(columnName == "email") - #expect(previousValue == "old@test.com") - #expect(newValue == "new@test.com") - } else { - Issue.record("Expected cellEdit action") - } - } - - // MARK: - Mixed Operations Test - - @Test("Mixed operations maintain correct counts") - func mixedOperationsMaintainCorrectCounts() { - let manager = DataChangeUndoManager() - manager.push(makeCellEditAction(row: 0)) - manager.push(makeCellEditAction(row: 1)) - manager.push(makeCellEditAction(row: 2)) - - guard let action1 = manager.popUndo() else { - Issue.record("Expected non-nil undo action") - return - } - manager.moveToRedo(action1) - - guard let action2 = manager.popUndo() else { - Issue.record("Expected non-nil undo action") - return - } - manager.moveToRedo(action2) - - #expect(manager.undoCount == 1) - #expect(manager.redoCount == 2) - } -} From 7110b84ce2c7834c1747f521979642b0a67e629f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 14:52:39 +0700 Subject: [PATCH 03/11] docs: update CHANGELOG for NSUndoManager migration --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7f00176..a9a049b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extract reusable SearchFieldView component from 4 custom search field implementations - Replace custom resize handle with native NSSplitView for inspector panel +### Changed + +- Migrate undo system from custom stacks to NSUndoManager — Edit menu now shows "Undo Edit Cell", "Undo Delete Row", etc. + ### Added - iOS: connection groups and tags From b396dfe2402d708a55df215689074b5d30853ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 14:58:59 +0700 Subject: [PATCH 04/11] fix: address review issues in NSUndoManager migration - Fix primaryKeyChange undo closure capturing stale workingPrimaryKey - Fix batch insertion undo leaking insertedRowData entries - Clear undo history in loadSchema to prevent stale actions on table switch - Make undoManager private to prevent bypass of lastUndoResult contract --- TablePro/Core/ChangeTracking/DataChangeManager.swift | 3 ++- TablePro/Core/SchemaTracking/StructureChangeManager.swift | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 4a4b53657..692b841d3 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -98,7 +98,7 @@ final class DataChangeManager { return lo } - let undoManager: UndoManager = { + private let undoManager: UndoManager = { let manager = UndoManager() manager.levelsOfUndo = 100 manager.groupsByEvent = false @@ -606,6 +606,7 @@ final class DataChangeManager { for rowIndex in rowIndices { removeChangeByKey(rowIndex: rowIndex, type: .insert) insertedRowIndices.remove(rowIndex) + insertedRowData.removeValue(forKey: rowIndex) changedRowIndices.insert(rowIndex) } lastUndoResult = UndoResult( diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 635c5e419..f49026f83 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -96,9 +96,10 @@ final class StructureChangeManager { // Reset working state resetWorkingState() - // Clear changes + // Clear changes and undo history pendingChanges.removeAll() validationErrors.removeAll() + undoManager.removeAllActions() hasChanges = false // Increment reloadVersion to trigger DataGridView column width recalculation @@ -673,8 +674,9 @@ final class StructureChangeManager { } case .primaryKeyChange(let old, _): + let current = workingPrimaryKey undoManager.registerUndo(withTarget: self) { target in - target.applySchemaUndo(.primaryKeyChange(old: self.workingPrimaryKey, new: old)) + target.applySchemaUndo(.primaryKeyChange(old: current, new: old)) } undoManager.setActionName(String(localized: "Change Primary Key")) workingPrimaryKey = old From 78b28773bb8ec9854ca0f9bc1e0d6a40335080f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 15:02:32 +0700 Subject: [PATCH 05/11] =?UTF-8?q?revert:=20restore=20PanelResizeHandle=20?= =?UTF-8?q?=E2=80=94=20HorizontalSplitView=20causes=20exclusivity=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSHostingView wrapping MainContentView creates a separate SwiftUI evaluation context. When updateNSView sets rootView, the inner hosting view re-evaluates MainContentView.body, which re-entrantly accesses @Observable properties (QueryTabManager.tabs) already being read by the outer tree — triggering a Swift exclusivity violation crash. The PanelResizeHandle approach keeps MainContentView in the same SwiftUI tree, avoiding re-entrant access. --- TablePro/ContentView.swift | 36 +++-- .../Components/HorizontalSplitView.swift | 143 ------------------ .../Views/Components/PanelResizeHandle.swift | 40 +++++ 3 files changed, 61 insertions(+), 158 deletions(-) delete mode 100644 TablePro/Views/Components/HorizontalSplitView.swift create mode 100644 TablePro/Views/Components/PanelResizeHandle.swift diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index becd20620..ac8772991 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -226,13 +226,7 @@ struct ContentView: View { } detail: { // MARK: - Detail (Main workspace with optional right sidebar) if let currentSession = currentSession, let rightPanelState, let sessionState { - HorizontalSplitView( - isTrailingCollapsed: !rightPanelState.isPresented, - trailingWidth: Bindable(rightPanelState).panelWidth, - minTrailingWidth: RightPanelState.minWidth, - maxTrailingWidth: RightPanelState.maxWidth, - autosaveName: "InspectorSplit" - ) { + HStack(spacing: 0) { MainContentView( connection: currentSession.connection, payload: payload, @@ -250,15 +244,23 @@ struct ContentView: View { toolbarState: sessionState.toolbarState, coordinator: sessionState.coordinator ) - } trailing: { - UnifiedRightPanelView( - state: rightPanelState, - inspectorContext: inspectorContext, - connection: currentSession.connection, - tables: currentSession.tables - ) - .background(Color(nsColor: .windowBackgroundColor)) + .frame(maxWidth: .infinity) + + if rightPanelState.isPresented { + PanelResizeHandle(panelWidth: Bindable(rightPanelState).panelWidth) + Divider() + UnifiedRightPanelView( + state: rightPanelState, + inspectorContext: inspectorContext, + connection: currentSession.connection, + tables: currentSession.tables + ) + .frame(width: rightPanelState.panelWidth) + .background(Color(nsColor: .windowBackgroundColor)) + .transition(.move(edge: .trailing)) + } } + .animation(.easeInOut(duration: 0.2), value: rightPanelState.isPresented) } else { VStack(spacing: 16) { ProgressView() @@ -429,6 +431,10 @@ struct ContentView: View { } } + private func saveCurrentSessionState() { + // State is automatically saved through bindings + } + // MARK: - Persistence private func deleteConnection(_ connection: DatabaseConnection) { diff --git a/TablePro/Views/Components/HorizontalSplitView.swift b/TablePro/Views/Components/HorizontalSplitView.swift deleted file mode 100644 index 2217fdada..000000000 --- a/TablePro/Views/Components/HorizontalSplitView.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// HorizontalSplitView.swift -// TablePro -// - -import AppKit -import SwiftUI - -struct HorizontalSplitView: NSViewRepresentable { - var isTrailingCollapsed: Bool - @Binding var trailingWidth: CGFloat - var minTrailingWidth: CGFloat - var maxTrailingWidth: CGFloat - var autosaveName: String - @ViewBuilder var leading: Leading - @ViewBuilder var trailing: Trailing - - func makeCoordinator() -> Coordinator { - Coordinator(trailingWidth: $trailingWidth) - } - - func makeNSView(context: Context) -> NSSplitView { - let splitView = NSSplitView() - splitView.isVertical = true - splitView.dividerStyle = .thin - splitView.autosaveName = autosaveName - splitView.delegate = context.coordinator - - let leadingHosting = NSHostingView(rootView: leading) - leadingHosting.sizingOptions = [.minSize] - - let trailingHosting = NSHostingView(rootView: trailing) - trailingHosting.sizingOptions = [.minSize] - - splitView.addArrangedSubview(leadingHosting) - splitView.addArrangedSubview(trailingHosting) - - context.coordinator.leadingHosting = leadingHosting - context.coordinator.trailingHosting = trailingHosting - context.coordinator.lastCollapsedState = isTrailingCollapsed - context.coordinator.minWidth = minTrailingWidth - context.coordinator.maxWidth = maxTrailingWidth - - if isTrailingCollapsed { - trailingHosting.isHidden = true - } - - return splitView - } - - func updateNSView(_ splitView: NSSplitView, context: Context) { - context.coordinator.leadingHosting?.rootView = leading - context.coordinator.trailingHosting?.rootView = trailing - context.coordinator.trailingWidth = $trailingWidth - context.coordinator.minWidth = minTrailingWidth - context.coordinator.maxWidth = maxTrailingWidth - - guard let trailingView = context.coordinator.trailingHosting else { return } - let wasCollapsed = context.coordinator.lastCollapsedState - - if isTrailingCollapsed != wasCollapsed { - context.coordinator.lastCollapsedState = isTrailingCollapsed - if isTrailingCollapsed { - if splitView.subviews.count >= 2 { - context.coordinator.savedDividerPosition = splitView.subviews[1].frame.width - } - splitView.setPosition(splitView.bounds.width, ofDividerAt: 0) - trailingView.isHidden = true - } else { - trailingView.isHidden = false - let targetWidth = context.coordinator.savedDividerPosition ?? trailingWidth - splitView.adjustSubviews() - splitView.setPosition(splitView.bounds.width - targetWidth, ofDividerAt: 0) - } - } - } - - final class Coordinator: NSObject, NSSplitViewDelegate { - var leadingHosting: NSHostingView? - var trailingHosting: NSHostingView? - var lastCollapsedState = false - var savedDividerPosition: CGFloat? - var minWidth: CGFloat = 0 - var maxWidth: CGFloat = 0 - var trailingWidth: Binding - - init(trailingWidth: Binding) { - self.trailingWidth = trailingWidth - } - - func splitView( - _ splitView: NSSplitView, - constrainMinCoordinate proposedMinimumPosition: CGFloat, - ofSubviewAt dividerIndex: Int - ) -> CGFloat { - splitView.bounds.width - maxWidth - } - - func splitView( - _ splitView: NSSplitView, - constrainMaxCoordinate proposedMaximumPosition: CGFloat, - ofSubviewAt dividerIndex: Int - ) -> CGFloat { - splitView.bounds.width - minWidth - } - - func splitView( - _ splitView: NSSplitView, - canCollapseSubview subview: NSView - ) -> Bool { - subview == trailingHosting - } - - func splitView( - _ splitView: NSSplitView, - effectiveRect proposedEffectiveRect: NSRect, - forDrawnRect drawnRect: NSRect, - ofDividerAt dividerIndex: Int - ) -> NSRect { - if trailingHosting?.isHidden == true { - return .zero - } - return proposedEffectiveRect - } - - func splitView( - _ splitView: NSSplitView, - shouldHideDividerAt dividerIndex: Int - ) -> Bool { - trailingHosting?.isHidden == true - } - - func splitViewDidResizeSubviews(_ notification: Notification) { - guard let splitView = notification.object as? NSSplitView, - splitView.subviews.count >= 2, - trailingHosting?.isHidden != true - else { return } - let width = splitView.subviews[1].frame.width - guard width > 0, abs(width - trailingWidth.wrappedValue) > 0.5 else { return } - trailingWidth.wrappedValue = width - } - } -} diff --git a/TablePro/Views/Components/PanelResizeHandle.swift b/TablePro/Views/Components/PanelResizeHandle.swift new file mode 100644 index 000000000..6f8531c6b --- /dev/null +++ b/TablePro/Views/Components/PanelResizeHandle.swift @@ -0,0 +1,40 @@ +// +// PanelResizeHandle.swift +// TablePro +// +// Draggable resize handle for the right panel. +// + +import SwiftUI + +struct PanelResizeHandle: View { + @Binding var panelWidth: CGFloat + + @State private var isDragging = false + + var body: some View { + Rectangle() + .fill(Color.clear) + .frame(width: 5) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + isDragging = true + // Dragging left increases panel width (handle is on the leading edge) + let newWidth = panelWidth - value.translation.width + panelWidth = min(max(newWidth, RightPanelState.minWidth), RightPanelState.maxWidth) + } + .onEnded { _ in + isDragging = false + } + ) + } +} From 4a752bd9bd090bf113a3cd62fefc341743a27023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 15:03:12 +0700 Subject: [PATCH 06/11] docs: remove reverted NSSplitView entry from CHANGELOG --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a049b95..af0222885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use sheet presentation for all file open/save panels instead of free-floating dialogs - Replace event monitor with native SwiftUI .onKeyPress() in connection switcher - Extract reusable SearchFieldView component from 4 custom search field implementations -- Replace custom resize handle with native NSSplitView for inspector panel ### Changed From e1687aaad383160794ba8acb84323fd227f1b643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 15:07:34 +0700 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20remove=20groupsByEvent=3Dfalse=20?= =?UTF-8?q?=E2=80=94=20causes=20registerUndo=20to=20silently=20fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With groupsByEvent=false, NSUndoManager requires explicit beginUndoGrouping() before registerUndo(). Without it, registerUndo throws an exception that AppKit catches silently, causing all undo registration to be no-ops. This broke cell edit commit — changes were recorded in the tracking arrays but never registered with the undo manager. The default groupsByEvent=true auto-creates groups per run loop pass, which is correct for individual cell edits. --- TablePro/Core/ChangeTracking/DataChangeManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 692b841d3..4de37020a 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -101,7 +101,6 @@ final class DataChangeManager { private let undoManager: UndoManager = { let manager = UndoManager() manager.levelsOfUndo = 100 - manager.groupsByEvent = false return manager }() From b01f41ed47fc7795f5c0706164cca11a95eb3374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 15:13:22 +0700 Subject: [PATCH 08/11] fix: preserve insertedRowData across undo/redo cycles for row insertions - rowInsertion: capture savedValues BEFORE undoRowInsertion clears them, register reverse AFTER redo repopulates insertedRowData - batchRowInsertion redo: restore insertedRowData for each re-inserted row so SQL generation produces correct INSERT values --- .../ChangeTracking/DataChangeManager.swift | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 4de37020a..c259c2db6 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -505,23 +505,24 @@ final class DataChangeManager { 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 - // Restore saved values so the reverse operation can access them - if let savedValues { - target.insertedRowData[rowIndex] = savedValues - } - target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) - } - undoManager.setActionName(String(localized: "Insert Row")) - if insertedRowIndices.contains(rowIndex) { + // Undo: capture values BEFORE undoRowInsertion clears them + 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")) undoRowInsertion(rowIndex: rowIndex) changedRowIndices.insert(rowIndex) lastUndoResult = UndoResult( action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil ) } else { + // Redo: re-insert the row, then register reverse + let savedValues = insertedRowData[rowIndex] insertedRowIndices.insert(rowIndex) let cellChanges = columns.enumerated().map { index, columnName in CellChange( @@ -538,6 +539,15 @@ final class DataChangeManager { if let savedValues { insertedRowData[rowIndex] = savedValues } + // Register reverse AFTER insertedRowData is populated + let valuesToCapture = insertedRowData[rowIndex] + undoManager.registerUndo(withTarget: self) { [valuesToCapture] target in + if let valuesToCapture { + target.insertedRowData[rowIndex] = valuesToCapture + } + target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) + } + undoManager.setActionName(String(localized: "Insert Row")) changedRowIndices.insert(rowIndex) lastUndoResult = UndoResult( action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues @@ -629,6 +639,7 @@ final class DataChangeManager { let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges) changes.append(rowChange) insertedRowIndices.insert(rowIndex) + insertedRowData[rowIndex] = values } rebuildChangeIndex() From 46a64a5223fed26c7afb9f8e075d713f213b748a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 15:26:47 +0700 Subject: [PATCH 09/11] fix: resolve 8 undo/redo edge-case bugs found in deep review StructureChangeManager: - S1: Distinguish undo-of-new-add vs redo-of-existing-delete in columnAdd/Delete, indexAdd/Delete, foreignKeyAdd/Delete by checking currentColumns/Indexes/FKs - S2: Update pendingChanges for newly-added items on undo of edit (add else branches) - S3: Call validate() after every undo/redo to keep validationErrors fresh DataChangeManager: - D1: Populate insertedRowData in batchRowInsertion redo path - D2: Shift existing row indices up when redoing row insertion (new shiftRowIndicesUp helper) - D3: Shift insertedRowData and modifiedCells keys in undoRowInsertion - D4: Use rows.contains instead of rows.first for batch deletion undo/redo detection - D5: Set groupsByEvent=false with explicit beginUndoGrouping/endUndoGrouping via registerUndoAction helper to prevent run-loop auto-grouping --- .../ChangeTracking/DataChangeManager.swift | 129 ++++++++++++------ .../StructureChangeManager.swift | 37 +++-- 2 files changed, 117 insertions(+), 49 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index c259c2db6..320810f6f 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -101,9 +101,16 @@ final class DataChangeManager { private let undoManager: UndoManager = { let manager = UndoManager() manager.levelsOfUndo = 100 + manager.groupsByEvent = false return manager }() + private func registerUndoAction(_ handler: @escaping (DataChangeManager) -> Void) { + undoManager.beginUndoGrouping() + undoManager.registerUndo(withTarget: self, handler: handler) + undoManager.endUndoGrouping() + } + private var lastUndoResult: UndoResult? // MARK: - Undo/Redo Properties @@ -229,7 +236,7 @@ final class DataChangeManager { newValue: newValue )) } - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue @@ -284,7 +291,7 @@ final class DataChangeManager { changedRowIndices.insert(rowIndex) } - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue @@ -304,7 +311,7 @@ final class DataChangeManager { changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 deletedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } undoManager.setActionName(String(localized: "Delete Row")) @@ -333,7 +340,7 @@ final class DataChangeManager { changedRowIndices.insert(rowIndex) batchData.append((rowIndex: rowIndex, originalRow: originalRow)) } - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.batchRowDeletion(rows: batchData)) } undoManager.setActionName(String(localized: "Delete Rows")) @@ -348,7 +355,7 @@ final class DataChangeManager { changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 insertedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) } undoManager.setActionName(String(localized: "Insert Row")) @@ -385,10 +392,70 @@ final class DataChangeManager { } } + var newInsertedRowData: [Int: [String?]] = [:] + for (key, value) in insertedRowData { + if key > rowIndex { + newInsertedRowData[key - 1] = value + } else { + newInsertedRowData[key] = value + } + } + insertedRowData = newInsertedRowData + + var newModifiedCells: [Int: Set] = [:] + for (key, value) in modifiedCells { + if key > rowIndex { + newModifiedCells[key - 1] = value + } else if key < rowIndex { + newModifiedCells[key] = value + } + } + modifiedCells = newModifiedCells + rebuildChangeIndex() hasChanges = !changes.isEmpty } + private func shiftRowIndicesUp(from insertionPoint: Int) { + for i in 0..= insertionPoint { + changes[i].rowIndex += 1 + } + } + + var shiftedInserted = Set() + for idx in insertedRowIndices { + shiftedInserted.insert(idx >= insertionPoint ? idx + 1 : idx) + } + insertedRowIndices = shiftedInserted + + var shiftedDeleted = Set() + for idx in deletedRowIndices { + shiftedDeleted.insert(idx >= insertionPoint ? idx + 1 : idx) + } + deletedRowIndices = shiftedDeleted + + var newInsertedRowData: [Int: [String?]] = [:] + for (key, value) in insertedRowData { + newInsertedRowData[key >= insertionPoint ? key + 1 : key] = value + } + insertedRowData = newInsertedRowData + + var newModifiedCells: [Int: Set] = [:] + for (key, value) in modifiedCells { + newModifiedCells[key >= insertionPoint ? key + 1 : key] = value + } + modifiedCells = newModifiedCells + + var newChangedRows = Set() + for idx in changedRowIndices { + newChangedRows.insert(idx >= insertionPoint ? idx + 1 : idx) + } + changedRowIndices = newChangedRows + + rebuildChangeIndex() + } + func undoBatchRowInsertion(rowIndices: [Int]) { guard !rowIndices.isEmpty else { return } @@ -413,7 +480,7 @@ final class DataChangeManager { insertedRowData.removeValue(forKey: rowIndex) } - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) } undoManager.setActionName(String(localized: "Insert Rows")) @@ -443,7 +510,7 @@ final class DataChangeManager { 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 + registerUndoAction { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: newValue, newValue: previousValue @@ -494,8 +561,6 @@ final class DataChangeManager { } } } else { - // Redo path: no existing change record, re-apply the edit. - // Cell currently holds newValue, changing to previousValue. recordCellChangeForRedo( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, oldValue: newValue, newValue: previousValue @@ -505,24 +570,23 @@ final class DataChangeManager { lastUndoResult = UndoResult(action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil) case .rowInsertion(let rowIndex): - if insertedRowIndices.contains(rowIndex) { - // Undo: capture values BEFORE undoRowInsertion clears them - let savedValues = insertedRowData[rowIndex] - undoManager.registerUndo(withTarget: self) { [savedValues] target in - if let savedValues { - target.insertedRowData[rowIndex] = savedValues - } - target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) + let savedValues = insertedRowData[rowIndex] + registerUndoAction { [savedValues] target in + if let savedValues { + target.insertedRowData[rowIndex] = savedValues } - undoManager.setActionName(String(localized: "Insert Row")) + 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 { - // Redo: re-insert the row, then register reverse - let savedValues = insertedRowData[rowIndex] + shiftRowIndicesUp(from: rowIndex) insertedRowIndices.insert(rowIndex) let cellChanges = columns.enumerated().map { index, columnName in CellChange( @@ -539,15 +603,6 @@ final class DataChangeManager { if let savedValues { insertedRowData[rowIndex] = savedValues } - // Register reverse AFTER insertedRowData is populated - let valuesToCapture = insertedRowData[rowIndex] - undoManager.registerUndo(withTarget: self) { [valuesToCapture] target in - if let valuesToCapture { - target.insertedRowData[rowIndex] = valuesToCapture - } - target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) - } - undoManager.setActionName(String(localized: "Insert Row")) changedRowIndices.insert(rowIndex) lastUndoResult = UndoResult( action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues @@ -555,20 +610,18 @@ final class DataChangeManager { } case .rowDeletion(let rowIndex, let originalRow): - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } undoManager.setActionName(String(localized: "Delete Row")) if deletedRowIndices.contains(rowIndex) { - // Undo: restore the deleted row undoRowDeletion(rowIndex: rowIndex) changedRowIndices.insert(rowIndex) lastUndoResult = UndoResult( action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow ) } else { - // Redo: re-delete the row redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) changedRowIndices.insert(rowIndex) lastUndoResult = UndoResult( @@ -577,14 +630,13 @@ final class DataChangeManager { } case .batchRowDeletion(let rows): - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { target in target.applyDataUndo(.batchRowDeletion(rows: rows)) } undoManager.setActionName(String(localized: "Delete Rows")) - let firstRowDeleted = rows.first.map { deletedRowIndices.contains($0.rowIndex) } ?? false - if firstRowDeleted { - // Undo: restore all deleted rows + let isUndo = rows.contains { deletedRowIndices.contains($0.rowIndex) } + if isUndo { for (rowIndex, _) in rows.reversed() { undoRowDeletion(rowIndex: rowIndex) changedRowIndices.insert(rowIndex) @@ -593,7 +645,6 @@ final class DataChangeManager { action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil ) } else { - // Redo: re-delete all rows for (rowIndex, originalRow) in rows { redoRowDeletion(rowIndex: rowIndex, originalRow: originalRow) changedRowIndices.insert(rowIndex) @@ -604,14 +655,13 @@ final class DataChangeManager { } case .batchRowInsertion(let rowIndices, let rowValues): - undoManager.registerUndo(withTarget: self) { target in + registerUndoAction { 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 { - // Undo: remove the inserted rows for rowIndex in rowIndices { removeChangeByKey(rowIndex: rowIndex, type: .insert) insertedRowIndices.remove(rowIndex) @@ -622,7 +672,6 @@ final class DataChangeManager { action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil ) } else { - // Redo: re-insert the rows for (index, rowIndex) in rowIndices.enumerated().reversed() { guard index < rowValues.count else { continue } let values = rowValues[index] diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index f49026f83..96992caf7 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -576,6 +576,8 @@ final class StructureChangeManager { } else { pendingChanges.removeValue(forKey: .column(id)) } + } else { + pendingChanges[.column(id)] = .addColumn(old) } } @@ -584,15 +586,19 @@ final class StructureChangeManager { target.applySchemaUndo(.columnDelete(column: column)) } undoManager.setActionName(String(localized: "Add Column")) - workingColumns.removeAll { $0.id == column.id } - pendingChanges.removeValue(forKey: .column(column.id)) + if currentColumns.contains(where: { $0.id == column.id }) { + pendingChanges[.column(column.id)] = .deleteColumn(column) + } else { + workingColumns.removeAll { $0.id == column.id } + pendingChanges.removeValue(forKey: .column(column.id)) + } case .columnDelete(let column): undoManager.registerUndo(withTarget: self) { target in target.applySchemaUndo(.columnAdd(column: column)) } undoManager.setActionName(String(localized: "Delete Column")) - if workingColumns.contains(where: { $0.id == column.id }) { + if currentColumns.contains(where: { $0.id == column.id }) { pendingChanges.removeValue(forKey: .column(column.id)) } else { workingColumns.append(column) @@ -613,6 +619,8 @@ final class StructureChangeManager { } else { pendingChanges.removeValue(forKey: .index(id)) } + } else { + pendingChanges[.index(id)] = .addIndex(old) } } @@ -621,15 +629,19 @@ final class StructureChangeManager { target.applySchemaUndo(.indexDelete(index: index)) } undoManager.setActionName(String(localized: "Add Index")) - workingIndexes.removeAll { $0.id == index.id } - pendingChanges.removeValue(forKey: .index(index.id)) + if currentIndexes.contains(where: { $0.id == index.id }) { + pendingChanges[.index(index.id)] = .deleteIndex(index) + } else { + workingIndexes.removeAll { $0.id == index.id } + pendingChanges.removeValue(forKey: .index(index.id)) + } case .indexDelete(let index): undoManager.registerUndo(withTarget: self) { target in target.applySchemaUndo(.indexAdd(index: index)) } undoManager.setActionName(String(localized: "Delete Index")) - if workingIndexes.contains(where: { $0.id == index.id }) { + if currentIndexes.contains(where: { $0.id == index.id }) { pendingChanges.removeValue(forKey: .index(index.id)) } else { workingIndexes.append(index) @@ -650,6 +662,8 @@ final class StructureChangeManager { } else { pendingChanges.removeValue(forKey: .foreignKey(id)) } + } else { + pendingChanges[.foreignKey(id)] = .addForeignKey(old) } } @@ -658,15 +672,19 @@ final class StructureChangeManager { target.applySchemaUndo(.foreignKeyDelete(fk: fk)) } undoManager.setActionName(String(localized: "Add Foreign Key")) - workingForeignKeys.removeAll { $0.id == fk.id } - pendingChanges.removeValue(forKey: .foreignKey(fk.id)) + if currentForeignKeys.contains(where: { $0.id == fk.id }) { + pendingChanges[.foreignKey(fk.id)] = .deleteForeignKey(fk) + } else { + workingForeignKeys.removeAll { $0.id == fk.id } + pendingChanges.removeValue(forKey: .foreignKey(fk.id)) + } case .foreignKeyDelete(let fk): undoManager.registerUndo(withTarget: self) { target in target.applySchemaUndo(.foreignKeyAdd(fk: fk)) } undoManager.setActionName(String(localized: "Delete Foreign Key")) - if workingForeignKeys.contains(where: { $0.id == fk.id }) { + if currentForeignKeys.contains(where: { $0.id == fk.id }) { pendingChanges.removeValue(forKey: .foreignKey(fk.id)) } else { workingForeignKeys.append(fk) @@ -687,6 +705,7 @@ final class StructureChangeManager { } } + validate() hasChanges = !pendingChanges.isEmpty reloadVersion += 1 rebuildVisualStateCache() From 76bae917bc85ff4135be33061c9e6d42cfdde56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 15:30:37 +0700 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20revert=20groupsByEvent=3Dfalse=20?= =?UTF-8?q?=E2=80=94=20use=20default=20auto-grouping=20to=20avoid=20regist?= =?UTF-8?q?ration=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChangeTracking/DataChangeManager.swift | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 320810f6f..2f64b43bf 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -101,16 +101,9 @@ final class DataChangeManager { private let undoManager: UndoManager = { let manager = UndoManager() manager.levelsOfUndo = 100 - manager.groupsByEvent = false return manager }() - private func registerUndoAction(_ handler: @escaping (DataChangeManager) -> Void) { - undoManager.beginUndoGrouping() - undoManager.registerUndo(withTarget: self, handler: handler) - undoManager.endUndoGrouping() - } - private var lastUndoResult: UndoResult? // MARK: - Undo/Redo Properties @@ -236,7 +229,7 @@ final class DataChangeManager { newValue: newValue )) } - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue @@ -291,7 +284,7 @@ final class DataChangeManager { changedRowIndices.insert(rowIndex) } - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue @@ -311,7 +304,7 @@ final class DataChangeManager { changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 deletedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } undoManager.setActionName(String(localized: "Delete Row")) @@ -340,7 +333,7 @@ final class DataChangeManager { changedRowIndices.insert(rowIndex) batchData.append((rowIndex: rowIndex, originalRow: originalRow)) } - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.batchRowDeletion(rows: batchData)) } undoManager.setActionName(String(localized: "Delete Rows")) @@ -355,7 +348,7 @@ final class DataChangeManager { changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 insertedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) } undoManager.setActionName(String(localized: "Insert Row")) @@ -480,7 +473,7 @@ final class DataChangeManager { insertedRowData.removeValue(forKey: rowIndex) } - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) } undoManager.setActionName(String(localized: "Insert Rows")) @@ -510,7 +503,7 @@ final class DataChangeManager { private func applyDataUndo(_ action: UndoAction) { switch action { case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: newValue, newValue: previousValue @@ -571,7 +564,7 @@ final class DataChangeManager { case .rowInsertion(let rowIndex): let savedValues = insertedRowData[rowIndex] - registerUndoAction { [savedValues] target in + undoManager.registerUndo(withTarget: self) { [savedValues] target in if let savedValues { target.insertedRowData[rowIndex] = savedValues } @@ -610,7 +603,7 @@ final class DataChangeManager { } case .rowDeletion(let rowIndex, let originalRow): - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } undoManager.setActionName(String(localized: "Delete Row")) @@ -630,7 +623,7 @@ final class DataChangeManager { } case .batchRowDeletion(let rows): - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.batchRowDeletion(rows: rows)) } undoManager.setActionName(String(localized: "Delete Rows")) @@ -655,7 +648,7 @@ final class DataChangeManager { } case .batchRowInsertion(let rowIndices, let rowValues): - registerUndoAction { target in + undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.batchRowInsertion(rowIndices: rowIndices, rowValues: rowValues)) } undoManager.setActionName(String(localized: "Insert Rows")) From cd81f16fb6c089d9081d744f92d5692c1706f5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 6 Apr 2026 15:45:18 +0700 Subject: [PATCH 11/11] fix: add index shifting for batch row insertion redo, remove dead stub --- TablePro/ContentView.swift | 4 ---- TablePro/Core/ChangeTracking/DataChangeManager.swift | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index ac8772991..14195846c 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -431,10 +431,6 @@ struct ContentView: View { } } - private func saveCurrentSessionState() { - // State is automatically saved through bindings - } - // MARK: - Persistence private func deleteConnection(_ connection: DatabaseConnection) { diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 2f64b43bf..0ae7d0c32 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -665,6 +665,11 @@ final class DataChangeManager { 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]