diff --git a/CHANGELOG.md b/CHANGELOG.md index c913815e1..4a5411ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click - QueryTab decomposed into focused sub-types: TabExecutionState, TabTableContext, TabQueryContent, TabDisplayState - Inspector: dense field list, PK/FK key icons, Set NULL/DEFAULT in picker dropdowns, toolbar tracking separator diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index 7f2d0473e..cf8e8e2b4 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -1,102 +1,38 @@ -// -// AnyChangeManager.swift -// TablePro -// -// Type-erased wrapper for change managers (data and structure). -// Allows DataGridView to work with both DataChangeManager and StructureChangeManager. -// - import Foundation import Observation -/// Type-erased change manager wrapper +@MainActor +protocol ChangeManaging: AnyObject { + var hasChanges: Bool { get } + var reloadVersion: Int { get } + var canRedo: Bool { get } + var rowChanges: [RowChange] { get } + func isRowDeleted(_ rowIndex: Int) -> Bool + func recordCellChange( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String?, + originalRow: [String?]? + ) + func undoRowDeletion(rowIndex: Int) + func undoRowInsertion(rowIndex: Int) + func consumeChangedRowIndices() -> Set +} + @Observable @MainActor final class AnyChangeManager { - @ObservationIgnored private var dataManager: DataChangeManager? - @ObservationIgnored private var structureManager: StructureChangeManager? - - var hasChanges: Bool { - dataManager?.hasChanges ?? structureManager?.hasChanges ?? false - } - - var reloadVersion: Int { - dataManager?.reloadVersion ?? structureManager?.reloadVersion ?? 0 - } - - @ObservationIgnored private let _isRowDeleted: (Int) -> Bool - @ObservationIgnored private let _getChanges: () -> [Any] - @ObservationIgnored private let _canRedo: () -> Bool - @ObservationIgnored private let _recordCellChange: ((Int, Int, String, String?, String?, [String?]) -> Void)? - @ObservationIgnored private let _undoRowDeletion: ((Int) -> Void)? - @ObservationIgnored private let _undoRowInsertion: ((Int) -> Void)? - @ObservationIgnored private let _consumeChangedRowIndices: (() -> Set)? - - // MARK: - Initializers - - /// Wrap a DataChangeManager - init(dataManager: DataChangeManager) { - self.dataManager = dataManager - self._isRowDeleted = { rowIndex in - dataManager.isRowDeleted(rowIndex) - } - self._getChanges = { - dataManager.changes - } - self._canRedo = { - dataManager.canRedo - } - self._recordCellChange = { rowIndex, columnIndex, columnName, oldValue, newValue, originalRow in - dataManager.recordCellChange( - rowIndex: rowIndex, - columnIndex: columnIndex, - columnName: columnName, - oldValue: oldValue, - newValue: newValue, - originalRow: originalRow - ) - } - self._undoRowDeletion = { rowIndex in - dataManager.undoRowDeletion(rowIndex: rowIndex) - } - self._undoRowInsertion = { rowIndex in - dataManager.undoRowInsertion(rowIndex: rowIndex) - } - self._consumeChangedRowIndices = { - dataManager.consumeChangedRowIndices() - } - } - - /// Wrap a StructureChangeManager - init(structureManager: StructureChangeManager) { - self.structureManager = structureManager - self._isRowDeleted = { _ in false } // Structure doesn't track row deletions - self._getChanges = { - Array(structureManager.pendingChanges.values) - } - self._canRedo = { - structureManager.canRedo - } - self._recordCellChange = nil // Structure uses custom editing logic - self._undoRowDeletion = nil - self._undoRowInsertion = nil - self._consumeChangedRowIndices = { - structureManager.consumeChangedRowIndices() - } - } + @ObservationIgnored private let wrapped: any ChangeManaging - // MARK: - Public API - - var canRedo: Bool { - _canRedo() - } + var hasChanges: Bool { wrapped.hasChanges } + var reloadVersion: Int { wrapped.reloadVersion } + var canRedo: Bool { wrapped.canRedo } + var rowChanges: [RowChange] { wrapped.rowChanges } func isRowDeleted(_ rowIndex: Int) -> Bool { - _isRowDeleted(rowIndex) - } - - var changes: [Any] { - _getChanges() + wrapped.isRowDeleted(rowIndex) } func recordCellChange( @@ -107,18 +43,29 @@ final class AnyChangeManager { newValue: String?, originalRow: [String?] ) { - _recordCellChange?(rowIndex, columnIndex, columnName, oldValue, newValue, originalRow) + wrapped.recordCellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue, + originalRow: originalRow + ) } func undoRowDeletion(rowIndex: Int) { - _undoRowDeletion?(rowIndex) + wrapped.undoRowDeletion(rowIndex: rowIndex) } func undoRowInsertion(rowIndex: Int) { - _undoRowInsertion?(rowIndex) + wrapped.undoRowInsertion(rowIndex: rowIndex) } func consumeChangedRowIndices() -> Set { - _consumeChangedRowIndices?() ?? [] + wrapped.consumeChangedRowIndices() + } + + init(_ manager: any ChangeManaging) { + self.wrapped = manager } } diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index ffde7d698..34e057b3a 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -23,9 +23,10 @@ struct UndoResult { /// @MainActor ensures thread-safe access - critical for avoiding EXC_BAD_ACCESS /// when multiple queries complete simultaneously (e.g., rapid sorting over SSH tunnel) @MainActor @Observable -final class DataChangeManager { +final class DataChangeManager: ChangeManaging { private static let logger = Logger(subsystem: "com.TablePro", category: "DataChangeManager") var changes: [RowChange] = [] + var rowChanges: [RowChange] { changes } var hasChanges: Bool = false var reloadVersion: Int = 0 @@ -234,7 +235,7 @@ final class DataChangeManager { undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - previousValue: oldValue, newValue: newValue + previousValue: oldValue, newValue: newValue, originalRow: nil )) } undoManager.setActionName(String(localized: "Edit Cell")) @@ -289,7 +290,7 @@ final class DataChangeManager { undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - previousValue: oldValue, newValue: newValue + previousValue: oldValue, newValue: newValue, originalRow: originalRow )) } undoManager.setActionName(String(localized: "Edit Cell")) @@ -504,11 +505,11 @@ final class DataChangeManager { // swiftlint:disable:next function_body_length private func applyDataUndo(_ action: UndoAction) { switch action { - case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue): + case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue, let originalRow): undoManager.registerUndo(withTarget: self) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - previousValue: newValue, newValue: previousValue + previousValue: newValue, newValue: previousValue, originalRow: originalRow )) } undoManager.setActionName(String(localized: "Edit Cell")) @@ -558,7 +559,7 @@ final class DataChangeManager { } else { recordCellChangeForRedo( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - oldValue: newValue, newValue: previousValue + oldValue: newValue, newValue: previousValue, originalRow: originalRow ) } changedRowIndices.insert(rowIndex) @@ -708,7 +709,8 @@ final class DataChangeManager { columnIndex: Int, columnName: String, oldValue: String?, - newValue: String? + newValue: String?, + originalRow: [String?]? ) { let cellChange = CellChange( rowIndex: rowIndex, @@ -758,7 +760,8 @@ final class DataChangeManager { } } else { let rowChange = RowChange( - rowIndex: rowIndex, type: .update, cellChanges: [cellChange] + rowIndex: rowIndex, type: .update, cellChanges: [cellChange], + originalRow: originalRow ) changes.append(rowChange) changeIndex[updateKey] = changes.count - 1 diff --git a/TablePro/Core/ChangeTracking/DataChangeModels.swift b/TablePro/Core/ChangeTracking/DataChangeModels.swift index 7ec13675c..b0f15b120 100644 --- a/TablePro/Core/ChangeTracking/DataChangeModels.swift +++ b/TablePro/Core/ChangeTracking/DataChangeModels.swift @@ -75,7 +75,8 @@ enum UndoAction { columnIndex: Int, columnName: String, previousValue: String?, - newValue: String? + newValue: String?, + originalRow: [String?]? ) case rowInsertion(rowIndex: Int) case rowDeletion(rowIndex: Int, originalRow: [String?]) diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index fbd9d2ccf..3af236ede 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -11,7 +11,7 @@ import Observation /// Manager for tracking and applying schema changes @MainActor @Observable -final class StructureChangeManager { +final class StructureChangeManager: ChangeManaging { private(set) var pendingChanges: [SchemaChangeIdentifier: SchemaChange] = [:] @ObservationIgnored private var changeOrder: [SchemaChangeIdentifier] = [] private(set) var validationErrors: [SchemaChangeIdentifier: String] = [:] @@ -882,6 +882,25 @@ final class StructureChangeManager { let tab: StructureTab let row: Int } + + // MARK: - ChangeManaging Conformance (Data-Specific No-Ops) + + var rowChanges: [RowChange] { [] } + + func isRowDeleted(_ rowIndex: Int) -> Bool { false } + + func recordCellChange( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: String?, + newValue: String?, + originalRow: [String?]? + ) {} + + func undoRowDeletion(rowIndex: Int) {} + + func undoRowInsertion(rowIndex: Int) {} } // MARK: - Schema Undo Action diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index e3c2a324c..bb0554d0e 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -176,7 +176,7 @@ final class RowOperationsManager { private func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? { switch result.action { - case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _): + case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _): if rowIndex < resultRows.count { resultRows[rowIndex][columnIndex] = previousValue } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ba15fd81a..adedd1834 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -89,7 +89,7 @@ struct MainEditorContentView: View { } // Fallback before onAppear initializes cachedChangeManager. // Safe: onAppear fires before any user interaction needs it. - return AnyChangeManager(dataManager: changeManager) + return AnyChangeManager(changeManager) } // MARK: - Body @@ -152,7 +152,7 @@ struct MainEditorContentView: View { } .onAppear { updateHasQueryText() - cachedChangeManager = AnyChangeManager(dataManager: changeManager) + cachedChangeManager = AnyChangeManager(changeManager) if let tab = tabManager.selectedTab { cacheRowProvider(for: tab) } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index a974ecdec..31879e868 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -312,8 +312,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return } - for change in changeManager.changes { - guard let rowChange = change as? RowChange else { continue } + for rowChange in changeManager.rowChanges { let rowIndex = rowChange.rowIndex let isDeleted = rowChange.type == .delete let isInserted = rowChange.type == .insert diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 2fd0ffb2a..10a7536d5 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -281,8 +281,7 @@ struct DataGridView: NSViewRepresentable { // Re-apply pending cell edits only when changes have been modified if changeManager.reloadVersion != coordinator.lastReapplyVersion { coordinator.lastReapplyVersion = changeManager.reloadVersion - for change in changeManager.changes { - guard let rowChange = change as? RowChange else { continue } + for rowChange in changeManager.rowChanges { for cellChange in rowChange.cellChanges { coordinator.rowProvider.updateValue( cellChange.newValue, @@ -730,7 +729,7 @@ struct DataGridView: NSViewRepresentable { ], columns: ["id", "name", "email"] ), - changeManager: AnyChangeManager(dataManager: DataChangeManager()), + changeManager: AnyChangeManager(DataChangeManager()), isEditable: true, selectedRowIndices: .constant([]), sortState: .constant(SortState()), diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 72de2553e..bd4c840d7 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -56,7 +56,7 @@ struct CreateTableView: View { let manager = StructureChangeManager() _structureChangeManager = State(wrappedValue: manager) - _wrappedChangeManager = State(wrappedValue: AnyChangeManager(structureManager: manager)) + _wrappedChangeManager = State(wrappedValue: AnyChangeManager(manager)) _gridDelegate = State(wrappedValue: CreateTableGridDelegate( structureChangeManager: manager, structureTab: .columns, diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 0f279b569..2ede1713c 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -61,7 +61,7 @@ struct TableStructureView: View { let manager = StructureChangeManager() _structureChangeManager = State(wrappedValue: manager) - _wrappedChangeManager = State(wrappedValue: AnyChangeManager(structureManager: manager)) + _wrappedChangeManager = State(wrappedValue: AnyChangeManager(manager)) _gridDelegate = State(wrappedValue: StructureGridDelegate( structureChangeManager: manager, selectedTab: .columns,