diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d519cfac..6214585e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). +- Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id. +- Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. The snapshot now refreshes only when the user switches result sets (saving the outgoing tab, loading the incoming one), so each insert / undo / paste no longer triggers an `@Observable` re-render of the whole editor. Fixes empty cells on Load More and CPU spikes when adding or undoing rows. +- Undo of a cell edit clears the modified-cell highlight: `DataChangeManager.applyDataUndo` now bumps `reloadVersion` so the data grid rebuilds its visual state cache. +- Reloading a table tab keeps cached column metadata (defaults, foreign keys, nullability, enum values) when no fresh schema fetch was needed, so the FK arrow and dropdown chevron stay visible across reloads instead of toggling. - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - 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 diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index cf8e8e2b4..cfa0c1ac3 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -7,6 +7,7 @@ protocol ChangeManaging: AnyObject { var reloadVersion: Int { get } var canRedo: Bool { get } var rowChanges: [RowChange] { get } + var insertedRowIndices: Set { get } func isRowDeleted(_ rowIndex: Int) -> Bool func recordCellChange( rowIndex: Int, @@ -30,6 +31,7 @@ final class AnyChangeManager { var reloadVersion: Int { wrapped.reloadVersion } var canRedo: Bool { wrapped.canRedo } var rowChanges: [RowChange] { wrapped.rowChanges } + var insertedRowIndices: Set { wrapped.insertedRowIndices } func isRowDeleted(_ rowIndex: Int) -> Bool { wrapped.isRowDeleted(rowIndex) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 1ade78419..c0d12c513 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -17,6 +17,21 @@ struct UndoResult { let needsRowRemoval: Bool let needsRowRestore: Bool let restoreRow: [String?]? + let delta: Delta + + init( + action: UndoAction, + needsRowRemoval: Bool, + needsRowRestore: Bool, + restoreRow: [String?]?, + delta: Delta = .none + ) { + self.action = action + self.needsRowRemoval = needsRowRemoval + self.needsRowRestore = needsRowRestore + self.restoreRow = restoreRow + self.delta = delta + } } /// Manager for tracking and applying data changes @@ -259,7 +274,10 @@ final class DataChangeManager: ChangeManaging { originalDBValue: newValue, newValue: previousValue, originalRow: originalRow ) } - lastUndoResult = UndoResult(action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil, + delta: .cellChanged(row: rowIndex, column: columnIndex) + ) } private func applyRowInsertionUndo(rowIndex: Int, action: UndoAction) { @@ -274,12 +292,14 @@ final class DataChangeManager: ChangeManaging { if pending.isRowInserted(rowIndex) { _ = pending.undoRowInsertion(rowIndex: rowIndex) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .rowsRemoved(IndexSet(integer: rowIndex)) ) } else { pending.reinsertRow(rowIndex: rowIndex, columns: columns, savedValues: savedValues) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues, + delta: .rowsInserted(IndexSet(integer: rowIndex)) ) } } @@ -292,12 +312,14 @@ final class DataChangeManager: ChangeManaging { if pending.isRowDeleted(rowIndex) { _ = pending.undoRowDeletion(rowIndex: rowIndex) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow, + delta: .fullReplace ) } else { pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .fullReplace ) } } @@ -315,14 +337,16 @@ final class DataChangeManager: ChangeManaging { _ = pending.undoRowDeletion(rowIndex: rowIndex) } lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil, + delta: .fullReplace ) } else { for (rowIndex, originalRow) in rows { pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow) } lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .fullReplace ) } } @@ -335,15 +359,18 @@ final class DataChangeManager: ChangeManaging { } let firstInserted = rowIndices.first.map { pending.isRowInserted($0) } ?? false + let indices = IndexSet(rowIndices) if firstInserted { _ = pending.undoBatchRowInsertion(rowIndices: rowIndices, columnCount: columns.count) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .rowsRemoved(indices) ) } else { pending.reinsertBatch(rowIndices: rowIndices, rowValues: rowValues, columns: columns) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil, + delta: .rowsInserted(indices) ) } } diff --git a/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/TablePro/Core/Plugins/QueryResultExportDataSource.swift index 0e12d0c3d..4706f55c7 100644 --- a/TablePro/Core/Plugins/QueryResultExportDataSource.swift +++ b/TablePro/Core/Plugins/QueryResultExportDataSource.swift @@ -7,9 +7,6 @@ import Foundation import os import TableProPluginKit -/// In-memory `PluginExportDataSource` backed by a RowBuffer snapshot. -/// Allows export plugins (CSV, JSON, SQL, XLSX, MQL) to export query results -/// without modification to the plugins themselves. final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Sendable { let databaseTypeId: String @@ -20,14 +17,12 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send private static let logger = Logger(subsystem: "com.TablePro", category: "QueryResultExportDataSource") - init(rowBuffer: RowBuffer, databaseType: DatabaseType, driver: DatabaseDriver?) { + init(tableRows: TableRows, databaseType: DatabaseType, driver: DatabaseDriver?) { self.databaseTypeId = databaseType.rawValue self.driver = driver - - // Snapshot data at init time for thread safety - self.columns = rowBuffer.columns - self.columnTypeNames = rowBuffer.columnTypes.map { $0.rawType ?? "" } - self.rows = rowBuffer.rows + self.columns = tableRows.columns + self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" } + self.rows = tableRows.rows.map(\.values) } func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { diff --git a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift index 25d266889..90dc375f6 100644 --- a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift +++ b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift @@ -4,7 +4,7 @@ // // Streaming export data source for query results. // Re-executes the query and streams rows directly from the database to the export plugin, -// bypassing RowBuffer. Allows exporting large result sets without loading all rows into memory. +// bypassing in-memory storage. Allows exporting large result sets without loading all rows into memory. // import Foundation diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 3af236ede..ba968bc5e 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -887,6 +887,8 @@ final class StructureChangeManager: ChangeManaging { var rowChanges: [RowChange] { [] } + var insertedRowIndices: Set { [] } + func isRowDeleted(_ rowIndex: Int) -> Bool { false } func recordCellChange( diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index d9e199dbb..4c1d21863 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -181,7 +181,7 @@ final class ExportService { // MARK: - Query Results Export func exportQueryResults( - rowBuffer: RowBuffer, + tableRows: TableRows, config: ExportConfiguration, to url: URL ) async throws { @@ -189,7 +189,7 @@ final class ExportService { throw ExportError.formatNotFound(config.formatId) } - let totalRows = rowBuffer.rows.count + let totalRows = tableRows.count state = ExportState(isExporting: true, totalTables: 1, totalRows: totalRows) isCancelled = false @@ -201,7 +201,7 @@ final class ExportService { } let dataSource = QueryResultExportDataSource( - rowBuffer: rowBuffer, + tableRows: tableRows, databaseType: databaseType, driver: driver ) diff --git a/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift b/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift index a76377fd6..2f8f05856 100644 --- a/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift +++ b/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift @@ -3,7 +3,7 @@ // TablePro // // Pure formatter that transforms raw cell values into display-ready strings. -// Used by InMemoryRowProvider's display cache to compute values once per cell. +// Used by the data grid coordinator's display cache to compute values once per cell. // import Foundation diff --git a/TablePro/Core/Services/Query/RowDataStore.swift b/TablePro/Core/Services/Query/RowDataStore.swift deleted file mode 100644 index 135519b64..000000000 --- a/TablePro/Core/Services/Query/RowDataStore.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -@MainActor -@Observable -final class RowDataStore { - @ObservationIgnored private var store: [UUID: RowBuffer] = [:] - - func buffer(for tabId: UUID) -> RowBuffer { - if let existing = store[tabId] { - return existing - } - let buffer = RowBuffer() - store[tabId] = buffer - return buffer - } - - func existingBuffer(for tabId: UUID) -> RowBuffer? { - store[tabId] - } - - func setBuffer(_ buffer: RowBuffer, for tabId: UUID) { - store[tabId] = buffer - } - - func removeBuffer(for tabId: UUID) { - store.removeValue(forKey: tabId) - } - - func evict(for tabId: UUID) { - store[tabId]?.evict() - } - - func evictAll(except activeTabId: UUID?) { - for (id, buffer) in store where id != activeTabId { - if !buffer.rows.isEmpty && !buffer.isEvicted { - buffer.evict() - } - } - } - - func tearDown() { - store.removeAll() - } -} diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index de8cac6f1..4e8620f4b 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -1,46 +1,56 @@ -// -// RowOperationsManager.swift -// TablePro -// -// Service responsible for row operations: add, delete, duplicate, undo/redo. -// Extracted from MainContentView for better separation of concerns. -// - import AppKit import Foundation import os -/// Manager for row operations in the data grid @MainActor final class RowOperationsManager { private static let logger = Logger(subsystem: "com.TablePro", category: "RowOperationsManager") - /// Maximum number of rows that can be copied to clipboard to prevent OOM private static let maxClipboardRows = 50_000 - // MARK: - Dependencies + struct AddNewRowResult { + let rowIndex: Int + let values: [String?] + let delta: Delta + } - private let changeManager: DataChangeManager + struct DeleteRowsResult { + let nextRowToSelect: Int + let physicallyRemovedIndices: [Int] + let delta: Delta + } + + struct PastedRowInfo { + let rowIndex: Int + let values: [String?] + } - // MARK: - Initialization + struct PasteRowsResult { + let pastedRows: [PastedRowInfo] + let delta: Delta + } + + struct UndoApplicationResult { + let adjustedSelection: Set? + let delta: Delta + } + + struct UndoInsertRowResult { + let adjustedSelection: Set + let delta: Delta + } + + private let changeManager: DataChangeManager init(changeManager: DataChangeManager) { self.changeManager = changeManager } - // MARK: - Add Row - - /// Add a new row to a table tab - /// - Parameters: - /// - columns: Column names - /// - columnDefaults: Column default values - /// - resultRows: Current rows (will be mutated) - /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed func addNewRow( columns: [String], columnDefaults: [String: String?], - resultRows: inout [[String?]] - ) -> (rowIndex: Int, values: [String?])? { + tableRows: inout TableRows + ) -> AddNewRowResult? { var newRowValues: [String?] = [] for column in columns { if let defaultValue = columnDefaults[column], defaultValue != nil { @@ -50,58 +60,43 @@ final class RowOperationsManager { } } - let newRowIndex = resultRows.count - resultRows.append(newRowValues) + let newRowIndex = tableRows.count + let delta = tableRows.appendInsertedRow(values: newRowValues) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newRowValues) - return (newRowIndex, newRowValues) + return AddNewRowResult(rowIndex: newRowIndex, values: newRowValues, delta: delta) } - // MARK: - Duplicate Row - - /// Duplicate a row with new primary key - /// - Parameters: - /// - sourceRowIndex: Index of row to duplicate - /// - columns: Column names - /// - resultRows: Current rows (will be mutated) - /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed func duplicateRow( sourceRowIndex: Int, columns: [String], - resultRows: inout [[String?]] - ) -> (rowIndex: Int, values: [String?])? { - guard sourceRowIndex < resultRows.count else { return nil } + tableRows: inout TableRows + ) -> AddNewRowResult? { + guard sourceRowIndex >= 0, sourceRowIndex < tableRows.count else { return nil } - var newValues = resultRows[sourceRowIndex] + var newValues = tableRows.rows[sourceRowIndex].values for pkColumn in changeManager.primaryKeyColumns { - if let pkIndex = columns.firstIndex(of: pkColumn) { + if let pkIndex = columns.firstIndex(of: pkColumn), pkIndex < newValues.count { newValues[pkIndex] = "__DEFAULT__" } } - let newRowIndex = resultRows.count - resultRows.append(newValues) + let newRowIndex = tableRows.count + let delta = tableRows.appendInsertedRow(values: newValues) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newValues) - return (newRowIndex, newValues) - } - - // MARK: - Delete Rows - - struct DeleteRowsResult { - let nextRowToSelect: Int - let physicallyRemovedIndices: [Int] + return AddNewRowResult(rowIndex: newRowIndex, values: newValues, delta: delta) } func deleteSelectedRows( selectedIndices: Set, - resultRows: inout [[String?]] + tableRows: inout TableRows ) -> DeleteRowsResult { guard !selectedIndices.isEmpty else { - return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: []) + return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: [], delta: .none) } var insertedRowsToDelete: [Int] = [] @@ -114,19 +109,17 @@ final class RowOperationsManager { if changeManager.isRowInserted(rowIndex) { insertedRowsToDelete.append(rowIndex) } else if !changeManager.isRowDeleted(rowIndex) { - if rowIndex < resultRows.count { - existingRowsToDelete.append((rowIndex: rowIndex, originalRow: resultRows[rowIndex])) + if rowIndex < tableRows.count { + existingRowsToDelete.append((rowIndex: rowIndex, originalRow: tableRows.rows[rowIndex].values)) } } } let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) + var delta: Delta = .none if !sortedInsertedRows.isEmpty { - for rowIndex in sortedInsertedRows { - guard rowIndex < resultRows.count else { continue } - resultRows.remove(at: rowIndex) - } + delta = tableRows.remove(at: IndexSet(sortedInsertedRows)) changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) } @@ -134,7 +127,7 @@ final class RowOperationsManager { changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) } - let totalRows = resultRows.count + let totalRows = tableRows.count let rowsDeleted = sortedInsertedRows.count let adjustedMaxRow = maxSelectedRow - rowsDeleted let adjustedMinRow = minSelectedRow - sortedInsertedRows.count(where: { $0 < minSelectedRow }) @@ -152,97 +145,88 @@ final class RowOperationsManager { return DeleteRowsResult( nextRowToSelect: nextRow, - physicallyRemovedIndices: sortedInsertedRows + physicallyRemovedIndices: sortedInsertedRows, + delta: delta ) } - // MARK: - Undo/Redo - - /// Undo the last change - /// - Parameter resultRows: Current rows (will be mutated) - /// - Returns: Updated selection indices - func undoLastChange(resultRows: inout [[String?]]) -> Set? { + func undoLastChange(tableRows: inout TableRows) -> UndoApplicationResult? { guard let result = changeManager.undoLastChange() else { return nil } - return applyUndoResult(result, resultRows: &resultRows) + return applyUndoResult(result, tableRows: &tableRows) } - /// Redo the last undone change - /// - Parameters: - /// - resultRows: Current rows (will be mutated) - /// - columns: Column names for new row creation - /// - Returns: Updated selection indices - func redoLastChange(resultRows: inout [[String?]], columns: [String]) -> Set? { + func redoLastChange(tableRows: inout TableRows) -> UndoApplicationResult? { guard let result = changeManager.redoLastChange() else { return nil } - return applyUndoResult(result, resultRows: &resultRows) + return applyUndoResult(result, tableRows: &tableRows) } - func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? { + func applyUndoResult(_ result: UndoResult, tableRows: inout TableRows) -> UndoApplicationResult { switch result.action { case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _): - if rowIndex < resultRows.count { - resultRows[rowIndex][columnIndex] = previousValue - } + let delta = tableRows.edit(row: rowIndex, column: columnIndex, value: previousValue) + return UndoApplicationResult(adjustedSelection: nil, delta: delta) case .rowInsertion(let rowIndex): if result.needsRowRemoval { - if rowIndex < resultRows.count { - resultRows.remove(at: rowIndex) - return Set() + guard rowIndex >= 0, rowIndex < tableRows.count else { + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } + let delta = tableRows.remove(at: IndexSet(integer: rowIndex)) + return UndoApplicationResult(adjustedSelection: Set(), delta: delta) } 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) - } + let columnCount = tableRows.columns.count + let values = result.restoreRow ?? [String?](repeating: nil, count: columnCount) + let delta = tableRows.insertInsertedRow(at: rowIndex, values: values) + return UndoApplicationResult(adjustedSelection: nil, delta: delta) } + return UndoApplicationResult(adjustedSelection: nil, delta: .none) case .rowDeletion: - break + return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) case .batchRowDeletion: - break + return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) 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) + let validIndices = IndexSet(rowIndices.filter { $0 >= 0 && $0 < tableRows.count }) + guard !validIndices.isEmpty else { + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } + let delta = tableRows.remove(at: validIndices) + return UndoApplicationResult(adjustedSelection: nil, delta: delta) } 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) + var insertedIndices = IndexSet() + let pairs = zip(rowIndices, rowValues).sorted { $0.0 < $1.0 } + for (rowIndex, values) in pairs { + guard rowIndex >= 0, rowIndex <= tableRows.count else { continue } + _ = tableRows.insertInsertedRow(at: rowIndex, values: values) + insertedIndices.insert(rowIndex) + } + guard !insertedIndices.isEmpty else { + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } + return UndoApplicationResult(adjustedSelection: nil, delta: .rowsInserted(insertedIndices)) } + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } - - return nil } - // MARK: - Undo Insert Row - - /// Remove a row that was inserted (called by undo context menu) - /// - Parameters: - /// - rowIndex: Index of the inserted row - /// - resultRows: Current rows (will be mutated) - /// - selectedIndices: Current selection (will be adjusted) - /// - Returns: Adjusted selection indices func undoInsertRow( at rowIndex: Int, - resultRows: inout [[String?]], + tableRows: inout TableRows, selectedIndices: Set - ) -> Set { - guard rowIndex >= 0 && rowIndex < resultRows.count else { return selectedIndices } + ) -> UndoInsertRowResult { + guard rowIndex >= 0 && rowIndex < tableRows.count else { + return UndoInsertRowResult(adjustedSelection: selectedIndices, delta: .none) + } - // Remove the row from resultRows - resultRows.remove(at: rowIndex) + let delta = tableRows.remove(at: IndexSet(integer: rowIndex)) - // Adjust selection indices var adjustedSelection = Set() for idx in selectedIndices { if idx == rowIndex { - continue // Skip the removed row + continue } else if idx > rowIndex { adjustedSelection.insert(idx - 1) } else { @@ -250,21 +234,12 @@ final class RowOperationsManager { } } - return adjustedSelection + return UndoInsertRowResult(adjustedSelection: adjustedSelection, delta: delta) } - // MARK: - Copy Rows - - /// Copy selected rows to clipboard as tab-separated values - /// - Parameters: - /// - selectedIndices: Indices of rows to copy - /// - resultRows: Current rows - /// - columns: Column names (used when includeHeaders is true) - /// - includeHeaders: Whether to prepend column headers as the first TSV line func copySelectedRowsToClipboard( selectedIndices: Set, - resultRows: [[String?]], - columns: [String] = [], + tableRows: TableRows, includeHeaders: Bool = false ) { guard !selectedIndices.isEmpty else { return } @@ -281,22 +256,22 @@ final class RowOperationsManager { let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices - let columnCount = resultRows.first?.count ?? 1 + let columnCount = tableRows.rows.first?.values.count ?? 1 let estimatedRowLength = columnCount * 12 var result = "" result.reserveCapacity(indicesToCopy.count * estimatedRowLength) - if includeHeaders, !columns.isEmpty { - for (colIdx, col) in columns.enumerated() { + if includeHeaders, !tableRows.columns.isEmpty { + for (colIdx, col) in tableRows.columns.enumerated() { if colIdx > 0 { result.append("\t") } result.append(col) } } for rowIndex in indicesToCopy { - guard rowIndex < resultRows.count else { continue } + guard rowIndex < tableRows.count else { continue } if !result.isEmpty { result.append("\n") } - for (colIdx, value) in resultRows[rowIndex].enumerated() { + for (colIdx, value) in tableRows.rows[rowIndex].values.enumerated() { if colIdx > 0 { result.append("\t") } result.append(value ?? "NULL") } @@ -309,57 +284,37 @@ final class RowOperationsManager { ClipboardService.shared.writeText(result) } - // MARK: - Paste Rows - - /// Paste rows from clipboard (TSV format) and insert into table - /// - Parameters: - /// - columns: Column names for the table - /// - primaryKeyColumns: Primary key column names (will be set to __DEFAULT__) - /// - resultRows: Current rows (will be mutated) - /// - clipboard: Clipboard provider (injectable for testing) - /// - parser: Row data parser (injectable for testing) - /// - Returns: Array of (rowIndex, values) for pasted rows, or empty array on failure - @MainActor func pasteRowsFromClipboard( columns: [String], primaryKeyColumns: [String], - resultRows: inout [[String?]], + tableRows: inout TableRows, clipboard: ClipboardProvider? = nil, parser: RowDataParser? = nil - ) -> [(rowIndex: Int, values: [String?])] { - // Read from clipboard + ) -> PasteRowsResult { let clipboardProvider = clipboard ?? ClipboardService.shared guard let clipboardText = clipboardProvider.readText() else { - return [] + return PasteRowsResult(pastedRows: [], delta: .none) } - // Create schema let schema = TableSchema( columns: columns, primaryKeyColumns: primaryKeyColumns ) - // Parse clipboard text (auto-detect CSV vs TSV) let rowParser = parser ?? Self.detectParser(for: clipboardText) let parseResult = rowParser.parse(clipboardText, schema: schema) switch parseResult { case .success(let parsedRows): - return insertParsedRows(parsedRows, into: &resultRows) + return insertParsedRows(parsedRows, into: &tableRows) case .failure(let error): - // Log error (in production, this could show a user-facing alert) Self.logger.warning("Paste failed: \(error.localizedDescription)") - return [] + return PasteRowsResult(pastedRows: [], delta: .none) } } - // MARK: - Parser Detection - - /// Auto-detect whether clipboard text is CSV or TSV - /// Heuristic: if tabs appear in most lines, use TSV; otherwise CSV static func detectParser(for text: String) -> RowDataParser { - // Single-pass scan: count non-empty lines containing tabs vs commas var tabLines = 0 var commaLines = 0 var nonEmptyLines = 0 @@ -383,7 +338,6 @@ final class RowOperationsManager { if char == "," { lineHasComma = true } } } - // Handle last line (no trailing newline) if !lineIsEmpty { nonEmptyLines += 1 if lineHasTab { tabLines += 1 } @@ -395,7 +349,6 @@ final class RowOperationsManager { let tabCount = tabLines let commaCount = commaLines - // If majority of lines have tabs, use TSV; otherwise CSV if tabCount > commaCount { return TSVRowParser() } else if commaCount > 0 { @@ -404,30 +357,25 @@ final class RowOperationsManager { return TSVRowParser() } - // MARK: - Private Helpers - - /// Insert parsed rows into the table - /// - Parameters: - /// - parsedRows: Array of parsed rows from clipboard - /// - resultRows: Current rows (will be mutated) - /// - Returns: Array of (rowIndex, values) for inserted rows private func insertParsedRows( _ parsedRows: [ParsedRow], - into resultRows: inout [[String?]] - ) -> [(rowIndex: Int, values: [String?])] { - var pastedRowInfo: [(Int, [String?])] = [] + into tableRows: inout TableRows + ) -> PasteRowsResult { + var pastedRowInfo: [PastedRowInfo] = [] + var insertedIndices = IndexSet() for parsedRow in parsedRows { let rowValues = parsedRow.values - - resultRows.append(rowValues) - let newRowIndex = resultRows.count - 1 + let newRowIndex = tableRows.count + _ = tableRows.appendInsertedRow(values: rowValues) + insertedIndices.insert(newRowIndex) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: rowValues) - pastedRowInfo.append((newRowIndex, rowValues)) + pastedRowInfo.append(PastedRowInfo(rowIndex: newRowIndex, values: rowValues)) } - return pastedRowInfo + let delta: Delta = insertedIndices.isEmpty ? .none : .rowsInserted(insertedIndices) + return PasteRowsResult(pastedRows: pastedRowInfo, delta: delta) } } diff --git a/TablePro/Core/Services/Query/TableRowsStore.swift b/TablePro/Core/Services/Query/TableRowsStore.swift new file mode 100644 index 000000000..94be10cf5 --- /dev/null +++ b/TablePro/Core/Services/Query/TableRowsStore.swift @@ -0,0 +1,65 @@ +import Foundation + +@MainActor +@Observable +final class TableRowsStore { + @ObservationIgnored private var store: [UUID: TableRows] = [:] + @ObservationIgnored private var evictedSet: Set = [] + + func tableRows(for tabId: UUID) -> TableRows { + if let existing = store[tabId] { + return existing + } + let rows = TableRows() + store[tabId] = rows + return rows + } + + func existingTableRows(for tabId: UUID) -> TableRows? { + store[tabId] + } + + func setTableRows(_ rows: TableRows, for tabId: UUID) { + store[tabId] = rows + evictedSet.remove(tabId) + } + + func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Void) { + var rows = store[tabId] ?? TableRows() + mutate(&rows) + store[tabId] = rows + evictedSet.remove(tabId) + } + + func removeTableRows(for tabId: UUID) { + store.removeValue(forKey: tabId) + evictedSet.remove(tabId) + } + + func isEvicted(_ tabId: UUID) -> Bool { + evictedSet.contains(tabId) + } + + func evict(for tabId: UUID) { + guard var rows = store[tabId] else { return } + guard !rows.rows.isEmpty else { return } + rows.rows = [] + store[tabId] = rows + evictedSet.insert(tabId) + } + + func evictAll(except activeTabId: UUID?) { + for (id, rows) in store where id != activeTabId { + guard !rows.rows.isEmpty, !evictedSet.contains(id) else { continue } + var copy = rows + copy.rows = [] + store[id] = copy + evictedSet.insert(id) + } + } + + func tearDown() { + store.removeAll() + evictedSet.removeAll() + } +} diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index df821ebca..e9dfc00e7 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -504,6 +504,7 @@ internal final class SQLFavoriteStorage { var hasConnectionFilter = false var hasFolderFilter = false + let isJoined: Bool if let searchText = searchText, !searchText.isEmpty { sql = """ SELECT f.id, f.name, f.query, f.keyword, f.folder_id, f.connection_id, f.sort_order, f.created_at, f.updated_at @@ -511,6 +512,7 @@ internal final class SQLFavoriteStorage { INNER JOIN favorites_fts ON f.rowid = favorites_fts.rowid WHERE favorites_fts MATCH ? """ + isJoined = true if connectionIdString != nil { sql += " AND (f.connection_id IS NULL OR f.connection_id = ?)" @@ -526,6 +528,7 @@ internal final class SQLFavoriteStorage { SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at FROM favorites """ + isJoined = false var whereClauses: [String] = [] @@ -544,7 +547,7 @@ internal final class SQLFavoriteStorage { } } - sql += " ORDER BY sort_order ASC, name ASC;" + sql += isJoined ? " ORDER BY f.sort_order ASC, f.name ASC;" : " ORDER BY sort_order ASC, name ASC;" var statement: OpaquePointer? guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { diff --git a/TablePro/Models/Export/ExportModels.swift b/TablePro/Models/Export/ExportModels.swift index 238732573..9902724c7 100644 --- a/TablePro/Models/Export/ExportModels.swift +++ b/TablePro/Models/Export/ExportModels.swift @@ -11,7 +11,7 @@ import TableProPluginKit /// Defines the export mode: either exporting database tables or in-memory query results. enum ExportMode { case tables(connection: DatabaseConnection, preselectedTables: Set) - case queryResults(connection: DatabaseConnection, rowBuffer: RowBuffer, suggestedFileName: String) + case queryResults(connection: DatabaseConnection, tableRows: TableRows, suggestedFileName: String) case streamingQuery(connection: DatabaseConnection, query: String, suggestedFileName: String) } diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index 01997ddb7..bfc22b679 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -14,7 +14,7 @@ import os final class ResultSet: Identifiable { let id: UUID var label: String - var rowBuffer: RowBuffer + var tableRows: TableRows var executionTime: TimeInterval? var rowsAffected: Int = 0 var errorMessage: String? @@ -27,37 +27,11 @@ final class ResultSet: Identifiable { var pagination = PaginationState() var columnLayout = ColumnLayoutState() - var columnTypes: [ColumnType] { - get { rowBuffer.columnTypes } - set { rowBuffer.columnTypes = newValue } - } - - var columnDefaults: [String: String?] { - get { rowBuffer.columnDefaults } - set { rowBuffer.columnDefaults = newValue } - } - - var columnForeignKeys: [String: ForeignKeyInfo] { - get { rowBuffer.columnForeignKeys } - set { rowBuffer.columnForeignKeys = newValue } - } - - var columnEnumValues: [String: [String]] { - get { rowBuffer.columnEnumValues } - set { rowBuffer.columnEnumValues = newValue } - } - - var columnNullable: [String: Bool] { - get { rowBuffer.columnNullable } - set { rowBuffer.columnNullable = newValue } - } - - var resultColumns: [String] { rowBuffer.columns } - var resultRows: [[String?]] { rowBuffer.rows } + var resultColumns: [String] { tableRows.columns } - init(id: UUID = UUID(), label: String, rowBuffer: RowBuffer = RowBuffer()) { + init(id: UUID = UUID(), label: String, tableRows: TableRows = TableRows()) { self.id = id self.label = label - self.rowBuffer = rowBuffer + self.tableRows = tableRows } } diff --git a/TablePro/Models/Query/RowBuffer.swift b/TablePro/Models/Query/RowBuffer.swift deleted file mode 100644 index 86c2b3b83..000000000 --- a/TablePro/Models/Query/RowBuffer.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// RowBuffer.swift -// TablePro -// - -import Foundation -import os -import TableProPluginKit - -/// Reference-type wrapper for large result data. -/// When QueryTab (a struct) is copied via CoW, only this 8-byte reference is copied -/// instead of duplicating potentially large result arrays. -final class RowBuffer { - var rows: [[String?]] - var columns: [String] - var columnTypes: [ColumnType] - var columnDefaults: [String: String?] - var columnForeignKeys: [String: ForeignKeyInfo] - var columnEnumValues: [String: [String]] - var columnNullable: [String: Bool] - - init( - rows: [[String?]] = [], - columns: [String] = [], - columnTypes: [ColumnType] = [], - columnDefaults: [String: String?] = [:], - columnForeignKeys: [String: ForeignKeyInfo] = [:], - columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] - ) { - self.rows = rows - self.columns = columns - self.columnTypes = columnTypes - self.columnDefaults = columnDefaults - self.columnForeignKeys = columnForeignKeys - self.columnEnumValues = columnEnumValues - self.columnNullable = columnNullable - } - - /// Create a deep copy of this buffer (used when explicit data duplication is needed) - func copy() -> RowBuffer { - RowBuffer( - rows: rows, - columns: columns, - columnTypes: columnTypes, - columnDefaults: columnDefaults, - columnForeignKeys: columnForeignKeys, - columnEnumValues: columnEnumValues, - columnNullable: columnNullable - ) - } - - /// Whether this buffer's row data has been evicted to save memory - private(set) var isEvicted: Bool = false - - /// Evict row data to free memory. Column metadata is preserved. - func evict() { - guard !isEvicted else { return } - rows = [] - isEvicted = true - } - - /// Restore row data after eviction - func restore(rows newRows: [[String?]]) { - self.rows = newRows - isEvicted = false - } - - deinit { - #if DEBUG - Logger(subsystem: "com.TablePro", category: "RowBuffer") - .debug("RowBuffer deallocated — columns: \(self.columns.count), evicted: \(self.isEvicted)") - #endif - } -} diff --git a/TablePro/Models/Query/RowProvider.swift b/TablePro/Models/Query/RowProvider.swift deleted file mode 100644 index 9bdab27db..000000000 --- a/TablePro/Models/Query/RowProvider.swift +++ /dev/null @@ -1,356 +0,0 @@ -// -// RowProvider.swift -// TablePro -// -// Protocol for virtualized row data access -// - -import Foundation -import os - -/// Protocol for virtualized data access with lazy loading support -protocol RowProvider: AnyObject { - /// Total number of rows available - var totalRowCount: Int { get } - - /// Column names - var columns: [String] { get } - - /// Column default values from schema - var columnDefaults: [String: String?] { get } - - /// Fetch rows for the given range - /// - Parameters: - /// - offset: Starting row index - /// - limit: Maximum number of rows to fetch - /// - Returns: Array of row data - func fetchRows(offset: Int, limit: Int) -> [TableRowData] - - /// Prefetch rows at specific indices for smoother scrolling - func prefetchRows(at indices: [Int]) - - /// Invalidate cached data (e.g., after refresh) - func invalidateCache() -} - -/// Represents a single row of table data -final class TableRowData { - let index: Int - var values: [String?] - - init(index: Int, values: [String?]) { - self.index = index - self.values = values - } - - /// Get value at column index - func value(at columnIndex: Int) -> String? { - guard columnIndex < values.count else { return nil } - return values[columnIndex] - } - - /// Set value at column index - func setValue(_ value: String?, at columnIndex: Int) { - guard columnIndex < values.count else { return } - values[columnIndex] = value - } -} - -// MARK: - In-Memory Row Provider - -/// Row provider that keeps all data in memory as `[[String?]]`. -/// References `RowBuffer` directly to avoid duplicating row data. -/// An optional `sortIndices` array maps display indices to source-row indices, -/// so sorted views don't need a reordered copy of the rows. -/// -/// Direct-access methods `value(atRow:column:)` and `rowValues(at:)` avoid -/// heap allocations by reading straight from the source `[String?]` array. -final class InMemoryRowProvider: RowProvider { - private weak var rowBuffer: RowBuffer? - /// Strong reference only when the provider created its own buffer (convenience init). - /// External buffers are owned by QueryTab, so we hold them weakly. - private var ownedBuffer: RowBuffer? - private static let emptyBuffer = RowBuffer() - private var safeBuffer: RowBuffer { rowBuffer ?? Self.emptyBuffer } - private var sortIndices: [Int]? - private var appendedRows: [[String?]] = [] - private(set) var columns: [String] - - /// Lazy per-cell cache for formatted display values. - /// Keyed by source row index (buffer index or offset appended index). - /// Evicted when exceeding maxDisplayCacheSize to bound memory. - private var displayCache: [Int: [String?]] = [:] - private static let maxDisplayCacheSize = 20_000 - private(set) var columnDefaults: [String: String?] - private(set) var columnTypes: [ColumnType] - private(set) var columnForeignKeys: [String: ForeignKeyInfo] - private(set) var columnEnumValues: [String: [String]] - private(set) var columnNullable: [String: Bool] - private(set) var columnDisplayFormats: [ValueDisplayFormat?] = [] - - var totalRowCount: Int { - bufferRowCount + appendedRows.count - } - - /// Number of rows coming from the buffer (respecting sort indices count when present) - private var bufferRowCount: Int { - sortIndices?.count ?? safeBuffer.rows.count - } - - init( - rowBuffer: RowBuffer, - sortIndices: [Int]? = nil, - columns: [String], - columnDefaults: [String: String?] = [:], - columnTypes: [ColumnType]? = nil, - columnForeignKeys: [String: ForeignKeyInfo] = [:], - columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] - ) { - self.rowBuffer = rowBuffer - self.sortIndices = sortIndices - self.columns = columns - self.columnDefaults = columnDefaults - self.columnTypes = columnTypes ?? Array(repeating: ColumnType.text(rawType: nil), count: columns.count) - self.columnForeignKeys = columnForeignKeys - self.columnEnumValues = columnEnumValues - self.columnNullable = columnNullable - } - - /// Convenience initializer that wraps rows in an internal RowBuffer. - /// Used by tests, previews, and callers that don't have a RowBuffer reference. - convenience init( - rows: [[String?]], - columns: [String], - columnDefaults: [String: String?] = [:], - columnTypes: [ColumnType]? = nil, - columnForeignKeys: [String: ForeignKeyInfo] = [:], - columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] - ) { - let buffer = RowBuffer(rows: rows, columns: columns) - self.init( - rowBuffer: buffer, - columns: columns, - columnDefaults: columnDefaults, - columnTypes: columnTypes, - columnForeignKeys: columnForeignKeys, - columnEnumValues: columnEnumValues, - columnNullable: columnNullable - ) - ownedBuffer = buffer - } - - func fetchRows(offset: Int, limit: Int) -> [TableRowData] { - let total = totalRowCount - let endIndex = min(offset + limit, total) - guard offset < endIndex else { return [] } - var result: [TableRowData] = [] - result.reserveCapacity(endIndex - offset) - for i in offset.. TableRowData? { - guard index >= 0 && index < totalRowCount else { return nil } - return TableRowData(index: index, values: sourceRow(at: index)) - } - - /// O(1) cell value access — no heap allocation. - func value(atRow rowIndex: Int, column columnIndex: Int) -> String? { - guard rowIndex >= 0 && rowIndex < totalRowCount else { return nil } - let src = sourceRow(at: rowIndex) - guard columnIndex >= 0 && columnIndex < src.count else { return nil } - return src[columnIndex] - } - - /// Returns the source values array for a display row. No copy until caller stores it. - func rowValues(at rowIndex: Int) -> [String?]? { - guard rowIndex >= 0 && rowIndex < totalRowCount else { return nil } - return sourceRow(at: rowIndex) - } - - // MARK: - Display Value Cache - - /// Get the formatted display value for a cell. - /// Computes on first access for the entire row, returns cached on subsequent calls. - @MainActor - func displayValue(atRow rowIndex: Int, column columnIndex: Int) -> String? { - guard rowIndex >= 0 && rowIndex < totalRowCount else { return nil } - - let cacheKey = resolveCacheKey(for: rowIndex) - - if let cachedRow = displayCache[cacheKey], columnIndex < cachedRow.count { - return cachedRow[columnIndex] - } - - let src = sourceRow(at: rowIndex) - let columnCount = columns.count - var rowCache = [String?](repeating: nil, count: columnCount) - for col in 0.. Self.maxDisplayCacheSize else { return } - let halfSize = Self.maxDisplayCacheSize / 2 - displayCache = displayCache.filter { abs($0.key - nearKey) <= halfSize } - } - - @MainActor - func preWarmDisplayCache(upTo rowCount: Int) { - let count = min(rowCount, totalRowCount) - for row in 0.. Int { - let newIndex = totalRowCount - appendedRows.append(values) - return newIndex - } - - /// Remove row at index (used when discarding new rows) - func removeRow(at index: Int) { - guard index >= 0 && index < totalRowCount else { return } - let bCount = bufferRowCount - if index >= bCount { - let appendedIdx = index - bCount - guard appendedIdx < appendedRows.count else { return } - appendedRows.remove(at: appendedIdx) - } else { - guard let buffer = rowBuffer else { return } - if let sorted = sortIndices { - let bufferIdx = sorted[index] - buffer.rows.remove(at: bufferIdx) - var newIndices = sorted - newIndices.remove(at: index) - for i in newIndices.indices where newIndices[i] > bufferIdx { - newIndices[i] -= 1 - } - sortIndices = newIndices - } else { - buffer.rows.remove(at: index) - } - } - displayCache.removeAll() - } - - /// Remove multiple rows at indices (used when discarding new rows) - /// Indices should be sorted in descending order to maintain correct removal - func removeRows(at indices: Set) { - for index in indices.sorted(by: >) { - guard index >= 0 && index < totalRowCount else { continue } - removeRow(at: index) - } - } - - // MARK: - Private - - /// Map a display index to a cache key based on the source row identity. - private func resolveCacheKey(for displayIndex: Int) -> Int { - let sourceIdx = resolveSourceIndex(displayIndex) - if let bufIdx = sourceIdx.bufferIndex { - return bufIdx - } else if let appIdx = sourceIdx.appendedIndex { - return bufferRowCount + appIdx - } - return displayIndex - } - - /// Resolve a display index to either a buffer index or an appended-row index. - private func resolveSourceIndex(_ displayIndex: Int) -> (bufferIndex: Int?, appendedIndex: Int?) { - let bCount = bufferRowCount - if displayIndex >= bCount { - return (nil, displayIndex - bCount) - } - if let sorted = sortIndices { - return (sorted[displayIndex], nil) - } - return (displayIndex, nil) - } - - /// Get the source row values for a display index. - private func sourceRow(at displayIndex: Int) -> [String?] { - let bCount = bufferRowCount - if displayIndex >= bCount { - return appendedRows[displayIndex - bCount] - } - if let sorted = sortIndices { - return safeBuffer.rows[sorted[displayIndex]] - } - return safeBuffer.rows[displayIndex] - } -} diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index e16647f02..81d1842aa 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -39,6 +39,18 @@ struct TableRows: Sendable { return rows[row][column] } + func index(of id: RowID) -> Int? { + for (index, row) in rows.enumerated() where row.id == id { + return index + } + return nil + } + + func row(withID id: RowID) -> Row? { + guard let index = index(of: id) else { return nil } + return rows[index] + } + @discardableResult mutating func edit(row: Int, column: Int, value: String?) -> Delta { guard row >= 0, row < rows.count else { return .none } @@ -72,6 +84,15 @@ struct TableRows: Sendable { return .rowsInserted(IndexSet(integer: rows.count - 1)) } + @discardableResult + mutating func insertInsertedRow(at index: Int, values: [String?]) -> Delta { + guard index >= 0, index <= rows.count else { return .none } + let normalized = Self.normalize(values: values, toCount: columns.count) + let row = Row(id: .inserted(UUID()), values: normalized) + rows.insert(row, at: index) + return .rowsInserted(IndexSet(integer: index)) + } + @discardableResult mutating func appendPage(_ pageRows: [[String?]], startingAt offset: Int) -> Delta { guard !pageRows.isEmpty else { return .none } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 0b9e0571f..87acdef6f 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -54,8 +54,8 @@ struct ExportDialog: View { } private var queryResultsRowCount: Int { - if case .queryResults(_, let rowBuffer, _) = mode { - return rowBuffer.rows.count + if case .queryResults(_, let tableRows, _) = mode { + return tableRows.count } return 0 } @@ -867,10 +867,10 @@ struct ExportDialog: View { service = ExportService(driver: driver, databaseType: connection.type) exportService = service try await service.exportStreamingQuery(query: query, config: config, to: url) - case .queryResults(_, let rowBuffer, _): + case .queryResults(_, let tableRows, _): service = ExportService(databaseType: connection.type) exportService = service - try await service.exportQueryResults(rowBuffer: rowBuffer, config: config, to: url) + try await service.exportQueryResults(tableRows: tableRows, config: config, to: url) default: return } diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 1d53268ce..abdce6059 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -7,7 +7,6 @@ // import AppKit -import SwiftUI @MainActor final class DataTabGridDelegate: DataGridViewDelegate { @@ -15,7 +14,6 @@ final class DataTabGridDelegate: DataGridViewDelegate { var columnVisibilityManager: ColumnVisibilityManager? var selectionState: GridSelectionState? - var editingCell: Binding? var onCellEdit: ((Int, Int, String?) -> Void)? var onSort: ((Int, Bool, Bool) -> Void)? @@ -59,16 +57,12 @@ final class DataTabGridDelegate: DataGridViewDelegate { } func dataGridPasteRows() { - var cell = editingCell?.wrappedValue - coordinator?.pasteRows(editingCell: &cell) - editingCell?.wrappedValue = cell + coordinator?.pasteRows() } func dataGridDuplicateRow() { guard let selectionState, let firstIndex = selectionState.indices.first else { return } - var cell = editingCell?.wrappedValue - coordinator?.duplicateSelectedRow(index: firstIndex, editingCell: &cell) - editingCell?.wrappedValue = cell + coordinator?.duplicateSelectedRow(index: firstIndex) } func dataGridExportResults() { @@ -107,7 +101,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { return menu } - weak var tableViewCoordinator: (any RowDeltaApplying)? + weak var tableViewCoordinator: (any TableViewCoordinating)? func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { self.tableViewCoordinator = tableViewCoordinator diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ff0d9d1b3..01e3739af 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -10,9 +10,11 @@ import AppKit import CodeEditSourceEditor import SwiftUI -/// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation +/// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation. +/// Stores a permutation of `RowID` so the grid keeps the same display order even after +/// inserts and deletes mutate the underlying TableRows storage. private struct SortedRowsCache { - let sortedIndices: [Int] + let sortedIDs: [RowID] let columnIndex: Int let direction: SortDirection let schemaVersion: Int @@ -34,7 +36,6 @@ struct MainEditorContentView: View { // MARK: - Selection State let selectionState: GridSelectionState - @Binding var editingCell: CellPosition? // MARK: - Callbacks @@ -61,7 +62,6 @@ struct MainEditorContentView: View { @State private var sortCache: [UUID: SortedRowsCache] = [:] - @State private var providerCache = RowProviderCache() @State private var cachedChangeManager: AnyChangeManager? @State private var erDiagramViewModels: [UUID: ERDiagramViewModel] = [:] @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] @@ -119,7 +119,7 @@ struct MainEditorContentView: View { } .onChange(of: tabManager.tabStructureVersion) { _, _ in let newIds = tabManager.tabIds - guard !sortCache.isEmpty || !providerCache.isEmpty || !erDiagramViewModels.isEmpty + guard !sortCache.isEmpty || !erDiagramViewModels.isEmpty || !serverDashboardViewModels.isEmpty else { coordinator.cleanupSortCache(openTabIds: Set(newIds)) return @@ -127,55 +127,24 @@ struct MainEditorContentView: View { let openTabIds = Set(newIds) sortCache = sortCache.filter { openTabIds.contains($0.key) } coordinator.cleanupSortCache(openTabIds: openTabIds) - providerCache.retain(tabIds: openTabIds) erDiagramViewModels = erDiagramViewModels.filter { openTabIds.contains($0.key) } serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) } } .onChange(of: tabManager.selectedTabId) { _, _ in updateHasQueryText() - - guard let tab = tabManager.selectedTab, - let existing = coordinator.rowDataStore.existingBuffer(for: tab.id), - !existing.isEvicted else { return } - if providerCache.provider( - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) == nil { - cacheRowProvider(for: tab) - } } .onAppear { updateHasQueryText() cachedChangeManager = AnyChangeManager(changeManager) - if let tab = tabManager.selectedTab, - let existing = coordinator.rowDataStore.existingBuffer(for: tab.id), - !existing.isEvicted { - cacheRowProvider(for: tab) - } wireDataTabDelegateStableRefs() refreshDataTabDelegateMutableRefs() coordinator.dataTabDelegate = dataTabDelegate coordinator.onTeardown = { [self] in - providerCache.removeAll() sortCache.removeAll() cachedChangeManager = nil coordinator.dataTabDelegate = nil } } - .onChange(of: tabManager.selectedTab?.schemaVersion) { _, newVersion in - guard let tab = tabManager.selectedTab, newVersion != nil else { return } - cacheRowProvider(for: tab) - } - .onChange(of: tabManager.selectedTab?.metadataVersion) { _, _ in - guard let tab = tabManager.selectedTab else { return } - cacheRowProvider(for: tab) - } - .onChange(of: tabManager.selectedTab?.display.activeResultSetId) { _, _ in - guard let tab = tabManager.selectedTab else { return } - cacheRowProvider(for: tab) - } .onChange(of: selectionState.indices) { _, newIndices in onSelectionChange(newIndices) } @@ -197,7 +166,6 @@ struct MainEditorContentView: View { dataTabDelegate.coordinator = coordinator dataTabDelegate.columnVisibilityManager = columnVisibilityManager dataTabDelegate.selectionState = selectionState - dataTabDelegate.editingCell = $editingCell dataTabDelegate.onCellEdit = onCellEdit dataTabDelegate.onSort = onSort dataTabDelegate.onUndoInsert = onUndoInsert @@ -437,11 +405,8 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } case .json: - let jsonBuffer = coordinator.rowDataStore.buffer(for: tab.id) ResultsJsonView( - columns: jsonBuffer.columns, - columnTypes: jsonBuffer.columnTypes, - rows: jsonBuffer.rows, + tableRows: resolvedTableRows(for: tab), selectedRowIndices: selectionState.indices ) case .data: @@ -449,13 +414,11 @@ struct MainEditorContentView: View { ExplainResultView(text: explainText, executionTime: tab.display.explainExecutionTime, plan: tab.display.explainPlan) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - // Result tab bar (when multiple result sets) if tab.display.resultSets.count > 1 { resultTabBar(tab: tab) Divider() } - // Inline error banner (when active result set has error) if let error = tab.display.activeResultSet?.errorMessage { InlineErrorBanner( message: error, @@ -464,8 +427,7 @@ struct MainEditorContentView: View { Divider() } - // Content: success view OR filter+grid - let resolvedBuffer = coordinator.rowDataStore.buffer(for: tab.id) + let resolvedRows = resolvedTableRows(for: tab) if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, !tab.execution.isExecuting { @@ -474,7 +436,7 @@ struct MainEditorContentView: View { executionTime: rs.executionTime, statusMessage: rs.statusMessage ) - } else if resolvedBuffer.columns.isEmpty && tab.execution.errorMessage == nil + } else if resolvedRows.columns.isEmpty && tab.execution.errorMessage == nil && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting { if tab.display.resultSets.isEmpty { @@ -487,11 +449,10 @@ struct MainEditorContentView: View { ) } } else { - // Filter panel (collapsible, above data grid) if filterStateManager.isVisible && tab.tabType == .table { FilterPanelView( filterState: filterStateManager, - columns: resolvedBuffer.columns, + columns: resolvedRows.columns, primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, onApply: onApplyFilters, @@ -500,8 +461,8 @@ struct MainEditorContentView: View { Divider() } - if tab.tabType == .query && !resolvedBuffer.columns.isEmpty - && resolvedBuffer.rows.isEmpty && tab.execution.lastExecutedAt != nil + if tab.tabType == .query && !resolvedRows.columns.isEmpty + && resolvedRows.rows.isEmpty && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting && !filterStateManager.hasAppliedFilters { emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime) @@ -525,9 +486,7 @@ struct MainEditorContentView: View { activeResultSetId: Binding( get: { tab.display.activeResultSetId }, set: { newId in - if let tabIdx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[tabIdx].display.activeResultSetId = newId - } + coordinator.switchActiveResultSet(to: newId, in: tab.id) } ), onClose: { id in @@ -556,8 +515,17 @@ struct MainEditorContentView: View { private func dataGridView(tab: QueryTab) -> some View { let isEditable = tab.tableContext.isEditable && !tab.tableContext.isView && !coordinator.safeModeLevel.blocksAllWrites + let tabId = tab.id DataGridView( - rowProvider: rowProvider(for: tab), + tableRowsProvider: { [coordinator] in + resolvedTableRowsForTab(coordinator: coordinator, tabId: tabId) + }, + tableRowsMutator: { [coordinator] mutate in + coordinator.mutateActiveTableRows(for: tabId) { rows in + mutate(&rows) + return .none + } + }, changeManager: currentChangeManager, schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, @@ -572,101 +540,42 @@ struct MainEditorContentView: View { showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers, hiddenColumns: columnVisibilityManager.hiddenColumns ), + sortedIDs: sortedIDsForTab(tab), + displayFormats: displayFormats(for: tab), delegate: dataTabDelegate, selectedRowIndices: Binding( get: { selectionState.indices }, set: { selectionState.indices = $0 } ), sortState: sortStateBinding(for: tab), - editingCell: $editingCell, columnLayout: columnLayoutBinding(for: tab) ) .frame(maxHeight: .infinity, alignment: .top) } - private func rowProvider(for tab: QueryTab) -> InMemoryRowProvider { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - if buffer.isEvicted { - providerCache.remove(for: tab.id) - return makeRowProvider(for: tab) - } - if let cached = providerCache.provider( - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) { - return cached - } - let provider = makeRowProvider(for: tab) - providerCache.store( - provider, - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) - return provider - } - - private func cacheRowProvider(for tab: QueryTab) { - let provider = makeRowProvider(for: tab) - providerCache.store( - provider, - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) + private func resolvedTableRows(for tab: QueryTab) -> TableRows { + coordinator.tableRowsStore.existingTableRows(for: tab.id) ?? TableRows() } - private func makeRowProvider(for tab: QueryTab) -> InMemoryRowProvider { - let provider: InMemoryRowProvider - - // Use active ResultSet data when available (multi-statement results) - if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { - provider = InMemoryRowProvider( - rowBuffer: rs.rowBuffer, - sortIndices: sortIndicesForTab(tab), - columns: rs.resultColumns, - columnDefaults: rs.columnDefaults, - columnTypes: rs.columnTypes, - columnForeignKeys: rs.columnForeignKeys, - columnEnumValues: rs.columnEnumValues, - columnNullable: rs.columnNullable - ) - } else { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - provider = InMemoryRowProvider( - rowBuffer: buffer, - sortIndices: sortIndicesForTab(tab), - columns: buffer.columns, - columnDefaults: buffer.columnDefaults, - columnTypes: buffer.columnTypes, - columnForeignKeys: buffer.columnForeignKeys, - columnEnumValues: buffer.columnEnumValues, - columnNullable: buffer.columnNullable - ) - } - - applyDisplayFormats(to: provider, tab: tab) - return provider + @MainActor + private func resolvedTableRowsForTab(coordinator: MainContentCoordinator, tabId: UUID) -> TableRows { + coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() } - private func applyDisplayFormats(to provider: InMemoryRowProvider, tab: QueryTab) { - let columns = provider.columns - let columnTypes = provider.columnTypes - guard !columns.isEmpty else { return } + private func displayFormats(for tab: QueryTab) -> [ValueDisplayFormat?] { + let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id) + let columns = tableRows?.columns ?? [] + let columnTypes = tableRows?.columnTypes ?? [] + guard !columns.isEmpty else { return [] } let settings = AppSettingsManager.shared.dataGrid let service = ValueDisplayFormatService.shared - // Auto-detect formats when the setting is enabled var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) if settings.enableSmartValueDetection { let sampleRows: [[String?]]? = { - let rows = tab.display.activeResultSet?.resultRows ?? coordinator.rowDataStore.buffer(for: tab.id).rows - return rows.isEmpty ? nil : Array(rows.prefix(10)) + let rows = tableRows?.rows.prefix(10).map(\.values) ?? [] + return rows.isEmpty ? nil : Array(rows) }() detected = ValueDisplayDetector.detect( columns: columns, @@ -674,7 +583,6 @@ struct MainEditorContentView: View { sampleValues: sampleRows ) - // Update service's auto-detected formats var autoMap: [String: ValueDisplayFormat] = [:] for (i, format) in detected.enumerated() where i < columns.count { if let format { @@ -686,7 +594,6 @@ struct MainEditorContentView: View { service.clearAutoDetectedFormats(connectionId: connectionId, tableName: tab.tableContext.tableName) } - // Merge with stored overrides (override > detection > nil) let connId = connectionId let tblName = tab.tableContext.tableName var merged = detected @@ -702,79 +609,57 @@ struct MainEditorContentView: View { } } - // Only set if there's at least one non-nil format - if merged.contains(where: { $0 != nil }) { - provider.updateDisplayFormats(merged) - } + return merged.contains(where: { $0 != nil }) ? merged : [] } - /// Returns sort index permutation for a tab, or nil if no sorting is needed. + /// Returns the display order as a permutation of `RowID`, or nil when no sort applies. /// For table tabs, sorting is handled server-side via SQL ORDER BY. - private func sortIndicesForTab(_ tab: QueryTab) -> [Int]? { - // Resolve data source: active ResultSet or tab-level fallback - let rowBuffer: RowBuffer - let rows: [[String?]] - let colTypes: [ColumnType] - if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { - rowBuffer = rs.rowBuffer - rows = rs.resultRows - colTypes = rs.columnTypes - } else { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - rowBuffer = buffer - rows = buffer.rows - colTypes = buffer.columnTypes - } - - guard !rowBuffer.isEvicted else { return nil } - - // Table tabs: no client-side sorting + private func sortedIDsForTab(_ tab: QueryTab) -> [RowID]? { if tab.tabType == .table { return nil } - // Query tabs: apply client-side sorting guard tab.sortState.isSorting else { return nil } - // Check coordinator's async sort cache (for large datasets sorted on background thread) + let resolvedRows = resolvedTableRows(for: tab) + guard !resolvedRows.rows.isEmpty else { + return nil + } + let colTypes = resolvedRows.columnTypes + if let cached = coordinator.querySortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, cached.schemaVersion == tab.schemaVersion { - return cached.sortedIndices + return cached.sortedIDs } - // For datasets sorted async, return nil (unsorted) until cache is ready - if rows.count > 1_000 { + if resolvedRows.rows.count > 1_000 { return nil } - // Small dataset: sort synchronously with view-level cache if let cached = sortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, cached.schemaVersion == tab.schemaVersion { - return cached.sortedIndices + return cached.sortedIDs } let sortColumns = tab.sortState.columns - let indices = Array(rows.indices) - let sortedIndices = indices.sorted { idx1, idx2 in - let row1 = rows[idx1] - let row2 = rows[idx2] + let storageRows = resolvedRows.rows + let sortedIndices = Array(storageRows.indices).sorted { idx1, idx2 in + let row1 = storageRows[idx1].values + let row2 = storageRows[idx2].values for sortCol in sortColumns { - let val1 = - sortCol.columnIndex < row1.count + let val1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex] ?? "") : "" - let val2 = - sortCol.columnIndex < row2.count + let val2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex] ?? "") : "" - let colType = - sortCol.columnIndex < colTypes.count + let colType = sortCol.columnIndex < colTypes.count ? colTypes[sortCol.columnIndex] : nil let result = RowSortComparator.compare(val1, val2, columnType: colType) if result == .orderedSame { continue } @@ -784,16 +669,16 @@ struct MainEditorContentView: View { } return false } + let sortedIDs = sortedIndices.map { storageRows[$0].id } - // Cache the result sortCache[tab.id] = SortedRowsCache( - sortedIndices: sortedIndices, + sortedIDs: sortedIDs, columnIndex: tab.sortState.columnIndex ?? -1, direction: tab.sortState.direction, schemaVersion: tab.schemaVersion ) - return sortedIndices + return sortedIDs } private func sortStateBinding(for tab: QueryTab) -> Binding { @@ -826,12 +711,12 @@ struct MainEditorContentView: View { // MARK: - Status Bar private func statusBar(tab: QueryTab) -> some View { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) + let resolvedRows = resolvedTableRows(for: tab) return MainStatusBarView( - snapshot: StatusBarSnapshot(tab: tab, buffer: buffer), + snapshot: StatusBarSnapshot(tab: tab, tableRows: resolvedRows), filterStateManager: filterStateManager, columnVisibilityManager: columnVisibilityManager, - allColumns: buffer.columns, + allColumns: resolvedRows.columns, selectedRowIndices: selectionState.indices, viewMode: resultsViewModeBinding(for: tab), onFirstPage: onFirstPage, diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 65f201bbf..803ca3fec 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -17,12 +17,12 @@ struct StatusBarSnapshot: Equatable { let pagination: PaginationState let statusMessage: String? - init(tab: QueryTab?, buffer: RowBuffer?) { + init(tab: QueryTab?, tableRows: TableRows?) { self.tabId = tab?.id self.tabType = tab?.tabType - self.hasRows = !(buffer?.rows.isEmpty ?? true) - self.hasColumns = !(buffer?.columns.isEmpty ?? true) - self.rowCount = buffer?.rows.count ?? 0 + self.hasRows = !(tableRows?.rows.isEmpty ?? true) + self.hasColumns = !(tableRows?.columns.isEmpty ?? true) + self.rowCount = tableRows?.rows.count ?? 0 self.hasTableName = tab?.tableContext.tableName != nil self.pagination = tab?.pagination ?? PaginationState() self.statusMessage = tab?.execution.statusMessage diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index 5136b79aa..bc659cc49 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -71,24 +71,36 @@ extension MainContentCoordinator { pendingDeletes: inout Set ) { let originalValues = changeManager.getOriginalValues() + var deltas: [Delta] = [] if let index = tabManager.selectedTabIndex { let tabId = tabManager.tabs[index].id - let buffer = rowDataStore.buffer(for: tabId) - for (rowIndex, columnIndex, originalValue) in originalValues { - if rowIndex < buffer.rows.count, - columnIndex < buffer.rows[rowIndex].count { - buffer.rows[rowIndex][columnIndex] = originalValue + let insertedIDs = collectInsertedRowIDs( + tabId: tabId, + indices: changeManager.insertedRowIndices + ) + let edits = originalValues.map { (row: $0.0, column: $0.1, value: $0.2) } + if !edits.isEmpty { + let editDelta = mutateActiveTableRows(for: tabId) { rows in + rows.editMany(edits) + } + if editDelta != .none { + deltas.append(editDelta) } } - - let insertedIndices = changeManager.insertedRowIndices.sorted(by: >) - for rowIndex in insertedIndices { - if rowIndex < buffer.rows.count { - buffer.rows.remove(at: rowIndex) + if !insertedIDs.isEmpty { + let removeDelta = mutateActiveTableRows(for: tabId) { rows in + rows.remove(rowIDs: insertedIDs) + } + if removeDelta != .none { + deltas.append(removeDelta) } } } + for delta in deltas { + dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) + } + if let tableName = tabManager.selectedTab?.tableContext.tableName { filterStateManager.saveLastFilters(for: tableName) } @@ -103,4 +115,17 @@ extension MainContentCoordinator { Task { await refreshTables() } } + + private func collectInsertedRowIDs(tabId: UUID, indices: Set) -> Set { + guard !indices.isEmpty else { return [] } + guard let tableRows = tableRowsStore.existingTableRows(for: tabId) else { return [] } + var ids = Set() + for index in indices where index >= 0 && index < tableRows.rows.count { + let id = tableRows.rows[index].id + if id.isInserted { + ids.insert(id) + } + } + return ids + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index a2a19e400..853c6ee44 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -84,7 +84,7 @@ extension MainContentCoordinator { if needsQuery, let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - rowDataStore.setBuffer(RowBuffer(), for: tabId) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() } @@ -100,12 +100,12 @@ extension MainContentCoordinator { // New tab — build filtered query directly, run once guard let tabIndex = tabManager.selectedTabIndex else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) let filteredQuery = queryBuilder.buildFilteredQuery( tableName: referencedTable, schemaName: fkInfo.referencedSchema, filters: [filter], - columns: buffer.columns, + columns: tableRows.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift index 945456474..13db7f2f1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift @@ -26,7 +26,7 @@ extension MainContentCoordinator { self.tabManager.tabs[capturedTabIndex].pagination.reset() let tab = self.tabManager.tabs[capturedTabIndex] - let buffer = self.rowDataStore.buffer(for: tab.id) + let buffer = self.tableRowsStore.tableRows(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildFilteredQuery( tableName: capturedTableName, @@ -64,7 +64,7 @@ extension MainContentCoordinator { guard capturedTabIndex < self.tabManager.tabs.count else { return } let tab = self.tabManager.tabs[capturedTabIndex] - let buffer = self.rowDataStore.buffer(for: tab.id) + let buffer = self.tableRowsStore.tableRows(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildBaseQuery( tableName: capturedTableName, @@ -95,7 +95,7 @@ extension MainContentCoordinator { let tableName = tabManager.tabs[tabIndex].tableContext.tableName else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) + let buffer = tableRowsStore.tableRows(for: tab.id) let hasFilters = filterStateManager.hasAppliedFilters let exclusions = columnExclusions(for: tableName) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index 9107f5361..2f92cfc48 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -96,8 +96,12 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - let buffer = rowDataStore.buffer(for: tab.id) - buffer.rows.append(contentsOf: pagedResult.rows) + var pageOffset = 0 + let appendDelta = mutateActiveTableRows(for: tab.id) { rows in + pageOffset = rows.count + return rows.appendPage(pagedResult.rows, startingAt: rows.count) + } + let newCount = pageOffset + pagedResult.rows.count tab.schemaVersion += 1 tab.pagination.loadMoreOffset = pagedResult.nextOffset tab.pagination.hasMoreRows = pagedResult.hasMore @@ -106,11 +110,12 @@ extension MainContentCoordinator { tab.pagination.baseQueryForMore = nil } tabManager.tabs[idx] = tab + dataTabDelegate?.tableViewCoordinator?.applyDelta(appendDelta) toolbarState.setExecuting(false) if capturedGeneration == queryGeneration { currentQueryTask = nil } - progressLog.info("[loadMore] applied totalRows=\(buffer.rows.count)") + progressLog.info("[loadMore] applied totalRows=\(newCount)") } } catch { await MainActor.run { [weak self] in @@ -136,7 +141,7 @@ extension MainContentCoordinator { tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore else { return } - let loadedCount = rowDataStore.buffer(for: tab.id).rows.count + let loadedCount = tableRowsStore.tableRows(for: tab.id).rows.count let totalEstimate = tab.pagination.totalRowCount let message: String @@ -217,12 +222,14 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - let buffer = rowDataStore.buffer(for: tab.id) - buffer.rows = result.rows + let replaceDelta = mutateActiveTableRows(for: tab.id) { rows in + rows.replace(rows: result.rows) + } tab.execution.executionTime = result.executionTime tab.schemaVersion += 1 tab.pagination.resetLoadMore() tabManager.tabs[idx] = tab + dataTabDelegate?.tableViewCoordinator?.applyDelta(replaceDelta) toolbarState.setExecuting(false) toolbarState.lastQueryDuration = result.executionTime currentQueryTask = nil diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index b3ec4e71b..58613a2b7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -84,12 +84,12 @@ extension MainContentCoordinator { } let stmtTableName = await MainActor.run { extractTableName(from: sql) } - let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)") - rs.rowBuffer = RowBuffer( - rows: result.rows.map { row in row.map { $0.map { String($0) } } }, + let stmtRows = TableRows.from( + queryRows: result.rows.map { row in row.map { $0.map { String($0) } } }, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) + let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)", tableRows: stmtRows) rs.executionTime = result.executionTime rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage @@ -233,14 +233,14 @@ extension MainContentCoordinator { tableName = lastSelectSQL.flatMap { extractTableName(from: $0) } } - rowDataStore.setBuffer( - RowBuffer(rows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), + setActiveTableRows( + TableRows.from(queryRows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), for: updatedTab.id ) updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = tableName != nil && updatedTab.tableContext.isEditable } else { - rowDataStore.setBuffer(RowBuffer(), for: updatedTab.id) + setActiveTableRows(TableRows(), for: updatedTab.id) if updatedTab.tabType != .table { updatedTab.tableContext.tableName = nil } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 973358f32..efec19dd3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -125,7 +125,7 @@ extension MainContentCoordinator { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - rowDataStore.setBuffer(RowBuffer(), for: tabId) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } @@ -207,7 +207,7 @@ extension MainContentCoordinator { previewCoordinator.filterStateManager.clearAll() if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { let tabId = previewCoordinator.tabManager.tabs[tabIndex].id - previewCoordinator.rowDataStore.setBuffer(RowBuffer(), for: tabId) + previewCoordinator.setActiveTableRows(TableRows(), for: tabId) previewCoordinator.tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() previewCoordinator.toolbarState.isTableTab = true @@ -279,7 +279,7 @@ extension MainContentCoordinator { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - rowDataStore.setBuffer(RowBuffer(), for: tabId) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true @@ -389,7 +389,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - rowDataStore.tearDown() + tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in @@ -424,7 +424,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - rowDataStore.tearDown() + tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 71cd55be0..2d52d6238 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -188,20 +188,19 @@ extension MainContentCoordinator { return false } let tab = tabManager.tabs[idx] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) guard tab.tableContext.tableName == tableName, - !buffer.columnDefaults.isEmpty, + !tableRows.columnDefaults.isEmpty, !tab.tableContext.primaryKeyColumns.isEmpty else { return false } - // Ensure every ENUM/SET column has its allowed values loaded - let enumSetColumnNames: [String] = buffer.columns.enumerated().compactMap { i, name in - guard i < buffer.columnTypes.count, - buffer.columnTypes[i].isEnumType || buffer.columnTypes[i].isSetType else { return nil } + let enumSetColumnNames: [String] = tableRows.columns.enumerated().compactMap { i, name in + guard i < tableRows.columnTypes.count, + tableRows.columnTypes[i].isEnumType || tableRows.columnTypes[i].isSetType else { return nil } return name } if !enumSetColumnNames.isEmpty, - !enumSetColumnNames.allSatisfy({ buffer.columnEnumValues[$0] != nil }) { + !enumSetColumnNames.allSatisfy({ tableRows.columnEnumValues[$0] != nil }) { return false } return true @@ -249,7 +248,10 @@ extension MainContentCoordinator { guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } var updatedTab = tabManager.tabs[idx] - let newBuffer = RowBuffer(rows: rows, columns: columns, columnTypes: columnTypes) + var columnEnumValues: [String: [String]] = [:] + var columnDefaults: [String: String?] = [:] + var columnForeignKeys: [String: ForeignKeyInfo] = [:] + var columnNullable: [String: Bool] = [:] updatedTab.schemaVersion += 1 updatedTab.execution.executionTime = executionTime updatedTab.execution.rowsAffected = rowsAffected @@ -258,35 +260,48 @@ extension MainContentCoordinator { updatedTab.execution.lastExecutedAt = Date() updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = isEditable - // Populate enum values from column types for the enum popover - for (index, colType) in newBuffer.columnTypes.enumerated() { - if case .enumType(_, let values) = colType, let vals = values, index < newBuffer.columns.count { - newBuffer.columnEnumValues[newBuffer.columns[index]] = vals + for (index, colType) in columnTypes.enumerated() { + if case .enumType(_, let values) = colType, let vals = values, index < columns.count { + columnEnumValues[columns[index]] = vals } } - // Merge FK metadata into the same update if available if let metadata { - newBuffer.columnDefaults = metadata.columnDefaults - newBuffer.columnForeignKeys = metadata.columnForeignKeys - newBuffer.columnNullable = metadata.columnNullable + columnDefaults = metadata.columnDefaults + columnForeignKeys = metadata.columnForeignKeys + columnNullable = metadata.columnNullable for (col, vals) in metadata.columnEnumValues { - newBuffer.columnEnumValues[col] = vals + columnEnumValues[col] = vals } if let approxCount = metadata.approximateRowCount, approxCount > 0 { updatedTab.pagination.totalRowCount = approxCount updatedTab.pagination.isApproximateRowCount = true } + } else { + let existing = tableRowsStore.tableRows(for: updatedTab.id) + columnDefaults = existing.columnDefaults + columnForeignKeys = existing.columnForeignKeys + columnNullable = existing.columnNullable + for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil { + columnEnumValues[col] = vals + } } if hasSchema { updatedTab.metadataVersion += 1 } - rowDataStore.setBuffer(newBuffer, for: updatedTab.id) + let newTableRows = TableRows.from( + queryRows: rows, + columns: columns, + columnTypes: columnTypes, + columnDefaults: columnDefaults, + columnForeignKeys: columnForeignKeys, + columnEnumValues: columnEnumValues, + columnNullable: columnNullable + ) + setActiveTableRows(newTableRows, for: updatedTab.id) - // Create a ResultSet for this single-statement execution - let rs = ResultSet(label: tableName ?? "Result") - rs.rowBuffer = newBuffer + let rs = ResultSet(label: tableName ?? "Result", tableRows: newTableRows) rs.executionTime = updatedTab.execution.executionTime rs.rowsAffected = updatedTab.execution.rowsAffected rs.statusMessage = updatedTab.execution.statusMessage @@ -466,13 +481,16 @@ extension MainContentCoordinator { guard capturedGeneration == queryGeneration else { return } guard !Task.isCancelled else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - let buffer = rowDataStore.buffer(for: tabId) + let existing = tableRowsStore.tableRows(for: tabId) let hasNewValues = columnEnumValues.contains { key, value in - buffer.columnEnumValues[key] != value + existing.columnEnumValues[key] != value } if hasNewValues { - for (col, vals) in columnEnumValues { - buffer.columnEnumValues[col] = vals + mutateActiveTableRows(for: tabId) { rows in + for (col, vals) in columnEnumValues { + rows.columnEnumValues[col] = vals + } + return .columnsReplaced } tabManager.tabs[idx].metadataVersion += 1 } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift index 841ecba45..256f3680e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift @@ -331,12 +331,12 @@ extension MainContentCoordinator { } let stmtTableName = await MainActor.run { extractTableName(from: stmtSQL) } - let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)") - rs.rowBuffer = RowBuffer( - rows: result.rows.map { row in row.map { $0.map { String($0) } } }, + let stmtRows = TableRows.from( + queryRows: result.rows.map { row in row.map { $0.map { String($0) } } }, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) + let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)", tableRows: stmtRows) rs.executionTime = result.executionTime rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index d1a2ad00d..aa957e4db 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -1,16 +1,7 @@ -// -// MainContentCoordinator+RowOperations.swift -// TablePro -// -// Row manipulation operations for MainContentCoordinator -// - import Foundation extension MainContentCoordinator { - // MARK: - Row Operations - - func addNewRow(editingCell: inout CellPosition?) { + func addNewRow() { guard !safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } @@ -18,18 +9,30 @@ extension MainContentCoordinator { let tab = tabManager.tabs[tabIndex] guard tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } - let buffer = rowDataStore.buffer(for: tab.id) - guard let result = rowOperationsManager.addNewRow( - columns: buffer.columns, - columnDefaults: buffer.columnDefaults, - resultRows: &buffer.rows - ) else { return } + let tabId = tab.id + let columnDefaults = tableRowsStore.tableRows(for: tabId).columnDefaults + let columns = tableRowsStore.tableRows(for: tabId).columns + + dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + + var addResult: RowOperationsManager.AddNewRowResult? + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.addNewRow( + columns: columns, + columnDefaults: columnDefaults, + tableRows: &rows + ) + addResult = result + return result?.delta ?? .none + } + + guard let result = addResult else { return } selectionState.indices = [result.rowIndex] - editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) + dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) } func deleteSelectedRows(indices: Set) { @@ -40,50 +43,70 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tabId = tabManager.tabs[tabIndex].id - let buffer = rowDataStore.buffer(for: tabId) - let result = rowOperationsManager.deleteSelectedRows( - selectedIndices: indices, - resultRows: &buffer.rows + + var deleteResult = RowOperationsManager.DeleteRowsResult( + nextRowToSelect: -1, + physicallyRemovedIndices: [], + delta: .none ) + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.deleteSelectedRows( + selectedIndices: indices, + tableRows: &rows + ) + deleteResult = result + return result.delta + } - if result.nextRowToSelect >= 0 - && result.nextRowToSelect < buffer.rows.count { - selectionState.indices = [result.nextRowToSelect] + let totalRows = tableRowsStore.tableRows(for: tabId).count + if deleteResult.nextRowToSelect >= 0 && deleteResult.nextRowToSelect < totalRows { + selectionState.indices = [deleteResult.nextRowToSelect] } else { selectionState.indices.removeAll() } tabManager.tabs[tabIndex].hasUserInteraction = true - if !result.physicallyRemovedIndices.isEmpty { + if !deleteResult.physicallyRemovedIndices.isEmpty { querySortCache.removeValue(forKey: tabId) - dataTabDelegate?.dataGridDidRemoveRows( - at: IndexSet(result.physicallyRemovedIndices) - ) + dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta) + } else { + dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() } } - func duplicateSelectedRow(index: Int, editingCell: inout CellPosition?) { + func duplicateSelectedRow(index: Int) { guard !safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] guard tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } - let buffer = rowDataStore.buffer(for: tab.id) - guard index < buffer.rows.count else { return } - guard let result = rowOperationsManager.duplicateRow( - sourceRowIndex: index, - columns: buffer.columns, - resultRows: &buffer.rows - ) else { return } + let tabId = tab.id + let columns = tableRowsStore.tableRows(for: tabId).columns + guard index >= 0, index < tableRowsStore.tableRows(for: tabId).count else { return } + + dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + + var dupResult: RowOperationsManager.AddNewRowResult? + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.duplicateRow( + sourceRowIndex: index, + columns: columns, + tableRows: &rows + ) + dupResult = result + return result?.delta ?? .none + } + + guard let result = dupResult else { return } selectionState.indices = [result.rowIndex] - editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) + dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) } func undoInsertRow(at rowIndex: Int) { @@ -91,14 +114,24 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tabId = tabManager.tabs[tabIndex].id - let buffer = rowDataStore.buffer(for: tabId) - selectionState.indices = rowOperationsManager.undoInsertRow( - at: rowIndex, - resultRows: &buffer.rows, - selectedIndices: selectionState.indices + + var undoResult = RowOperationsManager.UndoInsertRowResult( + adjustedSelection: selectionState.indices, + delta: .none ) + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.undoInsertRow( + at: rowIndex, + tableRows: &rows, + selectedIndices: selectionState.indices + ) + undoResult = result + return result.delta + } + + selectionState.indices = undoResult.adjustedSelection querySortCache.removeValue(forKey: tabId) - dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(integer: rowIndex)) + dataTabDelegate?.tableViewCoordinator?.applyDelta(undoResult.delta) } func handleUndoResult(_ result: UndoResult) { @@ -106,16 +139,23 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) - if let adjustedSelection = rowOperationsManager.applyUndoResult( - result, resultRows: &buffer.rows - ) { + let tabId = tab.id + + var application = RowOperationsManager.UndoApplicationResult(adjustedSelection: nil, delta: .none) + mutateActiveTableRows(for: tabId) { rows in + let applied = rowOperationsManager.applyUndoResult(result, tableRows: &rows) + application = applied + return applied.delta + } + + if let adjustedSelection = application.adjustedSelection { selectionState.indices = adjustedSelection } tabManager.tabs[tabIndex].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) + querySortCache.removeValue(forKey: tabId) dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() + dataTabDelegate?.tableViewCoordinator?.applyDelta(application.delta) } func copySelectedRowsToClipboard(indices: Set) { @@ -123,10 +163,10 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: buffer.rows + tableRows: tableRows ) } @@ -135,11 +175,10 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: buffer.rows, - columns: buffer.columns, + tableRows: tableRows, includeHeaders: true ) } @@ -148,54 +187,58 @@ extension MainContentCoordinator { guard let index = tabManager.selectedTabIndex, !indices.isEmpty else { return } let tab = tabManager.tabs[index] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) let rows = indices.sorted().compactMap { idx -> [String?]? in - guard idx < buffer.rows.count else { return nil } - return buffer.rows[idx] + guard idx >= 0, idx < tableRows.count else { return nil } + return tableRows.rows[idx].values } guard !rows.isEmpty else { return } let converter = JsonRowConverter( - columns: buffer.columns, - columnTypes: buffer.columnTypes + columns: tableRows.columns, + columnTypes: tableRows.columnTypes ) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } - func pasteRows(editingCell: inout CellPosition?) { + func pasteRows() { guard !safeModeLevel.blocksAllWrites, let index = tabManager.selectedTabIndex else { return } let tab = tabManager.tabs[index] - guard tab.tabType == .table else { return } - let buffer = rowDataStore.buffer(for: tab.id) - let pastedRows = rowOperationsManager.pasteRowsFromClipboard( - columns: buffer.columns, - primaryKeyColumns: changeManager.primaryKeyColumns, - resultRows: &buffer.rows - ) - - if !pastedRows.isEmpty { - let newIndices = Set(pastedRows.map { $0.rowIndex }) - selectionState.indices = newIndices + let tabId = tab.id + let columns = tableRowsStore.tableRows(for: tabId).columns - tabManager.tabs[index].selectedRowIndices = newIndices - tabManager.tabs[index].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(newIndices)) + var pasteResult = RowOperationsManager.PasteRowsResult(pastedRows: [], delta: .none) + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.pasteRowsFromClipboard( + columns: columns, + primaryKeyColumns: changeManager.primaryKeyColumns, + tableRows: &rows + ) + pasteResult = result + return result.delta } - } - // MARK: - Cell Operations + guard !pasteResult.pastedRows.isEmpty else { return } + + let newIndices = Set(pasteResult.pastedRows.map { $0.rowIndex }) + selectionState.indices = newIndices + + tabManager.tabs[index].selectedRowIndices = newIndices + tabManager.tabs[index].hasUserInteraction = true + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.tableViewCoordinator?.applyDelta(pasteResult.delta) + } func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { guard let index = tabManager.selectedTabIndex else { return } let tabId = tabManager.tabs[index].id - let buffer = rowDataStore.buffer(for: tabId) - guard rowIndex < buffer.rows.count else { return } - - buffer.rows[rowIndex][columnIndex] = value + let delta = mutateActiveTableRows(for: tabId) { rows in + rows.edit(row: rowIndex, column: columnIndex, value: value) + } tabManager.tabs[index].hasUserInteraction = true + dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index d9909f5c6..af68e7f37 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -249,7 +249,7 @@ extension MainContentCoordinator { let firstRemovedIndex = tabManager.tabs .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 for tabId in tabIdsToRemove { - rowDataStore.removeBuffer(for: tabId) + tableRowsStore.removeTableRows(for: tabId) } tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } if !tabManager.tabs.isEmpty { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 7e65f610b..21b9feea8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -17,12 +17,14 @@ extension MainContentCoordinator { guard let tabIdx = tabManager.selectedTabIndex else { return } let rs = tabManager.tabs[tabIdx].display.resultSets.first { $0.id == id } guard rs?.isPinned != true else { return } + let tabId = tabManager.tabs[tabIdx].id tabManager.tabs[tabIdx].display.resultSets.removeAll { $0.id == id } if tabManager.tabs[tabIdx].display.activeResultSetId == id { - tabManager.tabs[tabIdx].display.activeResultSetId = tabManager.tabs[tabIdx].display.resultSets.last?.id + let newActiveId = tabManager.tabs[tabIdx].display.resultSets.last?.id + switchActiveResultSet(to: newActiveId, in: tabId) } if tabManager.tabs[tabIdx].display.resultSets.isEmpty { - rowDataStore.setBuffer(RowBuffer(), for: tabManager.tabs[tabIdx].id) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIdx].execution.errorMessage = nil tabManager.tabs[tabIdx].execution.rowsAffected = 0 tabManager.tabs[tabIdx].execution.executionTime = nil @@ -105,7 +107,7 @@ extension MainContentCoordinator { func openExportQueryResultsDialog() { guard let tab = tabManager.selectedTab, - !rowDataStore.buffer(for: tab.id).rows.isEmpty else { return } + !tableRowsStore.tableRows(for: tab.id).rows.isEmpty else { return } activeSheet = .exportQueryResults } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index 364bec943..998705365 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -23,10 +23,10 @@ extension MainContentCoordinator { let editedFields = editState.getEditedFields() guard !editedFields.isEmpty else { return } - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex in - guard rowIndex < buffer.rows.count else { return nil } - let originalRow = buffer.rows[rowIndex] + guard rowIndex < tableRows.rows.count else { return nil } + let originalRow = tableRows.rows[rowIndex].values return RowChange( rowIndex: rowIndex, type: .update, @@ -35,7 +35,7 @@ extension MainContentCoordinator { rowIndex: rowIndex, columnIndex: field.columnIndex, columnName: field.columnName, - oldValue: originalRow[field.columnIndex], + oldValue: field.columnIndex < originalRow.count ? originalRow[field.columnIndex] : nil, newValue: field.newValue ) }, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 015d18604..4e45c1b7e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -57,7 +57,7 @@ extension MainContentCoordinator { if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { let newTab = tabManager.tabs[newIndex] - let newBuffer = rowDataStore.buffer(for: newId) + let newRows = tableRowsStore.tableRows(for: newId) // Restore filter state for new tab filterStateManager.restoreFromTabState(newTab.filterState) @@ -75,9 +75,9 @@ extension MainContentCoordinator { } else { changeManager.configureForTable( tableName: newTab.tableContext.tableName ?? "", - columns: newBuffer.columns, + columns: newRows.columns, primaryKeyColumns: newTab.tableContext.primaryKeyColumns.isEmpty - ? newBuffer.columns.prefix(1).map { $0 } + ? newRows.columns.prefix(1).map { $0 } : newTab.tableContext.primaryKeyColumns, databaseType: connection.type, triggerReload: false @@ -112,7 +112,7 @@ extension MainContentCoordinator { // If the tab shows isExecuting but has no results, the previous query was // likely cancelled when the user rapidly switched away. Force-clear the stale // flag so the lazy-load check below can re-execute the query. - if newTab.execution.isExecuting && newBuffer.rows.isEmpty && newTab.execution.lastExecutedAt == nil { + if newTab.execution.isExecuting && newRows.rows.isEmpty && newTab.execution.lastExecutedAt == nil { let tabId = newId Task { [weak self] in guard let self, @@ -122,9 +122,9 @@ extension MainContentCoordinator { } } - let isEvicted = newBuffer.isEvicted + let isEvicted = tableRowsStore.isEvicted(newId) let needsLazyQuery = newTab.tabType == .table - && (newBuffer.rows.isEmpty || isEvicted) + && (newRows.rows.isEmpty || isEvicted) && (newTab.execution.lastExecutedAt == nil || isEvicted) && newTab.execution.errorMessage == nil && !newTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -154,15 +154,15 @@ extension MainContentCoordinator { private func evictInactiveTabs(excluding activeTabIds: Set) { let start = Date() - let candidates: [(tab: QueryTab, buffer: RowBuffer)] = tabManager.tabs.compactMap { tab in + let candidates: [(tab: QueryTab, rows: TableRows)] = tabManager.tabs.compactMap { tab in guard !activeTabIds.contains(tab.id), tab.execution.lastExecutedAt != nil, !tab.pendingChanges.hasChanges, - let buffer = rowDataStore.existingBuffer(for: tab.id), - !buffer.isEvicted, - !buffer.rows.isEmpty + let rows = tableRowsStore.existingTableRows(for: tab.id), + !tableRowsStore.isEvicted(tab.id), + !rows.rows.isEmpty else { return nil } - return (tab, buffer) + return (tab, rows) } let sorted = candidates.sorted { @@ -170,12 +170,12 @@ extension MainContentCoordinator { let t1 = $1.tab.execution.lastExecutedAt ?? .distantFuture if t0 != t1 { return t0 < t1 } let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.buffer.rows.count, - columnCount: $0.buffer.columns.count + rowCount: $0.rows.rows.count, + columnCount: $0.rows.columns.count ) let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.buffer.rows.count, - columnCount: $1.buffer.columns.count + rowCount: $1.rows.rows.count, + columnCount: $1.rows.columns.count ) return size0 > size1 } @@ -190,7 +190,7 @@ extension MainContentCoordinator { let toEvict = sorted.dropLast(maxInactiveLoaded) for entry in toEvict { - entry.buffer.evict() + tableRowsStore.evict(for: entry.tab.id) } Self.lifecycleLogger.debug( "[switch] evictInactiveTabs evicted=\(toEvict.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift new file mode 100644 index 000000000..e252f41c6 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -0,0 +1,49 @@ +// +// MainContentCoordinator+TableRowsMutation.swift +// TablePro +// +// Single mutation surface for the active ResultSet's TableRows. Mutations +// flow through the store; the per-ResultSet snapshot is only refreshed when +// the user switches result sets (save outgoing, load incoming) so editing +// one tab doesn't trigger an `@Observable` re-render of the whole editor. +// + +import Foundation + +extension MainContentCoordinator { + @discardableResult + func mutateActiveTableRows( + for tabId: UUID, + _ mutate: (inout TableRows) -> Delta + ) -> Delta { + var delta: Delta = .none + tableRowsStore.updateTableRows(for: tabId) { rows in + delta = mutate(&rows) + } + return delta + } + + func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID) { + tableRowsStore.setTableRows(tableRows, for: tabId) + notifyFullReplaceIfActive(tabId: tabId) + } + + func switchActiveResultSet(to resultSetId: UUID?, in tabId: UUID) { + guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + if let outgoing = tabManager.tabs[tabIdx].display.activeResultSet { + outgoing.tableRows = tableRowsStore.tableRows(for: tabId) + } + tabManager.tabs[tabIdx].display.activeResultSetId = resultSetId + if let incoming = tabManager.tabs[tabIdx].display.activeResultSet { + tableRowsStore.setTableRows(incoming.tableRows, for: tabId) + notifyFullReplaceIfActive(tabId: tabId) + } + } + + private func notifyFullReplaceIfActive(tabId: UUID) { + guard let idx = tabManager.selectedTabIndex, + idx < tabManager.tabs.count, + tabManager.tabs[idx].id == tabId else { return } + dataTabDelegate?.tableViewCoordinator?.applyFullReplace() + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 70806c38e..07f0244ab 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -40,10 +40,11 @@ extension MainContentCoordinator { DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false let needsLazyLoad = tabManager.selectedTab.map { tab in - let buffer = rowDataStore.buffer(for: tab.id) + let rows = tableRowsStore.tableRows(for: tab.id) + let isEvicted = tableRowsStore.isEvicted(tab.id) return tab.tabType == .table - && (buffer.rows.isEmpty || buffer.isEvicted) - && (tab.execution.lastExecutedAt == nil || buffer.isEvicted) + && (rows.rows.isEmpty || isEvicted) + && (tab.execution.lastExecutedAt == nil || isEvicted) && tab.execution.errorMessage == nil && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 7066017a1..516776968 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -16,19 +16,19 @@ extension MainContentView { guard let tab = coordinator.tabManager.selectedTab, !coordinator.selectionState.indices.isEmpty, let firstIndex = coordinator.selectionState.indices.min() else { return nil } - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - guard firstIndex < buffer.rows.count else { return nil } + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + guard firstIndex < tableRows.rows.count else { return nil } - let row = buffer.rows[firstIndex] + let row = tableRows.rows[firstIndex].values var data: [(column: String, value: String?, type: String)] = [] let service = ValueDisplayFormatService.shared let connId = coordinator.connection.id let tblName = tab.tableContext.tableName - for (i, col) in buffer.columns.enumerated() { + for (i, col) in tableRows.columns.enumerated() { var value = i < row.count ? row[i] : nil - let type = i < buffer.columnTypes.count ? buffer.columnTypes[i].displayName : "string" + let type = i < tableRows.columnTypes.count ? tableRows.columnTypes[i].displayName : "string" // Apply display format if active if let rawValue = value { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 9c761d52a..0ce5ace93 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -176,19 +176,18 @@ extension MainContentView { rightPanelState.editState.onFieldChanged = nil return } - let buffer = coordinator.rowDataStore.buffer(for: tab.id) + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) var allRows: [[String?]] = [] for index in selectedIndices.sorted() { - if index < buffer.rows.count { - allRows.append(buffer.rows[index]) + if index < tableRows.rows.count { + allRows.append(tableRows.rows[index].values) } } - // Enrich column types with loaded enum values from Phase 2b - var columnTypes = buffer.columnTypes - for (i, col) in buffer.columns.enumerated() where i < columnTypes.count { - if let values = buffer.columnEnumValues[col], !values.isEmpty { + var columnTypes = tableRows.columnTypes + for (i, col) in tableRows.columns.enumerated() where i < columnTypes.count { + if let values = tableRows.columnEnumValues[col], !values.isEmpty { let ct = columnTypes[i] if ct.isEnumType { columnTypes[i] = .enumType(rawType: ct.rawType, values: values) @@ -198,12 +197,10 @@ extension MainContentView { } } - // Clear stale sidebar edits after refresh/discard if !changeManager.hasChanges { rightPanelState.editState.clearEdits() } - // Collect columns modified in data grid so sidebar shows green dots var modifiedColumns = Set() for rowIndex in selectedIndices { modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(rowIndex)) @@ -217,12 +214,12 @@ extension MainContentView { } let pkColumns = Set(tab.tableContext.primaryKeyColumns) - let fkColumns = Set(buffer.columnForeignKeys.keys) + let fkColumns = Set(tableRows.columnForeignKeys.keys) rightPanelState.editState.configure( selectedRowIndices: selectedIndices, allRows: allRows, - columns: buffer.columns, + columns: tableRows.columns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, excludedColumnNames: excludedNames, @@ -239,15 +236,14 @@ extension MainContentView { let capturedEditState = rightPanelState.editState rightPanelState.editState.onFieldChanged = { columnIndex, newValue in guard let tab = capturedCoordinator.tabManager.selectedTab else { return } - let buffer = capturedCoordinator.rowDataStore.buffer(for: tab.id) + let tableRows = capturedCoordinator.tableRowsStore.tableRows(for: tab.id) let columnName = - columnIndex < buffer.columns.count ? buffer.columns[columnIndex] : "" + columnIndex < tableRows.columns.count ? tableRows.columns[columnIndex] : "" for rowIndex in capturedEditState.selectedRowIndices { - guard rowIndex < buffer.rows.count else { continue } - let originalRow = buffer.rows[rowIndex] + guard rowIndex < tableRows.rows.count else { continue } + let originalRow = tableRows.rows[rowIndex].values - // Use full (lazy-loaded) original value if available, not truncated row data let oldValue: String? if columnIndex < capturedEditState.fields.count, !capturedEditState.fields[columnIndex].isTruncated @@ -283,16 +279,16 @@ extension MainContentView { let capturedCoordinator = coordinator let capturedEditState = rightPanelState.editState - let buffer = coordinator.rowDataStore.buffer(for: tab.id) + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) if !excludedNames.isEmpty, selectedIndices.count == 1, let tableName = tab.tableContext.tableName, let pkColumn = tab.tableContext.primaryKeyColumn, let rowIndex = selectedIndices.first, - rowIndex < buffer.rows.count + rowIndex < tableRows.rows.count { - let row = buffer.rows[rowIndex] - if let pkColIndex = buffer.columns.firstIndex(of: pkColumn), + let row = tableRows.rows[rowIndex].values + if let pkColIndex = tableRows.columns.firstIndex(of: pkColumn), pkColIndex < row.count, let pkValue = row[pkColIndex] { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index d2f0dbe1c..7888826dc 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -101,11 +101,11 @@ extension MainContentView { private func buildQueryResultsSummary() -> String? { guard let tab = currentTab else { return nil } - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - guard !buffer.columns.isEmpty, !buffer.rows.isEmpty else { return nil } + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + guard !tableRows.columns.isEmpty, !tableRows.rows.isEmpty else { return nil } - let columns = buffer.columns - let rows = buffer.rows + let columns = tableRows.columns + let rows = tableRows.rows let maxRows = 10 let displayRows = Array(rows.prefix(maxRows)) @@ -114,7 +114,7 @@ extension MainContentView { for row in displayRows { let values = columns.indices.map { i in - let raw = i < row.count ? (row[i] ?? "NULL") : "NULL" + let raw = i < row.values.count ? (row.values[i] ?? "NULL") : "NULL" return (raw as NSString).length > 200 ? String(raw.prefix(200)) + "..." : raw } lines.append(values.joined(separator: " | ")) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 842ecdd9d..cb97526e2 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -283,8 +283,7 @@ extension MainContentView { pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, tableOperationOptions: $tableOperationOptions, - rightPanelState: rightPanelState, - editingCell: $editingCell + rightPanelState: rightPanelState ) actions.window = viewWindow coordinator.commandActions = actions diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 699b7adc4..8b0b19727 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -34,7 +34,6 @@ final class MainContentCommandActions { @ObservationIgnored private let pendingDeletes: Binding> @ObservationIgnored private let tableOperationOptions: Binding<[String: TableOperationOptions]> @ObservationIgnored private let rightPanelState: RightPanelState - @ObservationIgnored private let editingCell: Binding /// The window this instance belongs to — used for key-window guards. @ObservationIgnored weak var window: NSWindow? @@ -55,8 +54,7 @@ final class MainContentCommandActions { pendingTruncates: Binding>, pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, - rightPanelState: RightPanelState, - editingCell: Binding + rightPanelState: RightPanelState ) { self.coordinator = coordinator self.filterStateManager = filterStateManager @@ -67,7 +65,6 @@ final class MainContentCommandActions { self.pendingDeletes = pendingDeletes self.tableOperationOptions = tableOperationOptions self.rightPanelState = rightPanelState - self.editingCell = editingCell setupSaveAction() setupObservers() @@ -170,19 +167,14 @@ final class MainContentCommandActions { } observeKeyWindowOnly(.pasteRows) { [weak self] _ in - guard let self else { return } - var cell = self.editingCell.wrappedValue - self.coordinator?.pasteRows(editingCell: &cell) - self.editingCell.wrappedValue = cell + self?.coordinator?.pasteRows() } } // MARK: - Row Operations (Group A — Called Directly) func addNewRow() { - var cell = editingCell.wrappedValue - coordinator?.addNewRow(editingCell: &cell) - editingCell.wrappedValue = cell + coordinator?.addNewRow() } func deleteSelectedRows(rowIndices: Set? = nil) { @@ -214,10 +206,7 @@ final class MainContentCommandActions { func duplicateRow() { let indices = selectionState.indices guard let selectedIndex = indices.first, indices.count == 1 else { return } - - var cell = editingCell.wrappedValue - coordinator?.duplicateSelectedRow(index: selectedIndex, editingCell: &cell) - editingCell.wrappedValue = cell + coordinator?.duplicateSelectedRow(index: selectedIndex) } func copySelectedRows() { @@ -243,9 +232,7 @@ final class MainContentCommandActions { if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.pasteRows?() } else { - var cell = editingCell.wrappedValue - coordinator?.pasteRows(editingCell: &cell) - editingCell.wrappedValue = cell + coordinator?.pasteRows() } } @@ -380,10 +367,14 @@ final class MainContentCommandActions { } else if coordinator?.tabManager.tabs.isEmpty == true { window.close() } else { - coordinator?.rowDataStore.evictAll(except: nil) - coordinator?.tabManager.tabs.removeAll() - coordinator?.tabManager.selectedTabId = nil - coordinator?.toolbarState.isTableTab = false + if let coordinator { + for tab in coordinator.tabManager.tabs { + coordinator.tableRowsStore.removeTableRows(for: tab.id) + } + coordinator.tabManager.tabs.removeAll() + coordinator.tabManager.selectedTabId = nil + coordinator.toolbarState.isTableTab = false + } } Self.logger.info("[close] performClose done ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } @@ -683,7 +674,7 @@ final class MainContentCommandActions { let currentId = tab.display.activeResultSetId ?? tab.display.resultSets.last?.id, let currentIndex = tab.display.resultSets.firstIndex(where: { $0.id == currentId }), currentIndex > 0 else { return } - coordinator.tabManager.tabs[tabIndex].display.activeResultSetId = tab.display.resultSets[currentIndex - 1].id + coordinator.switchActiveResultSet(to: tab.display.resultSets[currentIndex - 1].id, in: tab.id) } func nextResultTab() { @@ -693,7 +684,7 @@ final class MainContentCommandActions { let currentId = tab.display.activeResultSetId ?? tab.display.resultSets.last?.id, let currentIndex = tab.display.resultSets.firstIndex(where: { $0.id == currentId }), currentIndex < tab.display.resultSets.count - 1 else { return } - coordinator.tabManager.tabs[tabIndex].display.activeResultSetId = tab.display.resultSets[currentIndex + 1].id + coordinator.switchActiveResultSet(to: tab.display.resultSets[currentIndex + 1].id, in: tab.id) } func closeResultTab() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index ee1dd418f..cf35d3462 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -21,9 +21,11 @@ enum DiscardAction { case filter } -/// Cache entry for async-sorted query tab rows (stores index permutation, not row copies) +/// Cache entry for async-sorted query tab rows. Stores a permutation of `RowID` so the +/// sort survives mutations: inserted rows append to the end of the sorted view, and +/// removed rows are dropped from the permutation without re-sorting. struct QuerySortCacheEntry { - let sortedIndices: [Int] + let sortedIDs: [RowID] let columnIndex: Int let direction: SortDirection let schemaVersion: Int @@ -87,7 +89,7 @@ final class MainContentCoordinator { let filterStateManager: FilterStateManager let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState - let rowDataStore = RowDataStore() + let tableRowsStore = TableRowsStore() // MARK: - Services @@ -338,9 +340,7 @@ final class MainContentCoordinator { func evictInactiveRowData() { let selectedId = tabManager.selectedTabId for tab in tabManager.tabs where tab.id != selectedId && !tab.pendingChanges.hasChanges { - guard let buffer = rowDataStore.existingBuffer(for: tab.id), - !buffer.isEvicted, !buffer.rows.isEmpty else { continue } - buffer.evict() + tableRowsStore.evict(for: tab.id) } } @@ -573,20 +573,15 @@ final class MainContentCoordinator { for task in activeSortTasks.values { task.cancel() } activeSortTasks.removeAll() - // Let the view layer release cached row providers before we drop RowBuffers. - // Called synchronously here because SwiftUI onChange handlers don't fire - // reliably on disappearing views. onTeardown?() onTeardown = nil - // Notify DataGridView coordinators to release NSTableView cell views NotificationCenter.default.post( name: Self.teardownNotification, object: connection.id ) - // Release heavy data so memory drops even if SwiftUI delays deallocation - rowDataStore.tearDown() + tableRowsStore.tearDown() querySortCache.removeAll() cachedTableColumnTypes.removeAll() cachedTableColumnNames.removeAll() @@ -1309,8 +1304,8 @@ final class MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) - guard columnIndex >= 0 && columnIndex < buffer.columns.count else { return } + let tableRows = tableRowsStore.tableRows(for: tab.id) + guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } var currentSort = tab.sortState let newDirection: SortDirection = ascending ? .ascending : .descending @@ -1338,7 +1333,7 @@ final class MainContentCoordinator { // When more rows are available server-side, re-execute with ORDER BY // instead of sorting locally (we only have a partial result set) if tab.pagination.hasMoreRows { - let columnName = buffer.columns[columnIndex] + let columnName = tableRows.columns[columnIndex] let direction = currentSort.columns.first?.direction == .ascending ? "ASC" : "DESC" let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) @@ -1355,13 +1350,14 @@ final class MainContentCoordinator { tabManager.tabs[tabIndex].sortState = currentSort tabManager.tabs[tabIndex].hasUserInteraction = true tabManager.tabs[tabIndex].pagination.reset() - let rows = buffer.rows let tabId = tab.id let schemaVersion = tab.schemaVersion let sortColumns = currentSort.columns - let colTypes = buffer.columnTypes + let colTypes = tableRows.columnTypes + let storageRows = tableRows.rows + let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, $0.values) } - if rows.count > 1_000 { + if storageRows.count > 1_000 { // Sort on background thread to avoid UI freeze activeSortTasks[tabId]?.cancel() activeSortTasks.removeValue(forKey: tabId) @@ -1371,8 +1367,8 @@ final class MainContentCoordinator { let sortStartTime = Date() let task = Task.detached { [weak self] in - let sortedIndices = Self.multiColumnSortIndices( - rows: rows, + let sortedIDs = Self.multiColumnSortedIDs( + rows: snapshotRows, sortColumns: sortColumns, columnTypes: colTypes ) @@ -1386,7 +1382,7 @@ final class MainContentCoordinator { return } self.querySortCache[tabId] = QuerySortCacheEntry( - sortedIndices: sortedIndices, + sortedIDs: sortedIDs, columnIndex: sortColumns.first?.columnIndex ?? 0, direction: sortColumns.first?.direction ?? .ascending, schemaVersion: schemaVersion @@ -1411,7 +1407,7 @@ final class MainContentCoordinator { let tabId = tab.id let capturedSort = currentSort let capturedQuery = tab.content.query - let capturedColumns = buffer.columns + let capturedColumns = tableRows.columns confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in guard let self, confirmed, let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } @@ -1428,13 +1424,12 @@ final class MainContentCoordinator { } } - /// Multi-column sort returning index permutation (nonisolated for background thread). - /// Returns an array of indices into the original `rows` array, sorted by the given columns. - nonisolated private static func multiColumnSortIndices( - rows: [[String?]], + /// Multi-column sort returning a permutation of `RowID` (nonisolated for background thread). + nonisolated private static func multiColumnSortedIDs( + rows: [(id: RowID, values: [String?])], sortColumns: [SortColumn], columnTypes: [ColumnType] = [] - ) -> [Int] { + ) -> [RowID] { // Fast path: single-column sort avoids intermediate key array allocation if sortColumns.count == 1 { let col = sortColumns[0] @@ -1443,18 +1438,20 @@ final class MainContentCoordinator { let colType = colIndex < columnTypes.count ? columnTypes[colIndex] : nil var indices = Array(0..? @@ -180,7 +179,7 @@ struct MainContentView: View { isPresented: dismissBinding, mode: .queryResults( connection: connectionWithCurrentDatabase, - rowBuffer: coordinator.rowDataStore.buffer(for: tab.id), + tableRows: coordinator.tableRowsStore.tableRows(for: tab.id), suggestedFileName: fileName ) ) @@ -351,7 +350,7 @@ struct MainContentView: View { handleStructureChange() } .onChange(of: currentTab?.schemaVersion) { _, _ in - let columns = currentTab.map { coordinator.rowDataStore.buffer(for: $0.id).columns } + let columns = currentTab.map { coordinator.tableRowsStore.tableRows(for: $0.id).columns } handleColumnsChange(newColumns: columns) } .task { handleConnectionStatusChange() } @@ -401,7 +400,6 @@ struct MainContentView: View { windowId: windowId, connectionId: connection.id, selectionState: coordinator.selectionState, - editingCell: $editingCell, onCellEdit: { rowIndex, colIndex, value in coordinator.updateCellInTab( rowIndex: rowIndex, columnIndex: colIndex, value: value) @@ -413,7 +411,7 @@ struct MainContentView: View { isMultiSort: isMultiSort) }, onAddRow: { - coordinator.addNewRow(editingCell: &editingCell) + coordinator.addNewRow() }, onUndoInsert: { rowIndex in coordinator.undoInsertRow(at: rowIndex) diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index 8427d60a1..c3c2bcc99 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -360,32 +360,22 @@ final class DataGridCellFactory { /// Since the cell font is monospaced, we avoid per-row CoreText measurement /// and instead multiply character count by the pre-computed glyph advance width. /// This reduces the cost from O(sampleRows * CoreText) to O(sampleRows * 1). - /// - /// - Parameters: - /// - columnName: The column header name - /// - columnIndex: Index of the column - /// - rowProvider: Provider to get sample row data - /// - Returns: Optimal column width within min/max bounds func calculateOptimalColumnWidth( for columnName: String, columnIndex: Int, - rowProvider: InMemoryRowProvider + tableRows: TableRows ) -> CGFloat { - // For header: use character count * average proportional char width - // instead of CoreText measurement. ~0.6 of mono width is a good estimate - // for proportional system font. let headerCharCount = (columnName as NSString).length var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48 - let totalRows = rowProvider.totalRowCount - let columnCount = rowProvider.columns.count - // Reduce sample count for wide tables to keep total work bounded + let totalRows = tableRows.count + let columnCount = tableRows.columns.count let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount let step = max(1, totalRows / effectiveSampleCount) let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { - guard let value = rowProvider.value(atRow: i, column: columnIndex) else { continue } + guard let value = tableRows.value(at: i, column: columnIndex) else { continue } let charCount = min((value as NSString).length, Self.maxMeasureChars) let cellWidth = CGFloat(charCount) * charWidth + 16 @@ -404,19 +394,19 @@ final class DataGridCellFactory { func calculateFitToContentWidth( for columnName: String, columnIndex: Int, - rowProvider: InMemoryRowProvider + tableRows: TableRows ) -> CGFloat { let headerCharCount = (columnName as NSString).length var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48 - let totalRows = rowProvider.totalRowCount - let columnCount = rowProvider.columns.count + let totalRows = tableRows.count + let columnCount = tableRows.columns.count let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount let step = max(1, totalRows / effectiveSampleCount) let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { - guard let value = rowProvider.value(atRow: i, column: columnIndex) else { continue } + guard let value = tableRows.value(at: i, column: columnIndex) else { continue } let charCount = (value as NSString).length let cellWidth = CGFloat(charCount) * charWidth + 16 diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 898748a5d..de022b85d 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1,23 +1,20 @@ -// -// DataGridCoordinator.swift -// TablePro -// -// Coordinator handling NSTableView delegate and data source for DataGridView. -// - import AppKit import SwiftUI // MARK: - Coordinator -/// Coordinator handling NSTableView delegate and data source @MainActor final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate { - var rowProvider: InMemoryRowProvider + var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } + var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } + var cachedTableRows: TableRows = TableRows() var changeManager: AnyChangeManager var isEditable: Bool + var sortedIDs: [RowID]? + private(set) var columnDisplayFormats: [ValueDisplayFormat?] = [] + private var displayCache: [RowID: [String?]] = [:] weak var delegate: (any DataGridViewDelegate)? weak var activeFKPreviewPopover: NSPopover? var dropdownColumns: Set? @@ -27,25 +24,20 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var databaseType: DatabaseType? var tableName: String? var primaryKeyColumns: [String] = [] - /// First PK column, for copy-as-SQL and single-column contexts var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? - /// Capture current column widths and order from the live NSTableView - /// and persist directly to ColumnLayoutStorage. Called from dismantleNSView - /// to guarantee layout is saved even when the view is torn down without - /// a SwiftUI render cycle (e.g., closing a tab). func persistColumnLayoutToStorage() { guard tabType == .table else { return } guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return } - guard !rowProvider.columns.isEmpty else { return } + guard !cachedTableRows.columns.isEmpty else { return } var widths: [String: CGFloat] = [:] var order: [String] = [] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = DataGridView.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let name = rowProvider.columns[colIndex] + colIndex < cachedTableRows.columns.count else { continue } + let name = cachedTableRows.columns[colIndex] widths[name] = column.width order.append(name) } @@ -59,13 +51,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData weak var tableView: NSTableView? let cellFactory = DataGridCellFactory() + let tableRowsController = TableRowsController() var overlayEditor: CellOverlayEditor? - // Settings observer for real-time updates var settingsObserver: NSObjectProtocol? - // Theme observer for font/color changes var themeObserver: NSObjectProtocol? - /// Snapshot of last-seen data grid settings for change detection private var lastDataGridSettings: DataGridSettings @Binding var selectedRowIndices: Set @@ -78,14 +68,12 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private(set) var enumOrSetColumns: Set = [] private(set) var fkColumns: Set = [] var isSyncingSortDescriptors: Bool = false - /// Suppresses selection delegate callbacks during programmatic selection sync var isSyncingSelection = false var isRebuildingColumns: Bool = false var hasUserResizedColumns: Bool = false - /// Guards against two-frame bounce when async column layout write-back triggers updateNSView var isWritingColumnLayout: Bool = false var isEscapeCancelling = false - /// Debounced task for persisting column layout after resize/reorder + var isCommittingCellEdit = false var layoutPersistTask: Task? static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") @@ -99,13 +87,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } init( - rowProvider: InMemoryRowProvider, changeManager: AnyChangeManager, isEditable: Bool, selectedRowIndices: Binding>, delegate: (any DataGridViewDelegate)? ) { - self.rowProvider = rowProvider self.changeManager = changeManager self.isEditable = isEditable self._selectedRowIndices = selectedRowIndices @@ -114,10 +100,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData super.init() updateCache() - // Subscribe to theme changes for font/color updates observeThemeChanges() - // Subscribe to settings changes for real-time updates settingsObserver = NotificationCenter.default.addObserver( forName: .dataGridSettingsDidChange, object: nil, @@ -137,20 +121,17 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData tableView.tile() } - // Font changes are handled by .themeDidChange observer. - // Check for data format changes that need cell re-rendering. let dataChanged = prev.dateFormat != settings.dateFormat || prev.nullDisplay != settings.nullDisplay || prev.enableSmartValueDetection != settings.enableSmartValueDetection - // When smart detection is toggled off, clear display formats so they stop being applied if prev.enableSmartValueDetection != settings.enableSmartValueDetection && !settings.enableSmartValueDetection { - self.rowProvider.updateDisplayFormats([]) + self.updateDisplayFormats([]) } if dataChanged { - self.rowProvider.invalidateDisplayCache() + self.invalidateDisplayCache() let visibleRect = tableView.visibleRect let visibleRange = tableView.rows(in: visibleRect) if visibleRange.length > 0 { @@ -177,7 +158,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } - /// Subscribe to coordinator teardown to release NSTableView cell views. func observeTeardown(connectionId: UUID) { teardownObserver = NotificationCenter.default.addObserver( forName: MainContentCoordinator.teardownNotification, @@ -190,22 +170,22 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } - /// Release all data and cell views from the NSTableView. - /// Called during coordinator teardown to free memory while SwiftUI holds the view. private func releaseData() { overlayEditor?.dismiss(commit: false) - rowProvider = InMemoryRowProvider(rows: [], columns: []) + cachedTableRows = TableRows() rowVisualStateCache.removeAll() + displayCache.removeAll() + columnDisplayFormats = [] cachedRowCount = 0 cachedColumnCount = 0 - // Remove columns and reload to release cell views + sortedIDs = nil if let tableView { while let col = tableView.tableColumns.last { tableView.removeTableColumn(col) } tableView.reloadData() } - // Release delegate + tableRowsController.detach() delegate = nil activeFKPreviewPopover?.close() activeFKPreviewPopover = nil @@ -226,8 +206,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func updateCache() { - cachedRowCount = rowProvider.totalRowCount - cachedColumnCount = rowProvider.columns.count + cachedTableRows = tableRowsProvider() + cachedRowCount = sortedIDs?.count ?? cachedTableRows.count + cachedColumnCount = cachedTableRows.columns.count } func applyInsertedRows(_ indices: IndexSet) { @@ -248,26 +229,207 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyFullReplace() { guard let tableView else { return } - rowProvider.invalidateDisplayCache() + displayCache.removeAll() rebuildVisualStateCache() updateCache() tableView.reloadData() lastIdentity = nil } + func displayRow(at displayIndex: Int) -> Row? { + let tableRows = tableRowsProvider() + if let sorted = sortedIDs { + guard displayIndex >= 0, displayIndex < sorted.count else { return nil } + return tableRows.row(withID: sorted[displayIndex]) + } + guard displayIndex >= 0, displayIndex < tableRows.count else { return nil } + return tableRows.rows[displayIndex] + } + + func tableRowsIndex(forDisplayRow displayIndex: Int) -> Int? { + if let sorted = sortedIDs { + guard displayIndex >= 0, displayIndex < sorted.count else { return nil } + return tableRowsProvider().index(of: sorted[displayIndex]) + } + let count = tableRowsProvider().count + guard displayIndex >= 0, displayIndex < count else { return nil } + return displayIndex + } + + func displayValue(forID id: RowID, column: Int, rawValue: String?, columnType: ColumnType?) -> String? { + if let cachedRow = displayCache[id], column >= 0, column < cachedRow.count, let cached = cachedRow[column] { + return cached + } + let format = column >= 0 && column < columnDisplayFormats.count ? columnDisplayFormats[column] : nil + let formatted = CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: format) ?? rawValue + + var rowCache = displayCache[id] ?? [] + let neededCount = max(column + 1, columnDisplayFormats.count) + if rowCache.count < neededCount { + rowCache.append(contentsOf: Array(repeating: nil, count: neededCount - rowCache.count)) + } + if column >= 0, column < rowCache.count { + rowCache[column] = formatted + } + displayCache[id] = rowCache + return formatted + } + + func invalidateDisplayCache() { + displayCache.removeAll() + } + + func updateDisplayFormats(_ formats: [ValueDisplayFormat?]) { + columnDisplayFormats = formats + displayCache.removeAll() + } + + func syncDisplayFormats(_ formats: [ValueDisplayFormat?]) { + guard formats != columnDisplayFormats else { return } + columnDisplayFormats = formats + displayCache.removeAll() + } + + func preWarmDisplayCache(upTo rowCount: Int) { + let tableRows = tableRowsProvider() + let displayCount = sortedIDs?.count ?? tableRows.count + let count = min(rowCount, displayCount) + guard count > 0 else { return } + for displayIndex in 0..() + aliveIDs.reserveCapacity(tableRows.count) + for row in tableRows.rows { + aliveIDs.insert(row.id) + } + displayCache = displayCache.filter { aliveIDs.contains($0.key) } + } + + private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) { + guard let row = displayRow(at: displayIndex) else { return } + guard var rowCache = displayCache[row.id], column >= 0, column < rowCache.count else { return } + rowCache[column] = nil + displayCache[row.id] = rowCache + } + + func applyDelta(_ delta: Delta) { + switch delta { + case .cellChanged(let row, let column): + guard let tableView else { return } + let tableColumn = DataGridView.tableColumnIndex(for: column) + guard row >= 0, row < tableView.numberOfRows else { return } + guard tableColumn >= 0, tableColumn < tableView.numberOfColumns else { return } + invalidateDisplayCache(forDisplayRow: row, column: column) + tableView.reloadData( + forRowIndexes: IndexSet(integer: row), + columnIndexes: IndexSet(integer: tableColumn) + ) + case .cellsChanged(let positions): + guard !positions.isEmpty, let tableView else { return } + var rowSet = IndexSet() + var colSet = IndexSet() + for position in positions { + if position.row >= 0, position.row < tableView.numberOfRows { + rowSet.insert(position.row) + } + let tableColumn = DataGridView.tableColumnIndex(for: position.column) + if tableColumn >= 0, tableColumn < tableView.numberOfColumns { + colSet.insert(tableColumn) + } + invalidateDisplayCache(forDisplayRow: position.row, column: position.column) + } + guard !rowSet.isEmpty, !colSet.isEmpty else { return } + tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) + case .rowsInserted(let indices): + guard !indices.isEmpty else { return } + appendInsertedIDsToSortedIDs(at: indices) + applyInsertedRows(indices) + case .rowsRemoved(let indices): + guard !indices.isEmpty else { return } + removeMissingIDsFromSortedIDs() + pruneDisplayCacheToAliveIDs() + applyRemovedRows(indices) + case .columnsReplaced, .fullReplace: + sortedIDs = nil + displayCache.removeAll() + applyFullReplace() + } + } + + private func appendInsertedIDsToSortedIDs(at indices: IndexSet) { + guard sortedIDs != nil else { return } + let tableRows = tableRowsProvider() + for index in indices where index >= 0 && index < tableRows.count { + sortedIDs?.append(tableRows.rows[index].id) + } + } + + private func removeMissingIDsFromSortedIDs() { + guard sortedIDs != nil else { return } + let tableRows = tableRowsProvider() + var survivingIDs = Set() + survivingIDs.reserveCapacity(tableRows.count) + for row in tableRows.rows { + survivingIDs.insert(row.id) + } + sortedIDs?.removeAll { !survivingIDs.contains($0) } + } + func invalidateCachesForUndoRedo() { - rowProvider.invalidateDisplayCache() + displayCache.removeAll() rebuildVisualStateCache() updateCache() } - func rebuildColumnMetadataCache() { + func commitActiveCellEdit() { + guard let tableView, let window = tableView.window else { return } + if tableView.editedRow >= 0 { + window.makeFirstResponder(tableView) + return + } + if let firstResponder = window.firstResponder as? NSView, + firstResponder.isDescendant(of: tableView) { + window.makeFirstResponder(tableView) + } + } + + func beginEditing(displayRow: Int, column: Int) { + guard let tableView else { return } + let displayCol = DataGridView.tableColumnIndex(for: column) + guard displayRow >= 0, displayRow < tableView.numberOfRows, + displayCol >= 0, displayCol < tableView.numberOfColumns else { return } + tableView.scrollRowToVisible(displayRow) + tableView.selectRowIndexes(IndexSet(integer: displayRow), byExtendingSelection: false) + tableView.editColumn(displayCol, row: displayRow, with: nil, select: true) + } + + func rebuildColumnMetadataCache(from tableRows: TableRows) { var enumSet = Set() var fkSet = Set() - let columns = rowProvider.columns - let types = rowProvider.columnTypes - let enumValues = rowProvider.columnEnumValues - let fkKeys = rowProvider.columnForeignKeys + let columns = tableRows.columns + let types = tableRows.columnTypes + let enumValues = tableRows.columnEnumValues + let fkKeys = tableRows.columnForeignKeys for i in 0.. + if let sorted = sortedIDs { + insertedRowIndices = Set() + for (displayIndex, id) in sorted.enumerated() where id.isInserted { + insertedRowIndices.insert(displayIndex) + } + } else { + insertedRowIndices = changeManager.insertedRowIndices + } + + if !changeManager.hasChanges && insertedRowIndices.isEmpty { return } for rowChange in changeManager.rowChanges { let rowIndex = rowChange.rowIndex let isDeleted = rowChange.type == .delete - let isInserted = rowChange.type == .insert + let isInserted = insertedRowIndices.contains(rowIndex) || rowChange.type == .insert let modifiedColumns: Set = rowChange.type == .update ? Set(rowChange.cellChanges.map { $0.columnIndex }) : [] @@ -347,20 +513,26 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData modifiedColumns: modifiedColumns ) } + + for rowIndex in insertedRowIndices where rowVisualStateCache[rowIndex] == nil { + rowVisualStateCache[rowIndex] = RowVisualState( + isDeleted: false, + isInserted: true, + modifiedColumns: [] + ) + } } func visualState(for row: Int) -> RowVisualState { - // If delegate provides custom visual state, use it if let delegateState = delegate?.dataGridVisualState(forRow: row) { return delegateState } - // Otherwise use cache return rowVisualStateCache[row] ?? .empty } // MARK: - NSTableViewDataSource func numberOfRows(in tableView: NSTableView) -> Int { - cachedRowCount + sortedIDs?.count ?? tableRowsProvider().count } } diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 78f836dc7..7a70d8235 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -26,19 +26,23 @@ extension TableViewCoordinator { func undoInsertRow(at index: Int) { delegate?.dataGridUndoInsert(at: index) changeManager.undoRowInsertion(rowIndex: index) - rowProvider.removeRow(at: index) + tableRowsMutator { rows in + _ = rows.remove(at: IndexSet(integer: index)) + } + cachedTableRows = tableRowsProvider() updateCache() tableView?.reloadData() } func copyRows(at indices: Set) { let sortedIndices = indices.sorted() - let columnTypes = rowProvider.columnTypes + let tableRows = tableRowsProvider() + let columnTypes = tableRows.columnTypes var tsvRows: [String] = [] var htmlRows: [[String]] = [] for index in sortedIndices { - guard let values = rowProvider.rowValues(at: index) else { continue } + guard let values = displayRow(at: index)?.values else { continue } let formatted = formatRowValues(values: values, columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) @@ -51,13 +55,14 @@ extension TableViewCoordinator { func copyRowsWithHeaders(at indices: Set) { let sortedIndices = indices.sorted() - let columnTypes = rowProvider.columnTypes - let columns = rowProvider.columns + let tableRows = tableRowsProvider() + let columnTypes = tableRows.columnTypes + let columns = tableRows.columns var tsvRows: [String] = [columns.joined(separator: "\t")] var htmlRows: [[String]] = [] for index in sortedIndices { - guard let values = rowProvider.rowValues(at: index) else { continue } + guard let values = displayRow(at: index)?.values else { continue } let formatted = formatRowValues(values: values, columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) @@ -82,15 +87,15 @@ extension TableViewCoordinator { } func copyCellValue(at rowIndex: Int, columnIndex: Int) { - guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } + guard let row = displayRow(at: rowIndex), columnIndex < row.values.count else { return } - let value = rowProvider.value(atRow: rowIndex, column: columnIndex) ?? "NULL" - let columnTypes = rowProvider.columnTypes + let value = row.values[columnIndex] ?? "NULL" + let columnTypes = tableRows.columnTypes let columnType = columnTypes.indices.contains(columnIndex) ? columnTypes[columnIndex] : nil - // Use formatted value when a display format is active - let formats = rowProvider.columnDisplayFormats - if columnIndex < formats.count, let format = formats[columnIndex], format != .raw { + if columnIndex < columnDisplayFormats.count, let format = columnDisplayFormats[columnIndex], format != .raw { let formatted = ValueDisplayFormatService.applyFormat(value, format: format) ClipboardService.shared.writeText(formatted) return @@ -102,41 +107,44 @@ extension TableViewCoordinator { func copyRowsAsInsert(at indices: Set) { guard let tableName, let databaseType else { return } + let tableRows = tableRowsProvider() let driver = resolveDriver() let converter = SQLRowToStatementConverter( tableName: tableName, - columns: rowProvider.columns, + columns: tableRows.columns, primaryKeyColumn: primaryKeyColumn, databaseType: databaseType, quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) } + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateInserts(rows: rows)) } func copyRowsAsUpdate(at indices: Set) { guard let tableName, let databaseType else { return } + let tableRows = tableRowsProvider() let driver = resolveDriver() let converter = SQLRowToStatementConverter( tableName: tableName, - columns: rowProvider.columns, + columns: tableRows.columns, primaryKeyColumn: primaryKeyColumn, databaseType: databaseType, quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) } + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) } func copyRowsAsJson(at indices: Set) { - let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) } + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } guard !rows.isEmpty else { return } - let columnTypes = rowProvider.columnTypes - let converter = JsonRowConverter(columns: rowProvider.columns, columnTypes: columnTypes) + let tableRows = tableRowsProvider() + let columnTypes = tableRows.columnTypes + let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: columnTypes) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } @@ -162,11 +170,12 @@ extension TableViewCoordinator { let item = NSPasteboardItem() item.setString(String(row), forType: Self.rowDragType) - if let values = rowProvider.rowValues(at: row) { - let formatted = formatRowValues(values: values, columnTypes: rowProvider.columnTypes) + if let values = displayRow(at: row)?.values { + let tableRows = tableRowsProvider() + let formatted = formatRowValues(values: values, columnTypes: tableRows.columnTypes) item.setString(formatted.joined(separator: "\t"), forType: .string) item.setString( - HtmlTableEncoder.encode(rows: [formatted], headers: rowProvider.columns), + HtmlTableEncoder.encode(rows: [formatted], headers: tableRows.columns), forType: .html ) } diff --git a/TablePro/Views/Results/DataGridView+TypePicker.swift b/TablePro/Views/Results/DataGridView+TypePicker.swift index 5a915ac75..96c917b1b 100644 --- a/TablePro/Views/Results/DataGridView+TypePicker.swift +++ b/TablePro/Views/Results/DataGridView+TypePicker.swift @@ -17,7 +17,7 @@ extension TableViewCoordinator { ) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) ?? "" + let currentValue = cellValue(at: row, column: columnIndex) ?? "" let dbType = databaseType ?? .mysql let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index aa8f3effa..ef07c1329 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -9,13 +9,11 @@ import AppKit import SwiftUI -/// Position of a cell in the grid (row, column) struct CellPosition: Hashable { let row: Int let column: Int } -/// Cached visual state for a row - avoids repeated changeManager lookups struct RowVisualState { let isDeleted: Bool let isInserted: Bool @@ -24,7 +22,6 @@ struct RowVisualState { static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) } -/// Identity snapshot used to skip redundant updateNSView work when nothing has changed struct DataGridIdentity: Equatable { let reloadVersion: Int let schemaVersion: Int @@ -54,20 +51,21 @@ struct DataGridIdentity: Equatable { } } -/// High-performance table view using AppKit NSTableView struct DataGridView: NSViewRepresentable { - let rowProvider: InMemoryRowProvider + var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } + var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } var changeManager: AnyChangeManager var schemaVersion: Int = 0 var metadataVersion: Int = 0 var paginationVersion: Int = 0 let isEditable: Bool var configuration: DataGridConfiguration = .init() + var sortedIDs: [RowID]? + var displayFormats: [ValueDisplayFormat?] = [] var delegate: (any DataGridViewDelegate)? @Binding var selectedRowIndices: Set @Binding var sortState: SortState - @Binding var editingCell: CellPosition? @Binding var columnLayout: ColumnLayoutState // MARK: - NSViewRepresentable @@ -84,7 +82,6 @@ struct DataGridView: NSViewRepresentable { tableView.style = .plain tableView.setAccessibilityLabel(String(localized: "Data grid")) tableView.setAccessibilityRole(.table) - // Use settings for alternate row backgrounds let settings = AppSettingsManager.shared.dataGrid tableView.usesAlternatingRowBackgroundColors = settings.showAlternateRows tableView.allowsMultipleSelection = true @@ -93,7 +90,6 @@ struct DataGridView: NSViewRepresentable { tableView.columnAutoresizingStyle = .noColumnAutoresizing tableView.gridStyleMask = [.solidVerticalGridLineMask] tableView.intercellSpacing = NSSize(width: 1, height: 0) - // Use settings for row height tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) tableView.delegate = context.coordinator @@ -102,7 +98,6 @@ struct DataGridView: NSViewRepresentable { tableView.action = #selector(TableViewCoordinator.handleClick(_:)) tableView.doubleAction = #selector(TableViewCoordinator.handleDoubleClick(_:)) - // Add row number column let rowNumberColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("__rowNumber__")) rowNumberColumn.title = "#" rowNumberColumn.width = 40 @@ -114,13 +109,15 @@ struct DataGridView: NSViewRepresentable { tableView.addTableColumn(rowNumberColumn) rowNumberColumn.isHidden = !configuration.showRowNumbers - // Add data columns (suppress resize notifications during setup) + let initialRows = tableRowsProvider() + context.coordinator.cachedTableRows = initialRows + context.coordinator.isRebuildingColumns = true - for (index, columnName) in rowProvider.columns.enumerated() { + for (index, columnName) in initialRows.columns.enumerated() { let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) column.title = columnName - if index < rowProvider.columnTypes.count { - let typeName = rowProvider.columnTypes[index].rawType ?? rowProvider.columnTypes[index].displayName + if index < initialRows.columnTypes.count { + let typeName = initialRows.columnTypes[index].rawType ?? initialRows.columnTypes[index].displayName column.headerToolTip = "\(columnName) (\(typeName))" } column.headerCell.setAccessibilityLabel( @@ -129,7 +126,7 @@ struct DataGridView: NSViewRepresentable { column.width = context.coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: index, - rowProvider: rowProvider + tableRows: initialRows ) column.minWidth = 30 column.resizingMask = .userResizingMask @@ -141,12 +138,11 @@ struct DataGridView: NSViewRepresentable { tableView.addTableColumn(column) } - // Apply saved column widths (from user resizing) if !columnLayout.columnWidths.isEmpty { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let baseName = rowProvider.columns[colIndex] + colIndex < initialRows.columns.count else { continue } + let baseName = initialRows.columns[colIndex] if let savedWidth = columnLayout.columnWidths[baseName] { column.width = savedWidth } @@ -154,14 +150,12 @@ struct DataGridView: NSViewRepresentable { context.coordinator.hasUserResizedColumns = true } - // Apply saved column order if let savedOrder = columnLayout.columnOrder { - DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: rowProvider.columns) + DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: initialRows.columns) } context.coordinator.isRebuildingColumns = false - // Apply column visibility - applyColumnVisibility(to: tableView) + applyColumnVisibility(to: tableView, columns: initialRows.columns) if let headerView = tableView.headerView { let headerMenu = NSMenu() @@ -169,7 +163,6 @@ struct DataGridView: NSViewRepresentable { headerView.menu = headerMenu } - // Register for row drag-and-drop if delegate supports move let hasMoveRow = delegate != nil if hasMoveRow { tableView.registerForDraggedTypes([NSPasteboard.PasteboardType("com.TablePro.rowDrag")]) @@ -178,6 +171,11 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView + context.coordinator.tableRowsController.attach(tableView) + context.coordinator.tableRowsProvider = tableRowsProvider + context.coordinator.tableRowsMutator = tableRowsMutator + context.coordinator.sortedIDs = sortedIDs + context.coordinator.syncDisplayFormats(displayFormats) context.coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns @@ -188,7 +186,7 @@ struct DataGridView: NSViewRepresentable { context.coordinator.tableName = configuration.tableName context.coordinator.primaryKeyColumns = configuration.primaryKeyColumns context.coordinator.tabType = configuration.tabType - context.coordinator.rebuildColumnMetadataCache() + context.coordinator.rebuildColumnMetadataCache(from: tableRowsProvider()) if let connectionId = configuration.connectionId { context.coordinator.observeTeardown(connectionId: connectionId) } @@ -201,11 +199,9 @@ struct DataGridView: NSViewRepresentable { let coordinator = context.coordinator - // Don't reload while editing (field editor or overlay) if tableView.editedRow >= 0 { return } if let editor = context.coordinator.overlayEditor, editor.isActive { return } - // Sync row number visibility before identity check (setting can change without data change) if let rowNumCol = tableView.tableColumns.first(where: { $0.identifier.rawValue == "__rowNumber__" }) { let shouldHide = !configuration.showRowNumbers if rowNumCol.isHidden != shouldHide { @@ -213,7 +209,6 @@ struct DataGridView: NSViewRepresentable { } } - // Sync row drag registration when delegate availability changes let rowDragType = NSPasteboard.PasteboardType("com.TablePro.rowDrag") let hasDragRegistered = tableView.registeredDraggedTypes.contains(rowDragType) let hasMoveRow = delegate != nil @@ -232,28 +227,33 @@ struct DataGridView: NSViewRepresentable { coordinator.observeTeardown(connectionId: connectionId) } - // Identity-based early-return BEFORE reading settings — avoids - // AppSettingsManager access on every SwiftUI re-evaluation. + let latestRows = tableRowsProvider() + coordinator.cachedTableRows = latestRows + let rowDisplayCount = sortedIDs?.count ?? latestRows.count + let columnCount = latestRows.columns.count + let currentIdentity = DataGridIdentity( reloadVersion: changeManager.reloadVersion, schemaVersion: schemaVersion, metadataVersion: metadataVersion, paginationVersion: paginationVersion, - rowCount: rowProvider.totalRowCount, - columnCount: rowProvider.columns.count, + rowCount: rowDisplayCount, + columnCount: columnCount, isEditable: isEditable, configuration: configuration ) if currentIdentity == coordinator.lastIdentity { - // Only refresh delegate reference — it may have changed between body evals coordinator.delegate = delegate + coordinator.tableRowsProvider = tableRowsProvider + coordinator.tableRowsMutator = tableRowsMutator + coordinator.sortedIDs = sortedIDs + coordinator.syncDisplayFormats(displayFormats) delegate?.dataGridAttach(tableViewCoordinator: coordinator) return } let previousIdentity = coordinator.lastIdentity coordinator.lastIdentity = currentIdentity - // Update settings-based properties dynamically (after identity check) let settings = AppSettingsManager.shared.dataGrid if tableView.rowHeight != CGFloat(settings.rowHeight.rawValue) { tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) @@ -266,43 +266,27 @@ struct DataGridView: NSViewRepresentable { let metadataChanged = previousIdentity.map { $0.metadataVersion != metadataVersion } ?? false let oldRowCount = coordinator.cachedRowCount let oldColumnCount = coordinator.cachedColumnCount - let newRowCount = rowProvider.totalRowCount - let newColumnCount = rowProvider.columns.count - // Only do full reload if row/column count changed, columns changed, or result version changed - // For cell edits (versionChanged but same count), use granular reload - let structureChanged = oldRowCount != newRowCount || oldColumnCount != newColumnCount + let structureChanged = oldRowCount != rowDisplayCount || oldColumnCount != columnCount let needsFullReload = structureChanged - coordinator.rowProvider = rowProvider - - // Re-apply pending cell edits only when changes have been modified - if changeManager.reloadVersion != coordinator.lastReapplyVersion { - coordinator.lastReapplyVersion = changeManager.reloadVersion - for rowChange in changeManager.rowChanges { - for cellChange in rowChange.cellChanges { - coordinator.rowProvider.updateValue( - cellChange.newValue, - at: rowChange.rowIndex, - columnIndex: cellChange.columnIndex - ) - } - } - } - coordinator.updateCache() - coordinator.rebuildColumnMetadataCache() + coordinator.rebuildColumnMetadataCache(from: latestRows) if previousIdentity == nil || previousIdentity?.rowCount == 0 { let rowH = tableView.rowHeight if rowH > 0 { let visibleRows = Int(tableView.visibleRect.height / rowH) + 5 - coordinator.rowProvider.preWarmDisplayCache(upTo: visibleRows) + coordinator.preWarmDisplayCache(upTo: visibleRows) } } coordinator.changeManager = changeManager coordinator.isEditable = isEditable + coordinator.tableRowsProvider = tableRowsProvider + coordinator.tableRowsMutator = tableRowsMutator + coordinator.sortedIDs = sortedIDs + coordinator.syncDisplayFormats(displayFormats) coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns @@ -316,37 +300,33 @@ struct DataGridView: NSViewRepresentable { coordinator.rebuildVisualStateCache() - // Capture current column layout before any rebuilds (only if not about to rebuild) - // Check if columns changed (by name or structure) let currentDataColumns = tableView.tableColumns.dropFirst() let currentColumnIds = currentDataColumns.map { $0.identifier.rawValue } - let expectedColumnIds = rowProvider.columns.indices.map { Self.columnIdentifier(for: $0).rawValue } - let columnsChanged = !rowProvider.columns.isEmpty && (currentColumnIds != expectedColumnIds) + let expectedColumnIds = latestRows.columns.indices.map { Self.columnIdentifier(for: $0).rawValue } + let columnsChanged = !latestRows.columns.isEmpty && (currentColumnIds != expectedColumnIds) - // Only recalculate column widths when transitioning from 0 rows (initial data load). - // When row count changes but columns are the same and already have widths, skip - // the expensive calculateOptimalColumnWidth calls. - let isInitialDataLoad = structureChanged && oldRowCount == 0 && !rowProvider.columns.isEmpty + let isInitialDataLoad = structureChanged && oldRowCount == 0 && !latestRows.columns.isEmpty let shouldRebuildColumns = columnsChanged || isInitialDataLoad updateColumns( tableView: tableView, coordinator: coordinator, + tableRows: latestRows, columnsChanged: columnsChanged, shouldRebuild: shouldRebuildColumns, structureChanged: structureChanged ) - // Sync column visibility - applyColumnVisibility(to: tableView) + applyColumnVisibility(to: tableView, columns: latestRows.columns) - syncSortDescriptors(tableView: tableView, coordinator: coordinator) + syncSortDescriptors(tableView: tableView, coordinator: coordinator, columns: latestRows.columns) let paginationChanged = previousIdentity.map { $0.paginationVersion != paginationVersion } ?? false reloadAndSyncSelection( tableView: tableView, coordinator: coordinator, + tableRows: latestRows, needsFullReload: needsFullReload, versionChanged: versionChanged, metadataChanged: metadataChanged, @@ -356,10 +336,10 @@ struct DataGridView: NSViewRepresentable { // MARK: - updateNSView Helpers - /// Rebuild or sync table columns based on data changes private func updateColumns( tableView: NSTableView, coordinator: TableViewCoordinator, + tableRows: TableRows, columnsChanged: Bool, shouldRebuild: Bool, structureChanged: Bool @@ -369,19 +349,18 @@ struct DataGridView: NSViewRepresentable { defer { coordinator.isRebuildingColumns = false } if columnsChanged { - // Column count changed — full rebuild (remove all, create all) let columnsToRemove = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } for column in columnsToRemove { tableView.removeTableColumn(column) } let willRestoreWidths = !columnLayout.columnWidths.isEmpty - for (index, columnName) in rowProvider.columns.enumerated() { + for (index, columnName) in tableRows.columns.enumerated() { let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) column.title = columnName - if index < rowProvider.columnTypes.count { - let typeName = rowProvider.columnTypes[index].rawType - ?? rowProvider.columnTypes[index].displayName + if index < tableRows.columnTypes.count { + let typeName = tableRows.columnTypes[index].rawType + ?? tableRows.columnTypes[index].displayName column.headerToolTip = "\(columnName) (\(typeName))" } column.headerCell.setAccessibilityLabel( @@ -393,7 +372,7 @@ struct DataGridView: NSViewRepresentable { column.width = coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: index, - rowProvider: rowProvider + tableRows: tableRows ) } column.minWidth = 30 @@ -406,23 +385,22 @@ struct DataGridView: NSViewRepresentable { tableView.addTableColumn(column) } } else { - // Same column count — lightweight in-place update (avoids remove/add overhead) let hasSavedWidths = !columnLayout.columnWidths.isEmpty for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let columnName = rowProvider.columns[colIndex] + colIndex < tableRows.columns.count else { continue } + let columnName = tableRows.columns[colIndex] column.title = columnName - if colIndex < rowProvider.columnTypes.count { - let typeName = rowProvider.columnTypes[colIndex].rawType - ?? rowProvider.columnTypes[colIndex].displayName + if colIndex < tableRows.columnTypes.count { + let typeName = tableRows.columnTypes[colIndex].rawType + ?? tableRows.columnTypes[colIndex].displayName column.headerToolTip = "\(columnName) (\(typeName))" } if !hasSavedWidths { column.width = coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: colIndex, - rowProvider: rowProvider + tableRows: tableRows ) } column.isEditable = isEditable @@ -430,12 +408,11 @@ struct DataGridView: NSViewRepresentable { } let hasSavedLayout = !columnLayout.columnWidths.isEmpty - // Restore saved column widths after rebuild (from user resize or persisted layout) if hasSavedLayout { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let baseName = rowProvider.columns[colIndex] + colIndex < tableRows.columns.count else { continue } + let baseName = tableRows.columns[colIndex] if let savedWidth = columnLayout.columnWidths[baseName] { column.width = savedWidth } @@ -443,21 +420,17 @@ struct DataGridView: NSViewRepresentable { coordinator.hasUserResizedColumns = true } - // Restore saved column order after rebuild if let savedOrder = columnLayout.columnOrder { - DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: rowProvider.columns) + DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: tableRows.columns) coordinator.hasUserResizedColumns = true } - // Persist calculated widths so subsequent tab switches reuse them - // instead of calling the expensive calculateOptimalColumnWidth. - // Skip when saved layout exists to avoid overwriting persisted values. if !coordinator.hasUserResizedColumns, !hasSavedLayout { var newWidths: [String: CGFloat] = [:] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - newWidths[rowProvider.columns[colIndex]] = column.width + colIndex < tableRows.columns.count else { continue } + newWidths[tableRows.columns[colIndex]] = column.width } if !newWidths.isEmpty && newWidths != columnLayout.columnWidths { coordinator.isWritingColumnLayout = true @@ -468,25 +441,19 @@ struct DataGridView: NSViewRepresentable { } } } else { - // Always sync column editability (e.g., view tabs reusing table columns) for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { column.isEditable = isEditable } - // Skip layout capture when an async layout write-back is pending — - // prevents the two-frame bounce where stale widths are applied - // before the async block updates them. guard !coordinator.isWritingColumnLayout else { return } - // Capture current column layout from user interactions (resize/reorder) - // Only done in the non-rebuild path to avoid feedback loops if coordinator.hasUserResizedColumns, tableView.tableColumns.count > 1 { var currentWidths: [String: CGFloat] = [:] var currentOrder: [String] = [] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let baseName = rowProvider.columns[colIndex] + colIndex < tableRows.columns.count else { continue } + let baseName = tableRows.columns[colIndex] currentWidths[baseName] = column.width currentOrder.append(baseName) } @@ -509,8 +476,7 @@ struct DataGridView: NSViewRepresentable { } } - /// Synchronize sort descriptors and indicators with the table view - private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator) { + private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) { coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } @@ -519,8 +485,7 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = [] } } else if let firstSort = sortState.columns.first, - firstSort.columnIndex >= 0 && firstSort.columnIndex < rowProvider.columns.count { - // Sync with first sort column for NSTableView's built-in sort indicators + firstSort.columnIndex >= 0 && firstSort.columnIndex < columns.count { let key = Self.columnIdentifier(for: firstSort.columnIndex).rawValue let ascending = firstSort.direction == .ascending let currentDescriptor = tableView.sortDescriptors.first @@ -529,14 +494,13 @@ struct DataGridView: NSViewRepresentable { } } - // Update column header titles for multi-sort indicators - Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: rowProvider.columns) + Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: columns) } - /// Reload table data as needed and synchronize selection and editing state private func reloadAndSyncSelection( tableView: NSTableView, coordinator: TableViewCoordinator, + tableRows: TableRows, needsFullReload: Bool, versionChanged: Bool, metadataChanged: Bool = false, @@ -545,15 +509,13 @@ struct DataGridView: NSViewRepresentable { if needsFullReload { tableView.reloadData() } else if metadataChanged { - // FK metadata arrived (Phase 2) — reload only FK columns to show arrow buttons. - // Use display-order indices from tableView.tableColumns (respects user column reordering). let fkColumnIndices = IndexSet( tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in guard tableColumn.identifier.rawValue != "__rowNumber__", let modelIndex = Self.dataColumnIndex(from: tableColumn.identifier), - modelIndex < rowProvider.columns.count else { return nil } - let columnName = rowProvider.columns[modelIndex] - return rowProvider.columnForeignKeys[columnName] != nil ? displayIndex : nil + modelIndex < tableRows.columns.count else { return nil } + let columnName = tableRows.columns[modelIndex] + return tableRows.columnForeignKeys[columnName] != nil ? displayIndex : nil } ) if !fkColumnIndices.isEmpty { @@ -580,12 +542,10 @@ struct DataGridView: NSViewRepresentable { coordinator.lastReloadVersion = changeManager.reloadVersion - // Scroll to first row when page changes if paginationChanged && tableView.numberOfRows > 0 { tableView.scrollRowToVisible(0) } - // Sync selection let currentSelection = tableView.selectedRowIndexes let targetSelection = IndexSet(selectedRowIndices) if currentSelection != targetSelection { @@ -593,34 +553,15 @@ struct DataGridView: NSViewRepresentable { tableView.selectRowIndexes(targetSelection, byExtendingSelection: false) coordinator.isSyncingSelection = false } - - // Handle editingCell - if let cell = editingCell { - let tableColumn = DataGridView.tableColumnIndex(for: cell.column) - if cell.row < tableView.numberOfRows && tableColumn < tableView.numberOfColumns { - tableView.scrollRowToVisible(cell.row) - Task { @MainActor [weak tableView] in - guard let tableView else { return } - tableView.selectRowIndexes(IndexSet(integer: cell.row), byExtendingSelection: false) - tableView.editColumn(tableColumn, row: cell.row, with: nil, select: true) - self.editingCell = nil - } - } else { - Task { @MainActor in - self.editingCell = nil - } - } - } } // MARK: - Column Visibility - /// Apply hidden column state to the table view - private func applyColumnVisibility(to tableView: NSTableView) { + private func applyColumnVisibility(to tableView: NSTableView, columns: [String]) { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let columnName = rowProvider.columns[colIndex] + colIndex < columns.count else { continue } + let columnName = columns[colIndex] let shouldHide = configuration.hiddenColumns.contains(columnName) if column.isHidden != shouldHide { column.isHidden = shouldHide @@ -649,12 +590,10 @@ struct DataGridView: NSViewRepresentable { } private static func applyColumnOrder(_ order: [String], to tableView: NSTableView, columns: [String]) { - // Only apply if saved order is a permutation of current columns guard Set(order) == Set(columns) else { return } let dataColumns = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } - // Build name→column map for O(1) lookup var columnMap: [String: NSTableColumn] = [:] for col in dataColumns { if let idx = dataColumnIndex(from: col.identifier), idx < columns.count { @@ -674,7 +613,6 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - /// Update column header titles to show multi-sort priority indicators (e.g., "name 1▲", "age 2▼") private static func updateSortIndicators(tableView: NSTableView, sortState: SortState, columns: [String]) { for column in tableView.tableColumns { guard let colIndex = dataColumnIndex(from: column.identifier), @@ -688,11 +626,9 @@ struct DataGridView: NSViewRepresentable { let indicator = " \(sortIndex + 1)\(sortCol.direction.indicator)" column.title = "\(baseName)\(indicator)" } else { - // Single sort: NSTableView shows its own indicator, keep base name column.title = baseName } } else { - // Not sorted: restore base name column.title = baseName } } @@ -709,12 +645,12 @@ struct DataGridView: NSViewRepresentable { NotificationCenter.default.removeObserver(observer) coordinator.themeObserver = nil } - coordinator.rowProvider = InMemoryRowProvider(rows: [], columns: []) + coordinator.tableRowsController.detach() + coordinator.cachedTableRows = TableRows() } func makeCoordinator() -> TableViewCoordinator { TableViewCoordinator( - rowProvider: rowProvider, changeManager: changeManager, isEditable: isEditable, selectedRowIndices: $selectedRowIndices, @@ -726,21 +662,23 @@ struct DataGridView: NSViewRepresentable { // MARK: - Preview +private let previewTableRowsForDataGrid = TableRows.from( + queryRows: [ + ["1", "John", "john@example.com"], + ["2", "Jane", nil], + ["3", "Bob", "bob@example.com"], + ], + columns: ["id", "name", "email"], + columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: 3) +) + #Preview { DataGridView( - rowProvider: InMemoryRowProvider( - rows: [ - ["1", "John", "john@example.com"], - ["2", "Jane", nil], - ["3", "Bob", "bob@example.com"], - ], - columns: ["id", "name", "email"] - ), + tableRowsProvider: { previewTableRowsForDataGrid }, changeManager: AnyChangeManager(DataChangeManager()), isEditable: true, selectedRowIndices: .constant([]), sortState: .constant(SortState()), - editingCell: .constant(nil), columnLayout: .constant(ColumnLayoutState()) ) .frame(width: 600, height: 400) diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index b833d61f3..7860afda3 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -7,29 +7,51 @@ import AppKit extension TableViewCoordinator { func commitCellEdit(row: Int, columnIndex: Int, newValue: String?) { + guard !isCommittingCellEdit else { return } guard let tableView else { return } - guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } - - let oldValue = rowProvider.value(atRow: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } + guard let displayRowValues = displayRow(at: row) else { return } + guard columnIndex < displayRowValues.values.count else { return } + let oldValue = displayRowValues.values[columnIndex] guard oldValue != newValue else { return } - let columnName = rowProvider.columns[columnIndex] + isCommittingCellEdit = true + defer { isCommittingCellEdit = false } + + let storageRow = tableRowsIndex(forDisplayRow: row) + let columnName = tableRows.columns[columnIndex] + let originalRow = displayRowValues.values changeManager.recordCellChange( rowIndex: row, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, newValue: newValue, - originalRow: rowProvider.rowValues(at: row) ?? [] + originalRow: originalRow ) - rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) + var delta: Delta = .none + if let storageRow { + tableRowsMutator { tableRows in + delta = tableRows.edit(row: storageRow, column: columnIndex, value: newValue) + } + } delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) + invalidateDisplayCache() - let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) - tableView.reloadData( - forRowIndexes: IndexSet(integer: row), - columnIndexes: IndexSet(integer: tableColumnIndex) - ) + if storageRow != nil, case .cellChanged = delta { + let displayDelta: Delta = .cellChanged( + row: row, + column: DataGridView.tableColumnIndex(for: columnIndex) + ) + tableRowsController.apply(displayDelta) + } else { + let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) + tableView.reloadData( + forRowIndexes: IndexSet(integer: row), + columnIndexes: IndexSet(integer: tableColumnIndex) + ) + } } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift index 9ad521b8a..edcc86102 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift @@ -16,7 +16,7 @@ extension TableViewCoordinator { guard !grid.isEmpty, grid[0].count > 1 || grid.count > 1 else { return false } let maxRow = min(anchorRow + grid.count, cachedRowCount) - let maxCol = min(anchorColumn + (grid.first?.count ?? 0), rowProvider.columns.count) + let maxCol = min(anchorColumn + (grid.first?.count ?? 0), cachedTableRows.columns.count) guard anchorRow < maxRow, anchorColumn < maxCol else { return false } let undoManager = tableView?.window?.undoManager diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index df15970ec..ad0a09389 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -32,43 +32,39 @@ extension TableViewCoordinator { let columnIndex = DataGridView.dataColumnIndex(for: column) guard !changeManager.isRowDeleted(row) else { return } + let tableRows = tableRowsProvider() let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] if !immutable.isEmpty, - columnIndex < rowProvider.columns.count, - immutable.contains(rowProvider.columns[columnIndex]) { + columnIndex < tableRows.columns.count, + immutable.contains(tableRows.columns[columnIndex]) { return } - // FK columns use searchable dropdown popover on double click - if columnIndex < rowProvider.columns.count { - let columnName = rowProvider.columns[columnIndex] - if let fkInfo = rowProvider.columnForeignKeys[columnName] { + if columnIndex < tableRows.columns.count { + let columnName = tableRows.columns[columnIndex] + if let fkInfo = tableRows.columnForeignKeys[columnName] { showForeignKeyPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex, fkInfo: fkInfo) return } } - // Multiline values use the overlay editor instead of inline field editor - if let value = rowProvider.value(atRow: row, column: columnIndex), - value.containsLineBreak { + let value = cellValue(at: row, column: columnIndex) + if let value, value.containsLineBreak { showOverlayEditor(tableView: sender, row: row, column: column, columnIndex: columnIndex, value: value) return } - // JSON-like text values in non-JSON/non-chevron columns - if columnIndex < rowProvider.columnTypes.count { - let ct = rowProvider.columnTypes[columnIndex] + if columnIndex < tableRows.columnTypes.count { + let ct = tableRows.columnTypes[columnIndex] if ct.isBooleanType || ct.isDateType || ct.isBlobType || ct.isEnumType || ct.isSetType { return } } - if let cellValue = rowProvider.value(atRow: row, column: columnIndex), - cellValue.looksLikeJson { + if let value, value.looksLikeJson { showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) return } - // Regular columns — start inline editing sender.editColumn(column, row: row, with: nil, select: true) } @@ -106,17 +102,18 @@ extension TableViewCoordinator { return } - guard columnIndex < rowProvider.columnTypes.count, - columnIndex < rowProvider.columns.count else { return } + let tableRows = tableRowsProvider() + guard columnIndex < tableRows.columnTypes.count, + columnIndex < tableRows.columns.count else { return } - let ct = rowProvider.columnTypes[columnIndex] - let columnName = rowProvider.columns[columnIndex] + let ct = tableRows.columnTypes[columnIndex] + let columnName = tableRows.columns[columnIndex] if ct.isBooleanType { showDropdownMenu(tableView: tableView, row: row, column: column, columnIndex: columnIndex) - } else if ct.isEnumType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { + } else if ct.isEnumType, let values = tableRows.columnEnumValues[columnName], !values.isEmpty { showEnumPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) - } else if ct.isSetType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { + } else if ct.isSetType, let values = tableRows.columnEnumValues[columnName], !values.isEmpty { showSetPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } else if ct.isDateType { showDatePickerPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) @@ -134,13 +131,14 @@ extension TableViewCoordinator { let row = button.fkRow let columnIndex = button.fkColumnIndex + let tableRows = tableRowsProvider() guard row >= 0 && row < cachedRowCount, - columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } + columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } - let columnName = rowProvider.columns[columnIndex] - guard let fkInfo = rowProvider.columnForeignKeys[columnName] else { return } + let columnName = tableRows.columns[columnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName] else { return } - let value = rowProvider.value(atRow: row, column: columnIndex) + let value = cellValue(at: row, column: columnIndex) guard let value = value, !value.isEmpty else { return } delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index f776041f1..b9b4b99b1 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -11,25 +11,39 @@ extension TableViewCoordinator { guard let column = tableColumn else { return nil } let columnId = column.identifier.rawValue + let tableRows = tableRowsProvider() + let displayCount = sortedIDs?.count ?? tableRows.count if columnId == "__rowNumber__" { return cellFactory.makeRowNumberCell( tableView: tableView, row: row, - cachedRowCount: cachedRowCount, + cachedRowCount: displayCount, visualState: visualState(for: row) ) } guard let columnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return nil } - guard row >= 0 && row < cachedRowCount, + guard row >= 0 && row < displayCount, columnIndex >= 0 && columnIndex < cachedColumnCount else { return nil } - let rawValue = rowProvider.value(atRow: row, column: columnIndex) - let displayValue = rowProvider.displayValue(atRow: row, column: columnIndex) + guard let displayRow = displayRow(at: row), + columnIndex < displayRow.values.count else { + return nil + } + let rawValue = displayRow.values[columnIndex] + let columnType = columnIndex < tableRows.columnTypes.count + ? tableRows.columnTypes[columnIndex] + : nil + let formattedValue = displayValue( + forID: displayRow.id, + column: columnIndex, + rawValue: rawValue, + columnType: columnType + ) let state = visualState(for: row) let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) @@ -47,8 +61,8 @@ extension TableViewCoordinator { let isFKColumn = fkColumns.contains(columnIndex) let hasSpecialEditor: Bool = { - guard columnIndex < rowProvider.columnTypes.count else { return false } - let ct = rowProvider.columnTypes[columnIndex] + guard columnIndex < tableRows.columnTypes.count else { return false } + let ct = tableRows.columnTypes[columnIndex] return ct.isBooleanType || ct.isDateType || ct.isJsonType || ct.isBlobType }() @@ -56,7 +70,7 @@ extension TableViewCoordinator { tableView: tableView, row: row, columnIndex: columnIndex, - displayValue: displayValue, + displayValue: formattedValue, rawValue: rawValue, visualState: state, isEditable: isEditable && !state.isDeleted, @@ -83,4 +97,5 @@ extension TableViewCoordinator { rowView.rowIndex = row return rowView } + } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index e2631a84f..2548ba2be 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -15,19 +15,20 @@ extension TableViewCoordinator { func inlineEditEligibility(row: Int, columnIndex: Int) -> InlineEditEligibility { guard isEditable else { return .blocked } - guard row >= 0, columnIndex >= 0, columnIndex < rowProvider.columns.count else { return .blocked } + let tableRows = tableRowsProvider() + guard row >= 0, columnIndex >= 0, columnIndex < tableRows.columns.count else { return .blocked } guard !changeManager.isRowDeleted(row) else { return .blocked } let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] - if immutable.contains(rowProvider.columns[columnIndex]) { + if immutable.contains(tableRows.columns[columnIndex]) { return .blocked } - let columnName = rowProvider.columns[columnIndex] - if rowProvider.columnForeignKeys[columnName] != nil { return .blocked } + let columnName = tableRows.columns[columnIndex] + if tableRows.columnForeignKeys[columnName] != nil { return .blocked } - if columnIndex < rowProvider.columnTypes.count { - let ct = rowProvider.columnTypes[columnIndex] + if columnIndex < tableRows.columnTypes.count { + let ct = tableRows.columnTypes[columnIndex] if ct.isBooleanType || ct.isDateType || ct.isJsonType || ct.isBlobType || ct.isEnumType || ct.isSetType { return .blocked @@ -37,7 +38,9 @@ extension TableViewCoordinator { if dropdownColumns?.contains(columnIndex) == true { return .blocked } if typePickerColumns?.contains(columnIndex) == true { return .blocked } - if let value = rowProvider.value(atRow: row, column: columnIndex) { + if let displayRow = displayRow(at: row), + columnIndex < displayRow.values.count, + let value = displayRow.values[columnIndex] { if value.containsLineBreak { return .needsOverlayEditor(value: value) } if value.looksLikeJson { return .blocked } } @@ -119,10 +122,11 @@ extension TableViewCoordinator { tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - // Check if next cell is also multiline → open overlay there let nextColumnIndex = nextColumn - 1 - if nextColumnIndex >= 0, nextColumnIndex < rowProvider.columns.count, - let value = rowProvider.value(atRow: nextRow, column: nextColumnIndex), + if nextColumnIndex >= 0, + let nextDisplayRow = displayRow(at: nextRow), + nextColumnIndex < nextDisplayRow.values.count, + let value = nextDisplayRow.values[nextColumnIndex], value.containsLineBreak { showOverlayEditor(tableView: tableView, row: nextRow, column: nextColumn, columnIndex: nextColumnIndex, value: value) } else { @@ -142,14 +146,20 @@ extension TableViewCoordinator { if isEscapeCancelling { isEscapeCancelling = false - let originalValue = rowProvider.value(atRow: row, column: columnIndex) + let originalValue: String? = { + guard let displayRow = displayRow(at: row), columnIndex < displayRow.values.count else { return nil } + return displayRow.values[columnIndex] + }() textField.stringValue = originalValue ?? "" (control as? CellTextField)?.restoreTruncatedDisplay() return true } let rawInput = textField.stringValue - let oldValue = rowProvider.value(atRow: row, column: columnIndex) + let oldValue: String? = { + guard let displayRow = displayRow(at: row), columnIndex < displayRow.values.count else { return nil } + return displayRow.values[columnIndex] + }() let newValue: String? = rawInput.isEmpty && oldValue == nil ? nil : rawInput commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index daa89d264..2e390eda0 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -9,9 +9,18 @@ import SwiftUI // MARK: - Popover Editors extension TableViewCoordinator { + func cellValue(at row: Int, column columnIndex: Int) -> String? { + guard let displayRow = displayRow(at: row), columnIndex >= 0, columnIndex < displayRow.values.count else { + return nil + } + return displayRow.values[columnIndex] + } + func showDatePickerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) - let columnType = rowProvider.columnTypes[columnIndex] + let currentValue = cellValue(at: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columnTypes.count else { return } + let columnType = tableRows.columnTypes[columnIndex] guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -28,7 +37,7 @@ extension TableViewCoordinator { } func showForeignKeyPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int, fkInfo: ForeignKeyInfo) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } guard let databaseType, let connectionId else { return } @@ -62,10 +71,11 @@ extension TableViewCoordinator { } func showForeignKeyPreview(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - guard columnIndex >= 0, columnIndex < rowProvider.columns.count else { return } - let columnName = rowProvider.columns[columnIndex] - guard let fkInfo = rowProvider.columnForeignKeys[columnName] else { return } - let cellValue = rowProvider.value(atRow: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName] else { return } + let cellValue = cellValue(at: row, column: columnIndex) guard let databaseType, let connectionId else { return } guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -92,8 +102,10 @@ extension TableViewCoordinator { } func showJSONEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) - let columnName = rowProvider.columns[columnIndex] + let currentValue = cellValue(at: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -126,7 +138,7 @@ extension TableViewCoordinator { } func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -148,11 +160,13 @@ extension TableViewCoordinator { func showEnumPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - let columnName = rowProvider.columns[columnIndex] - guard let allowedValues = rowProvider.columnEnumValues[columnName] else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let allowedValues = tableRows.columnEnumValues[columnName] else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) - let isNullable = rowProvider.columnNullable[columnName] ?? true + let currentValue = cellValue(at: row, column: columnIndex) + let isNullable = tableRows.columnNullable[columnName] ?? true var values: [String] = [] if isNullable { @@ -179,10 +193,12 @@ extension TableViewCoordinator { func showSetPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - let columnName = rowProvider.columns[columnIndex] - guard let allowedValues = rowProvider.columnEnumValues[columnName] else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let allowedValues = tableRows.columnEnumValues[columnName] else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) let currentSet: Set if let value = currentValue { @@ -213,8 +229,10 @@ extension TableViewCoordinator { func showDropdownMenu(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) pendingDropdownRow = row pendingDropdownColumn = columnIndex pendingDropdownTableView = tableView @@ -238,8 +256,8 @@ extension TableViewCoordinator { menu.addItem(item) } - let columnName = rowProvider.columns[columnIndex] - let isNullable = rowProvider.columnNullable[columnName] ?? true + let columnName = tableRows.columns[columnIndex] + let isNullable = tableRows.columnNullable[columnName] ?? true if isNullable && customDropdownOptions?[columnIndex] == nil { menu.addItem(.separator()) let nullItem = NSMenuItem( diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 1c7af5545..f70f163e6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -15,7 +15,7 @@ extension TableViewCoordinator { guard let sortDescriptor = tableView.sortDescriptors.first, let key = sortDescriptor.key, let columnIndex = DataGridView.dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), - columnIndex >= 0 && columnIndex < rowProvider.columns.count else { + columnIndex >= 0 && columnIndex < cachedTableRows.columns.count else { return } @@ -34,10 +34,11 @@ extension TableViewCoordinator { return column.width } + let tableRows = tableRowsProvider() let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, + for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, columnIndex: dataColumnIndex, - rowProvider: rowProvider + tableRows: tableRows ) hasUserResizedColumns = true return width @@ -64,8 +65,8 @@ extension TableViewCoordinator { // Derive base column name from stable identifier (avoids sort indicator in title) let baseName: String = { if let idx = DataGridView.dataColumnIndex(from: column.identifier), - idx < rowProvider.columns.count { - return rowProvider.columns[idx] + idx < cachedTableRows.columns.count { + return cachedTableRows.columns[idx] } return column.title }() @@ -104,7 +105,7 @@ extension TableViewCoordinator { // "Display As" submenu for value display format overrides if let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) { - let columnType = dataColumnIndex < rowProvider.columnTypes.count ? rowProvider.columnTypes[dataColumnIndex] : nil + let columnType = dataColumnIndex < cachedTableRows.columnTypes.count ? cachedTableRows.columnTypes[dataColumnIndex] : nil let applicableFormats = ValueDisplayFormat.applicableFormats(for: columnType) if applicableFormats.count > 1 { let displaySubmenu = NSMenu() @@ -201,10 +202,11 @@ extension TableViewCoordinator { let column = tableView.tableColumns[columnIndex] guard let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return } + let tableRows = tableRowsProvider() let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, + for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, columnIndex: dataColumnIndex, - rowProvider: rowProvider + tableRows: tableRows ) column.width = width hasUserResizedColumns = true @@ -213,14 +215,15 @@ extension TableViewCoordinator { @objc func sizeAllColumnsToFit(_ sender: NSMenuItem) { guard let tableView else { return } + let tableRows = tableRowsProvider() for column in tableView.tableColumns { guard column.identifier.rawValue != "__rowNumber__", let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { continue } let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, + for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, columnIndex: dataColumnIndex, - rowProvider: rowProvider + tableRows: tableRows ) column.width = width } @@ -241,13 +244,12 @@ extension TableViewCoordinator { ) } - // Update the provider's format array and refresh - var formats = rowProvider.columnDisplayFormats + var formats = columnDisplayFormats while formats.count <= info.columnIndex { formats.append(nil) } formats[info.columnIndex] = (info.format == .raw) ? nil : info.format - rowProvider.updateDisplayFormats(formats) + updateDisplayFormats(formats) guard let tableView else { return } let visibleRect = tableView.visibleRect diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index a62f4db54..c07e554ad 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -248,9 +248,8 @@ final class KeyHandlingTableView: NSTableView { return } - // Multiline values use overlay editor instead of field editor let columnIndex = DataGridView.dataColumnIndex(for: focusedColumn) - if let value = coordinator?.rowProvider.value(atRow: row, column: columnIndex), + if let value = coordinator?.cellValue(at: row, column: columnIndex), value.containsLineBreak { coordinator?.showOverlayEditor(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex, value: value) return diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index c7e3a856e..b30fd0004 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -6,9 +6,7 @@ import SwiftUI internal struct ResultsJsonView: View { - let columns: [String] - let columnTypes: [ColumnType] - let rows: [[String?]] + let tableRows: TableRows let selectedRowIndices: Set @State private var viewMode: JSONViewMode @@ -20,19 +18,20 @@ internal struct ResultsJsonView: View { @State private var copied = false init( - columns: [String], - columnTypes: [ColumnType], - rows: [[String?]], + tableRows: TableRows, selectedRowIndices: Set ) { - self.columns = columns - self.columnTypes = columnTypes - self.rows = rows + self.tableRows = tableRows self.selectedRowIndices = selectedRowIndices self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode) } + private var allRows: [[String?]] { + tableRows.rows.map(\.values) + } + private var displayRows: [[String?]] { + let rows = allRows if selectedRowIndices.isEmpty { return rows } @@ -43,7 +42,7 @@ internal struct ResultsJsonView: View { private var rowCountText: String { let displaying = displayRows.count - let total = rows.count + let total = tableRows.count if selectedRowIndices.isEmpty || displaying == total { return String(format: String(localized: "%d rows"), total) } @@ -59,7 +58,7 @@ internal struct ResultsJsonView: View { } .onAppear { rebuildJson() } .onChange(of: selectedRowIndices) { rebuildJson() } - .onChange(of: rows.count) { rebuildJson() } + .onChange(of: tableRows.count) { rebuildJson() } .onChange(of: viewMode) { AppSettingsManager.shared.editor.jsonViewerPreferredMode = viewMode } @@ -107,7 +106,7 @@ internal struct ResultsJsonView: View { @ViewBuilder private var content: some View { - if rows.isEmpty { + if tableRows.rows.isEmpty { ContentUnavailableView( String(localized: "No Data"), systemImage: "curlybraces", @@ -153,7 +152,7 @@ internal struct ResultsJsonView: View { // MARK: - JSON Generation private func rebuildJson() { - let converter = JsonRowConverter(columns: columns, columnTypes: columnTypes) + let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes) let json = converter.generateJson(rows: displayRows) cachedJson = json prettyText = json.prettyPrintedAsJson() ?? json diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/RowDeltaApplying.swift deleted file mode 100644 index affc103e3..000000000 --- a/TablePro/Views/Results/RowDeltaApplying.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -@MainActor -protocol RowDeltaApplying: AnyObject { - func applyInsertedRows(_ indices: IndexSet) - func applyRemovedRows(_ indices: IndexSet) - func applyFullReplace() - func invalidateCachesForUndoRedo() -} - -extension TableViewCoordinator: RowDeltaApplying {} diff --git a/TablePro/Views/Results/RowProviderCache.swift b/TablePro/Views/Results/RowProviderCache.swift deleted file mode 100644 index f97becb93..000000000 --- a/TablePro/Views/Results/RowProviderCache.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -@MainActor -final class RowProviderCache { - private struct Entry { - let provider: InMemoryRowProvider - let schemaVersion: Int - let metadataVersion: Int - let sortState: SortState - } - - private var entries: [UUID: Entry] = [:] - - func provider( - for tabId: UUID, - schemaVersion: Int, - metadataVersion: Int, - sortState: SortState - ) -> InMemoryRowProvider? { - guard let entry = entries[tabId], - entry.schemaVersion == schemaVersion, - entry.metadataVersion == metadataVersion, - entry.sortState == sortState - else { - return nil - } - return entry.provider - } - - func store( - _ provider: InMemoryRowProvider, - for tabId: UUID, - schemaVersion: Int, - metadataVersion: Int, - sortState: SortState - ) { - entries[tabId] = Entry( - provider: provider, - schemaVersion: schemaVersion, - metadataVersion: metadataVersion, - sortState: sortState - ) - } - - func remove(for tabId: UUID) { - entries.removeValue(forKey: tabId) - } - - func retain(tabIds: Set) { - entries = entries.filter { tabIds.contains($0.key) } - } - - func removeAll() { - entries.removeAll() - } - - var isEmpty: Bool { - entries.isEmpty - } -} diff --git a/TablePro/Views/Results/TableRowViewWithMenu.swift b/TablePro/Views/Results/TableRowViewWithMenu.swift index 645d25e2d..a7dd7b7e2 100644 --- a/TablePro/Views/Results/TableRowViewWithMenu.swift +++ b/TablePro/Views/Results/TableRowViewWithMenu.swift @@ -98,11 +98,11 @@ final class TableRowViewWithMenu: NSTableRowView { menu.addItem(pasteItem) } - // FK actions (only for FK columns with non-empty values) - if dataColumnIndex >= 0, dataColumnIndex < coordinator.rowProvider.columns.count { - let columnName = coordinator.rowProvider.columns[dataColumnIndex] - if let fkInfo = coordinator.rowProvider.columnForeignKeys[columnName], - let cellValue = coordinator.rowProvider.value(atRow: rowIndex, column: dataColumnIndex), + let tableRows = coordinator.tableRowsProvider() + if dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count { + let columnName = tableRows.columns[dataColumnIndex] + if let fkInfo = tableRows.columnForeignKeys[columnName], + let cellValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex), !cellValue.isEmpty { menu.addItem(NSMenuItem.separator()) @@ -139,11 +139,11 @@ final class TableRowViewWithMenu: NSTableRowView { emptyItem.target = self setValueMenu.addItem(emptyItem) - let columnName = dataColumnIndex < coordinator.rowProvider.columns.count - ? coordinator.rowProvider.columns[dataColumnIndex] + let columnName = dataColumnIndex < tableRows.columns.count + ? tableRows.columns[dataColumnIndex] : nil - let isNullable = columnName.flatMap { coordinator.rowProvider.columnNullable[$0] } ?? true + let isNullable = columnName.flatMap { tableRows.columnNullable[$0] } ?? true if isNullable { let nullItem = NSMenuItem( title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "") @@ -152,7 +152,7 @@ final class TableRowViewWithMenu: NSTableRowView { setValueMenu.addItem(nullItem) } - let hasDefault = columnName.flatMap({ coordinator.rowProvider.columnDefaults[$0] ?? nil }) != nil + let hasDefault = columnName.flatMap({ tableRows.columnDefaults[$0] ?? nil }) != nil if hasDefault { let defaultItem = NSMenuItem( title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") @@ -297,9 +297,11 @@ final class TableRowViewWithMenu: NSTableRowView { @objc private func navigateToForeignKey(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int, let coordinator else { return } - let columnName = coordinator.rowProvider.columns[columnIndex] - guard let fkInfo = coordinator.rowProvider.columnForeignKeys[columnName], - let value = coordinator.rowProvider.value(atRow: rowIndex, column: columnIndex) else { return } + let tableRows = coordinator.tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName], + let value = coordinator.cellValue(at: rowIndex, column: columnIndex) else { return } coordinator.delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) } } diff --git a/TablePro/Views/Results/TableRowsController.swift b/TablePro/Views/Results/TableRowsController.swift new file mode 100644 index 000000000..60bc48d7a --- /dev/null +++ b/TablePro/Views/Results/TableRowsController.swift @@ -0,0 +1,54 @@ +import AppKit +import Foundation + +@MainActor +final class TableRowsController { + weak var tableView: NSTableView? + + var insertAnimation: NSTableView.AnimationOptions = .slideDown + var removeAnimation: NSTableView.AnimationOptions = .slideUp + + init(tableView: NSTableView? = nil) { + self.tableView = tableView + } + + func attach(_ tableView: NSTableView) { + self.tableView = tableView + } + + func detach() { + tableView = nil + } + + func apply(_ delta: Delta) { + guard let tableView else { return } + switch delta { + case .cellChanged(let row, let column): + guard row >= 0, row < tableView.numberOfRows else { return } + guard column >= 0, column < tableView.numberOfColumns else { return } + tableView.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column)) + case .cellsChanged(let positions): + guard !positions.isEmpty else { return } + var rowSet = IndexSet() + var colSet = IndexSet() + for position in positions { + if position.row >= 0, position.row < tableView.numberOfRows { + rowSet.insert(position.row) + } + if position.column >= 0, position.column < tableView.numberOfColumns { + colSet.insert(position.column) + } + } + guard !rowSet.isEmpty, !colSet.isEmpty else { return } + tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) + case .rowsInserted(let indices): + guard !indices.isEmpty else { return } + tableView.insertRows(at: indices, withAnimation: insertAnimation) + case .rowsRemoved(let indices): + guard !indices.isEmpty else { return } + tableView.removeRows(at: indices, withAnimation: removeAnimation) + case .columnsReplaced, .fullReplace: + tableView.reloadData() + } + } +} diff --git a/TablePro/Views/Results/TableViewCoordinating.swift b/TablePro/Views/Results/TableViewCoordinating.swift new file mode 100644 index 000000000..8c4b55bb3 --- /dev/null +++ b/TablePro/Views/Results/TableViewCoordinating.swift @@ -0,0 +1,14 @@ +import Foundation + +@MainActor +protocol TableViewCoordinating: AnyObject { + func applyInsertedRows(_ indices: IndexSet) + func applyRemovedRows(_ indices: IndexSet) + func applyFullReplace() + func applyDelta(_ delta: Delta) + func invalidateCachesForUndoRedo() + func commitActiveCellEdit() + func beginEditing(displayRow: Int, column: Int) +} + +extension TableViewCoordinator: TableViewCoordinating {} diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index bd4c840d7..eb9179b79 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -47,7 +47,6 @@ struct CreateTableView: View { // DataGridView state @State private var selectedRows: Set = [] @State private var sortState = SortState() - @State private var editingCell: CellPosition? @State private var columnLayout = ColumnLayoutState() init(connection: DatabaseConnection, coordinator: MainContentCoordinator?) { @@ -235,8 +234,9 @@ struct CreateTableView: View { additionalFields: [.primaryKey] ) + let tableRows = provider.asTableRows() return DataGridView( - rowProvider: provider.asInMemoryProvider(), + tableRowsProvider: { tableRows }, changeManager: wrappedChangeManager, isEditable: true, configuration: DataGridConfiguration( @@ -248,7 +248,6 @@ struct CreateTableView: View { delegate: gridDelegate, selectedRowIndices: $selectedRows, sortState: $sortState, - editingCell: $editingCell, columnLayout: $columnLayout ) } diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index 7692afb89..c351563e5 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -2,8 +2,7 @@ // StructureRowProvider.swift // TablePro // -// Adapts structure entities (columns/indexes/FKs) to InMemoryRowProvider interface -// Converts entity-based data to row-based format for DataGridView +// Adapts structure entities (columns/indexes/FKs) to TableRows for DataGridView // import Foundation @@ -149,7 +148,7 @@ final class StructureRowProvider { return canonicalFieldOrder.filter { fields.contains($0) } } - // MARK: - InMemoryRowProvider-compatible methods + // MARK: - Row Access func row(at index: Int) -> [String?]? { guard index >= 0, index < cachedRows.count else { return nil } @@ -258,13 +257,13 @@ final class StructureRowProvider { } } -// MARK: - Helper to create InMemoryRowProvider +// MARK: - Helper to create TableRows extension StructureRowProvider { - /// Creates an InMemoryRowProvider from structure data - func asInMemoryProvider() -> InMemoryRowProvider { - InMemoryRowProvider( - rows: rows, + /// Creates a TableRows snapshot from structure data + func asTableRows() -> TableRows { + TableRows.from( + queryRows: rows, columns: columns, columnTypes: columnTypes ) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 5bcd35fd4..41b867dda 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -48,7 +48,6 @@ struct TableStructureView: View { @State var wrappedChangeManager: AnyChangeManager @State var selectedRows: Set = [] @State var sortState = SortState() - @State var editingCell: CellPosition? @State var structureColumnLayout = ColumnLayoutState() @State var actionHandler = StructureViewActionHandler() @State var gridDelegate: StructureGridDelegate @@ -268,8 +267,9 @@ struct TableStructureView: View { let customOptions = provider.customDropdownOptions let allDropdownColumns = provider.dropdownColumns.union(Set(customOptions.keys)) + let tableRows = provider.asTableRows() return DataGridView( - rowProvider: provider.asInMemoryProvider(), + tableRowsProvider: { tableRows }, changeManager: wrappedChangeManager, schemaVersion: displayVersion, isEditable: canEdit, @@ -283,7 +283,6 @@ struct TableStructureView: View { delegate: gridDelegate, selectedRowIndices: $selectedRows, sortState: $sortState, - editingCell: $editingCell, columnLayout: $structureColumnLayout ) .safeAreaInset(edge: .top, spacing: 0) { diff --git a/TableProTests/Core/Services/Query/RowDataStoreTests.swift b/TableProTests/Core/Services/Query/RowDataStoreTests.swift deleted file mode 100644 index 3a7883426..000000000 --- a/TableProTests/Core/Services/Query/RowDataStoreTests.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// RowDataStoreTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("RowDataStore") -@MainActor -struct RowDataStoreTests { - - @Test("buffer(for:) creates an empty RowBuffer on first access and returns the same instance after") - func bufferCreatesAndReturnsSameInstance() { - let store = RowDataStore() - let tabId = UUID() - - let first = store.buffer(for: tabId) - #expect(first.rows.isEmpty) - #expect(first.columns.isEmpty) - #expect(first.isEvicted == false) - - let second = store.buffer(for: tabId) - #expect(ObjectIdentifier(first) == ObjectIdentifier(second)) - } - - @Test("setBuffer(_:for:) replaces the buffer for a tab id") - func setBufferReplacesEntry() { - let store = RowDataStore() - let tabId = UUID() - - let original = store.buffer(for: tabId) - let replacement = RowBuffer(rows: [["a"]], columns: ["c"]) - store.setBuffer(replacement, for: tabId) - - let resolved = store.buffer(for: tabId) - #expect(ObjectIdentifier(resolved) == ObjectIdentifier(replacement)) - #expect(ObjectIdentifier(resolved) != ObjectIdentifier(original)) - } - - @Test("existingBuffer(for:) returns nil before storage and the stored buffer afterwards") - func existingBufferReflectsState() { - let store = RowDataStore() - let tabId = UUID() - - #expect(store.existingBuffer(for: tabId) == nil) - - let buffer = RowBuffer(rows: [["x"]], columns: ["c"]) - store.setBuffer(buffer, for: tabId) - - let resolved = store.existingBuffer(for: tabId) - #expect(resolved != nil) - #expect(resolved.map(ObjectIdentifier.init) == ObjectIdentifier(buffer)) - } - - @Test("removeBuffer(for:) deletes the entry") - func removeBufferDeletes() { - let store = RowDataStore() - let tabId = UUID() - - store.setBuffer(RowBuffer(rows: [["x"]], columns: ["c"]), for: tabId) - #expect(store.existingBuffer(for: tabId) != nil) - - store.removeBuffer(for: tabId) - #expect(store.existingBuffer(for: tabId) == nil) - } - - @Test("evict(for:) calls evict on the stored buffer") - func evictMarksBuffer() { - let store = RowDataStore() - let tabId = UUID() - let buffer = RowBuffer(rows: [["a"], ["b"]], columns: ["c"]) - store.setBuffer(buffer, for: tabId) - - #expect(buffer.isEvicted == false) - store.evict(for: tabId) - - #expect(buffer.isEvicted == true) - #expect(buffer.rows.isEmpty) - } - - @Test("evict(for:) is a no-op for unknown tab ids") - func evictUnknownTabIsNoOp() { - let store = RowDataStore() - store.evict(for: UUID()) - } - - @Test("evictAll(except:) evicts every other tab and spares the active one") - func evictAllSparesActive() { - let store = RowDataStore() - let activeId = UUID() - let otherId1 = UUID() - let otherId2 = UUID() - - let activeBuffer = RowBuffer(rows: [["a"]], columns: ["c"]) - let otherBuffer1 = RowBuffer(rows: [["b"]], columns: ["c"]) - let otherBuffer2 = RowBuffer(rows: [["d"]], columns: ["c"]) - - store.setBuffer(activeBuffer, for: activeId) - store.setBuffer(otherBuffer1, for: otherId1) - store.setBuffer(otherBuffer2, for: otherId2) - - store.evictAll(except: activeId) - - #expect(activeBuffer.isEvicted == false) - #expect(activeBuffer.rows.count == 1) - #expect(otherBuffer1.isEvicted == true) - #expect(otherBuffer1.rows.isEmpty) - #expect(otherBuffer2.isEvicted == true) - #expect(otherBuffer2.rows.isEmpty) - } - - @Test("evictAll(except: nil) evicts every loaded tab") - func evictAllNoActiveEvictsAll() { - let store = RowDataStore() - let buffer1 = RowBuffer(rows: [["a"]], columns: ["c"]) - let buffer2 = RowBuffer(rows: [["b"]], columns: ["c"]) - store.setBuffer(buffer1, for: UUID()) - store.setBuffer(buffer2, for: UUID()) - - store.evictAll(except: nil) - - #expect(buffer1.isEvicted == true) - #expect(buffer2.isEvicted == true) - } - - @Test("evictAll(except:) skips empty buffers") - func evictAllSkipsEmpty() { - let store = RowDataStore() - let emptyBuffer = RowBuffer() - store.setBuffer(emptyBuffer, for: UUID()) - - store.evictAll(except: nil) - #expect(emptyBuffer.isEvicted == false) - } - - @Test("tearDown() clears the store") - func tearDownClearsAll() { - let store = RowDataStore() - let tabId1 = UUID() - let tabId2 = UUID() - store.setBuffer(RowBuffer(rows: [["a"]], columns: ["c"]), for: tabId1) - store.setBuffer(RowBuffer(rows: [["b"]], columns: ["c"]), for: tabId2) - - store.tearDown() - - #expect(store.existingBuffer(for: tabId1) == nil) - #expect(store.existingBuffer(for: tabId2) == nil) - } -} diff --git a/TableProTests/Core/Services/Query/TableRowsStoreTests.swift b/TableProTests/Core/Services/Query/TableRowsStoreTests.swift new file mode 100644 index 000000000..c2be639d4 --- /dev/null +++ b/TableProTests/Core/Services/Query/TableRowsStoreTests.swift @@ -0,0 +1,233 @@ +import Foundation +import Testing +@testable import TablePro + +@Suite("TableRowsStore") +@MainActor +struct TableRowsStoreTests { + + @Test("tableRows(for:) creates empty TableRows on first access and returns the same on second") + func tableRowsCreatesAndReturnsSameValue() { + let store = TableRowsStore() + let tabId = UUID() + + let first = store.tableRows(for: tabId) + #expect(first.rows.isEmpty) + #expect(first.columns.isEmpty) + #expect(store.isEvicted(tabId) == false) + + let second = store.tableRows(for: tabId) + #expect(second.rows.count == first.rows.count) + #expect(second.columns == first.columns) + } + + @Test("setTableRows(_:for:) replaces stored value") + func setTableRowsReplacesEntry() { + let store = TableRowsStore() + let tabId = UUID() + + _ = store.tableRows(for: tabId) + let replacement = TableRows.from( + queryRows: [["a"]], + columns: ["c"], + columnTypes: [.text(rawType: nil)] + ) + store.setTableRows(replacement, for: tabId) + + let resolved = store.tableRows(for: tabId) + #expect(resolved.rows.count == 1) + #expect(resolved.columns == ["c"]) + } + + @Test("existingTableRows(for:) returns nil before set and value after") + func existingTableRowsReflectsState() { + let store = TableRowsStore() + let tabId = UUID() + + #expect(store.existingTableRows(for: tabId) == nil) + + let rows = TableRows.from( + queryRows: [["x"]], + columns: ["c"], + columnTypes: [.text(rawType: nil)] + ) + store.setTableRows(rows, for: tabId) + + let resolved = store.existingTableRows(for: tabId) + #expect(resolved != nil) + #expect(resolved?.rows.count == 1) + } + + @Test("removeTableRows(for:) deletes the entry and clears evicted state") + func removeTableRowsDeletes() { + let store = TableRowsStore() + let tabId = UUID() + + store.setTableRows( + TableRows.from(queryRows: [["x"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + store.evict(for: tabId) + #expect(store.isEvicted(tabId) == true) + + store.removeTableRows(for: tabId) + #expect(store.existingTableRows(for: tabId) == nil) + #expect(store.isEvicted(tabId) == false) + } + + @Test("evict(for:) clears rows and marks evicted while preserving columns") + func evictMarksEvicted() { + let store = TableRowsStore() + let tabId = UUID() + let rows = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c"], + columnTypes: [.text(rawType: nil)] + ) + store.setTableRows(rows, for: tabId) + + #expect(store.isEvicted(tabId) == false) + store.evict(for: tabId) + + #expect(store.isEvicted(tabId) == true) + let evicted = store.existingTableRows(for: tabId) + #expect(evicted?.rows.isEmpty == true) + #expect(evicted?.columns == ["c"]) + } + + @Test("evict(for:) is no-op for unknown tab") + func evictUnknownTabIsNoOp() { + let store = TableRowsStore() + store.evict(for: UUID()) + } + + @Test("evictAll(except:) evicts other tabs and spares the active one") + func evictAllSparesActive() { + let store = TableRowsStore() + let activeId = UUID() + let otherId1 = UUID() + let otherId2 = UUID() + + let active = TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]) + let other1 = TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]) + let other2 = TableRows.from(queryRows: [["d"]], columns: ["c"], columnTypes: [.text(rawType: nil)]) + + store.setTableRows(active, for: activeId) + store.setTableRows(other1, for: otherId1) + store.setTableRows(other2, for: otherId2) + + store.evictAll(except: activeId) + + #expect(store.isEvicted(activeId) == false) + #expect(store.existingTableRows(for: activeId)?.rows.count == 1) + #expect(store.isEvicted(otherId1) == true) + #expect(store.existingTableRows(for: otherId1)?.rows.isEmpty == true) + #expect(store.isEvicted(otherId2) == true) + } + + @Test("evictAll(except: nil) evicts every loaded tab") + func evictAllNoActiveEvictsAll() { + let store = TableRowsStore() + let id1 = UUID() + let id2 = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id1 + ) + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id2 + ) + + store.evictAll(except: nil) + + #expect(store.isEvicted(id1) == true) + #expect(store.isEvicted(id2) == true) + } + + @Test("evictAll(except:) skips empty tables") + func evictAllSkipsEmpty() { + let store = TableRowsStore() + let tabId = UUID() + store.setTableRows(TableRows(), for: tabId) + + store.evictAll(except: nil) + #expect(store.isEvicted(tabId) == false) + } + + @Test("setTableRows clears evicted flag") + func setClearsEvicted() { + let store = TableRowsStore() + let tabId = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + store.evict(for: tabId) + #expect(store.isEvicted(tabId) == true) + + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + #expect(store.isEvicted(tabId) == false) + } + + @Test("updateTableRows applies mutation in place") + func updateTableRowsAppliesMutation() { + let store = TableRowsStore() + let tabId = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + + store.updateTableRows(for: tabId) { rows in + _ = rows.edit(row: 0, column: 0, value: "z") + } + + let resolved = store.existingTableRows(for: tabId) + #expect(resolved?.value(at: 0, column: 0) == "z") + } + + @Test("closing one tab removes only its TableRows entry, leaving siblings intact") + func closingTabRemovesOnlyThatEntry() { + let store = TableRowsStore() + let tabId1 = UUID() + let tabId2 = UUID() + + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId1 + ) + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId2 + ) + + store.removeTableRows(for: tabId1) + + #expect(store.existingTableRows(for: tabId1) == nil) + #expect(store.existingTableRows(for: tabId2)?.rows.count == 1) + } + + @Test("tearDown() clears the store") + func tearDownClearsAll() { + let store = TableRowsStore() + let id1 = UUID() + let id2 = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id1 + ) + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id2 + ) + + store.tearDown() + + #expect(store.existingTableRows(for: id1) == nil) + #expect(store.existingTableRows(for: id2) == nil) + } +} diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index df4b27d80..f3aa7ef33 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -1,11 +1,3 @@ -// -// RowOperationsManagerCopyTests.swift -// TableProTests -// -// Regression tests for RowOperationsManager copy optimization (P2-6). -// Validates TSV formatting, NULL handling, and large-row correctness. -// - import Foundation @testable import TablePro import Testing @@ -30,13 +22,13 @@ private final class MockClipboardProvider: ClipboardProvider { @MainActor @Suite("RowOperationsManager Copy") struct RowOperationsManagerCopyTests { - // MARK: - Helpers + private static let defaultColumns = ["id", "name", "email"] private func makeManager() -> (RowOperationsManager, DataChangeManager) { let changeManager = DataChangeManager() changeManager.configureForTable( tableName: "users", - columns: ["id", "name", "email"], + columns: Self.defaultColumns, primaryKeyColumns: ["id"], databaseType: .mysql ) @@ -44,26 +36,30 @@ struct RowOperationsManagerCopyTests { return (manager, changeManager) } + private func makeTableRows(rows: [[String?]], columns: [String]? = nil) -> TableRows { + let cols = columns ?? Self.defaultColumns + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: cols.count) + return TableRows.from(queryRows: rows, columns: cols, columnTypes: columnTypes) + } + private func copyAndCapture( manager: RowOperationsManager, indices: Set, rows: [[String?]], - columns: [String] = [], + columns: [String]? = nil, includeHeaders: Bool = false ) -> String? { let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + let tableRows = makeTableRows(rows: rows, columns: columns ?? Self.defaultColumns) manager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: rows, - columns: columns, + tableRows: tableRows, includeHeaders: includeHeaders ) return clipboard.lastWrittenText } - // MARK: - Single Row TSV - @Test("Single row copy produces tab-separated values") func singleRowTSV() { let (manager, _) = makeManager() @@ -74,8 +70,6 @@ struct RowOperationsManagerCopyTests { #expect(result == "1\tAlice\talice@test.com") } - // MARK: - Multiple Rows - @Test("Multiple rows separated by newlines in TSV format") func multipleRowsTSV() { let (manager, _) = makeManager() @@ -89,8 +83,6 @@ struct RowOperationsManagerCopyTests { #expect(result == "1\tAlice\ta@test.com\n2\tBob\tb@test.com") } - // MARK: - NULL Handling - @Test("NULL values rendered as literal NULL string") func nullValuesRenderedAsNullString() { let (manager, _) = makeManager() @@ -117,25 +109,22 @@ struct RowOperationsManagerCopyTests { #expect(lines?[1] == "NULL\tBob\tNULL") } - // MARK: - Empty Selection - @Test("Empty selection produces no clipboard write") func emptySelectionNoWrite() { let (manager, _) = makeManager() let rows = TestFixtures.makeRows(count: 3) let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + let tableRows = makeTableRows(rows: rows) manager.copySelectedRowsToClipboard( selectedIndices: [], - resultRows: rows + tableRows: tableRows ) #expect(clipboard.lastWrittenText == nil) } - // MARK: - Large Row Count - @Test("Large row count produces correct first and last rows") func largeRowCount() { let (manager, _) = makeManager() @@ -156,8 +145,6 @@ struct RowOperationsManagerCopyTests { #expect(lines.last == "\(count - 1)\tname_\(count - 1)\temail_\(count - 1)") } - // MARK: - Row Ordering - @Test("Copied rows are in sorted index order regardless of selection order") func rowsInSortedOrder() { let (manager, _) = makeManager() @@ -167,13 +154,11 @@ struct RowOperationsManagerCopyTests { ["C"], ] - let result = copyAndCapture(manager: manager, indices: [2, 0], rows: rows) + let result = copyAndCapture(manager: manager, indices: [2, 0], rows: rows, columns: ["letter"]) #expect(result == "A\nC") } - // MARK: - Include Headers - @Test("Copy with headers prepends column names as first TSV line") func copyWithHeaders() { let (manager, _) = makeManager() @@ -193,20 +178,16 @@ struct RowOperationsManagerCopyTests { #expect(lines[1] == "1\tAlice\ta@test.com") } - // MARK: - Out-of-Bounds Index - @Test("Out-of-bounds indices are skipped gracefully") func outOfBoundsIndicesSkipped() { let (manager, _) = makeManager() let rows: [[String?]] = [["1", "Alice"]] - let result = copyAndCapture(manager: manager, indices: [0, 5, 10], rows: rows) + let result = copyAndCapture(manager: manager, indices: [0, 5, 10], rows: rows, columns: ["id", "name"]) #expect(result == "1\tAlice") } - // MARK: - All NULL Row - @Test("Row with all NULL values produces tab-separated NULL strings") func allNullRow() { let (manager, _) = makeManager() diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index af362c956..ce6f969d0 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -1,10 +1,3 @@ -// -// RowOperationsManagerTests.swift -// TableProTests -// -// Tests for RowOperationsManager row operations: add, duplicate, delete, undo/redo. -// - import Foundation @testable import TablePro import Testing @@ -12,13 +5,17 @@ import Testing @MainActor @Suite("Row Operations Manager") struct RowOperationsManagerTests { - // MARK: - Test Helpers + private static let testColumns = ["id", "name", "email"] + private static let testColumnTypes: [ColumnType] = Array( + repeating: .text(rawType: nil), + count: 3 + ) private func makeManager() -> (RowOperationsManager, DataChangeManager) { let changeManager = DataChangeManager() changeManager.configureForTable( tableName: "users", - columns: ["id", "name", "email"], + columns: Self.testColumns, primaryKeyColumns: ["id"], databaseType: .mysql ) @@ -26,52 +23,87 @@ struct RowOperationsManagerTests { return (manager, changeManager) } - // MARK: - addNewRow Tests + private func makeTableRows(rowCount: Int) -> TableRows { + TableRows.from( + queryRows: TestFixtures.makeRows(count: rowCount, columns: Self.testColumns), + columns: Self.testColumns, + columnTypes: Self.testColumnTypes + ) + } + + private func emptyTableRows() -> TableRows { + TableRows.from( + queryRows: [], + columns: Self.testColumns, + columnTypes: Self.testColumnTypes + ) + } - @Test("addNewRow appends row to resultRows") + @Test("addNewRow appends row to tableRows") func addNewRowAppendsRow() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) - let originalCount = rows.count + var tableRows = makeTableRows(rowCount: 3) + let originalCount = tableRows.count _ = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) - #expect(rows.count == originalCount + 1) + #expect(tableRows.count == originalCount + 1) } - @Test("addNewRow returns correct row index") + @Test("addNewRow returns correct row index and inserted delta") func addNewRowReturnsCorrectIndex() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) #expect(result?.rowIndex == 5) + if case .rowsInserted(let indices) = result?.delta { + #expect(indices == IndexSet(integer: 5)) + } else { + Issue.record("Expected .rowsInserted delta") + } + } + + @Test("addNewRow assigns inserted RowID to new row") + func addNewRowAssignsInsertedRowID() { + let (manager, _) = makeManager() + var tableRows = makeTableRows(rowCount: 2) + + let result = manager.addNewRow( + columns: Self.testColumns, + columnDefaults: [:], + tableRows: &tableRows + ) + + #expect(result != nil) + let newIndex = result!.rowIndex + #expect(tableRows.rows[newIndex].id.isInserted) } @Test("addNewRow uses DEFAULT marker for columns with defaults") func addNewRowUsesDefaultMarker() { let (manager, _) = makeManager() - var rows: [[String?]] = [] + var tableRows = emptyTableRows() let defaults: [String: String?] = [ "id": "auto_increment", "name": nil, - "email": "user@example.com" + "email": "user@example.com", ] let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: defaults, - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) @@ -82,15 +114,15 @@ struct RowOperationsManagerTests { @Test("addNewRow uses nil for columns without defaults") func addNewRowUsesNilForNoDefaults() { let (manager, _) = makeManager() - var rows: [[String?]] = [] + var tableRows = emptyTableRows() let defaults: [String: String?] = [ - "id": "auto_increment" + "id": "auto_increment", ] let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: defaults, - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) @@ -101,12 +133,12 @@ struct RowOperationsManagerTests { @Test("addNewRow records insertion in change manager") func addNewRowRecordsInsertion() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) @@ -117,13 +149,13 @@ struct RowOperationsManagerTests { @Test("addNewRow increments change manager reload version") func addNewRowIncrementsReloadVersion() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) let versionBefore = changeManager.reloadVersion _ = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(changeManager.reloadVersion > versionBefore) @@ -132,77 +164,77 @@ struct RowOperationsManagerTests { @Test("multiple addNewRow calls append sequential rows") func multipleAddNewRowAppendsSequentially() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) - let r1 = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - let r2 = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - let r3 = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) + let r1 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + let r2 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + let r3 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) - #expect(rows.count == 5) + #expect(tableRows.count == 5) #expect(r1?.rowIndex == 2) #expect(r2?.rowIndex == 3) #expect(r3?.rowIndex == 4) } - // MARK: - duplicateRow Tests - @Test("duplicateRow copies source row values") func duplicateRowCopiesValues() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) - let sourceValues = rows[1] + var tableRows = makeTableRows(rowCount: 3) + let sourceValues = tableRows.rows[1].values let result = manager.duplicateRow( sourceRowIndex: 1, - columns: ["id", "name", "email"], - resultRows: &rows + columns: Self.testColumns, + tableRows: &tableRows ) #expect(result != nil) - // Non-PK columns should match source #expect(result?.values[1] == sourceValues[1]) #expect(result?.values[2] == sourceValues[2]) } - @Test("duplicateRow sets primary key to DEFAULT") + @Test("duplicateRow sets primary key to DEFAULT and returns inserted delta") func duplicateRowSetsPkToDefault() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) let result = manager.duplicateRow( sourceRowIndex: 0, - columns: ["id", "name", "email"], - resultRows: &rows + columns: Self.testColumns, + tableRows: &tableRows ) #expect(result != nil) #expect(result?.values[0] == "__DEFAULT__") + if case .rowsInserted(let indices) = result?.delta { + #expect(indices == IndexSet(integer: 3)) + } else { + Issue.record("Expected .rowsInserted delta") + } } @Test("duplicateRow returns nil for invalid source index") func duplicateRowReturnsNilForInvalidIndex() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) let result = manager.duplicateRow( sourceRowIndex: 10, - columns: ["id", "name", "email"], - resultRows: &rows + columns: Self.testColumns, + tableRows: &tableRows ) #expect(result == nil) } - // MARK: - deleteSelectedRows Tests - @Test("deleteSelectedRows marks existing rows as deleted") func deleteSelectedRowsMarksExistingAsDeleted() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) _ = manager.deleteSelectedRows( selectedIndices: [1, 3], - resultRows: &rows + tableRows: &tableRows ) #expect(changeManager.hasChanges) @@ -210,115 +242,121 @@ struct RowOperationsManagerTests { #expect(changeManager.isRowDeleted(3)) } - @Test("deleteSelectedRows removes inserted rows from resultRows") + @Test("deleteSelectedRows removes inserted rows from tableRows and reports delta") func deleteSelectedRowsRemovesInsertedRows() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) - // Insert a new row first - let result = manager.addNewRow( - columns: ["id", "name", "email"], + let addResult = manager.addNewRow( + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) - #expect(rows.count == 4) + #expect(tableRows.count == 4) - // Delete the inserted row - _ = manager.deleteSelectedRows( - selectedIndices: [result!.rowIndex], - resultRows: &rows + let result = manager.deleteSelectedRows( + selectedIndices: [addResult!.rowIndex], + tableRows: &tableRows ) - #expect(rows.count == 3) + #expect(tableRows.count == 3) + if case .rowsRemoved(let indices) = result.delta { + #expect(indices == IndexSet(integer: addResult!.rowIndex)) + } else { + Issue.record("Expected .rowsRemoved delta") + } } @Test("deleteSelectedRows returns correct next selection") func deleteSelectedRowsReturnsNextSelection() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - #expect(rows.count == 6) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + #expect(tableRows.count == 6) let result = manager.deleteSelectedRows( selectedIndices: [5], - resultRows: &rows + tableRows: &tableRows ) #expect(result.nextRowToSelect >= 0) - #expect(result.nextRowToSelect < rows.count) + #expect(result.nextRowToSelect < tableRows.count) } - @Test("deleteSelectedRows returns empty physicallyRemovedIndices for empty selection") + @Test("deleteSelectedRows returns empty result for empty selection") func deleteSelectedRowsEmptySelection() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) - let result = manager.deleteSelectedRows(selectedIndices: [], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [], tableRows: &tableRows) #expect(result.physicallyRemovedIndices.isEmpty) #expect(result.nextRowToSelect == -1) - #expect(rows.count == 3) + #expect(result.delta == .none) + #expect(tableRows.count == 3) } @Test("deleteSelectedRows: deleting only existing rows leaves physicallyRemovedIndices empty") func deleteSelectedRowsExistingOnly() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) - let result = manager.deleteSelectedRows(selectedIndices: [1, 3], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [1, 3], tableRows: &tableRows) #expect(result.physicallyRemovedIndices.isEmpty) - #expect(rows.count == 5) + #expect(result.delta == .none) + #expect(tableRows.count == 5) } @Test("deleteSelectedRows: deleting only inserted rows reports each in physicallyRemovedIndices") func deleteSelectedRowsInsertedOnly() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - #expect(rows.count == 5) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + #expect(tableRows.count == 5) - let result = manager.deleteSelectedRows(selectedIndices: [2, 3, 4], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [2, 3, 4], tableRows: &tableRows) #expect(result.physicallyRemovedIndices == [4, 3, 2]) - #expect(rows.count == 2) + #expect(tableRows.count == 2) + if case .rowsRemoved(let indices) = result.delta { + #expect(indices == IndexSet([2, 3, 4])) + } else { + Issue.record("Expected .rowsRemoved delta") + } } @Test("deleteSelectedRows: mixed inserted and existing rows reports only inserted indices") func deleteSelectedRowsMixed() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - #expect(rows.count == 4) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + #expect(tableRows.count == 4) - let result = manager.deleteSelectedRows(selectedIndices: [0, 3], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [0, 3], tableRows: &tableRows) #expect(result.physicallyRemovedIndices == [3]) - #expect(rows.count == 3) + #expect(tableRows.count == 3) } - // MARK: - Integration Tests - @Test("addNewRow then edit cell preserves insertion state") func addNewRowThenEditPreservesInsertion() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) - // Add a new row let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) let newIndex = result!.rowIndex - // Edit a cell in the new row changeManager.recordCellChange( rowIndex: newIndex, columnIndex: 1, @@ -327,10 +365,9 @@ struct RowOperationsManagerTests { newValue: "Alice" ) - // Both the insertion and the cell edit should be tracked #expect(changeManager.hasChanges) #expect(changeManager.isRowInserted(newIndex)) - // The row should still exist in resultRows - #expect(rows.count == 3) + #expect(tableRows.count == 3) + #expect(tableRows.rows[newIndex].id.isInserted) } } diff --git a/TableProTests/Helpers/TestFixtures.swift b/TableProTests/Helpers/TestFixtures.swift index 1601d87e3..0038c99ab 100644 --- a/TableProTests/Helpers/TestFixtures.swift +++ b/TableProTests/Helpers/TestFixtures.swift @@ -209,9 +209,10 @@ enum TestFixtures { } } - static func makeInMemoryRowProvider(rowCount: Int = 3, columns: [String] = ["id", "name", "email"]) -> InMemoryRowProvider { + static func makeTableRows(rowCount: Int = 3, columns: [String] = ["id", "name", "email"]) -> TableRows { let rows = makeRows(count: rowCount, columns: columns) - return InMemoryRowProvider(rows: rows, columns: columns) + let columnTypes = Array(repeating: ColumnType.text(rawType: nil), count: columns.count) + return TableRows.from(queryRows: rows, columns: columns, columnTypes: columnTypes) } static func makeForeignKeyInfo( diff --git a/TableProTests/Models/DisplayValueCacheTests.swift b/TableProTests/Models/DisplayValueCacheTests.swift deleted file mode 100644 index 3cd61ce3d..000000000 --- a/TableProTests/Models/DisplayValueCacheTests.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// DisplayValueCacheTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("Display Value Cache") -@MainActor -struct DisplayValueCacheTests { - private func makeProvider(rows: [[String?]], columns: [String], columnTypes: [ColumnType]? = nil) -> InMemoryRowProvider { - InMemoryRowProvider( - rows: rows, - columns: columns, - columnTypes: columnTypes - ) - } - - @Test("first access computes and returns display value") - func firstAccessComputes() { - let provider = makeProvider( - rows: [["hello", "world"]], - columns: ["a", "b"] - ) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "hello") - } - - @Test("second access returns cached value without recomputation") - func secondAccessCached() { - let provider = makeProvider( - rows: [["value1", "value2"]], - columns: ["a", "b"] - ) - let first = provider.displayValue(atRow: 0, column: 0) - let second = provider.displayValue(atRow: 0, column: 0) - #expect(first == second) - #expect(first == "value1") - } - - @Test("updateValue invalidates cache for that row") - func updateInvalidates() { - let provider = makeProvider( - rows: [["old", "keep"]], - columns: ["a", "b"] - ) - _ = provider.displayValue(atRow: 0, column: 0) - provider.updateValue("new", at: 0, columnIndex: 0) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "new") - } - - @Test("invalidateDisplayCache clears all cached values") - func invalidateAll() { - let provider = makeProvider( - rows: [["a1", "a2"], ["b1", "b2"]], - columns: ["x", "y"] - ) - _ = provider.displayValue(atRow: 0, column: 0) - _ = provider.displayValue(atRow: 1, column: 0) - provider.invalidateDisplayCache() - // After invalidation, re-access should still work (recomputes) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "a1") - } - - @Test("nil raw value returns nil display value") - func nilRawValue() { - let provider = makeProvider( - rows: [[nil, "ok"]], - columns: ["a", "b"] - ) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == nil) - } - - @Test("out-of-bounds row returns nil") - func outOfBoundsRow() { - let provider = makeProvider( - rows: [["a"]], - columns: ["x"] - ) - let result = provider.displayValue(atRow: 5, column: 0) - #expect(result == nil) - } - - @Test("linebreaks in values are sanitized in display cache") - func linebreaksSanitized() { - let provider = makeProvider( - rows: [["line1\nline2"]], - columns: ["a"] - ) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "line1 line2") - } - - @Test("updateRows clears display cache") - func updateRowsClearsCache() { - let provider = makeProvider( - rows: [["old"]], - columns: ["a"] - ) - _ = provider.displayValue(atRow: 0, column: 0) - provider.updateRows([["new"]]) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "new") - } -} diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index 453752c85..ab2187010 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -80,6 +80,103 @@ struct TableRowsReadTests { } } +@Suite("TableRows - id lookup") +struct TableRowsIDLookupTests { + @Test("index(of:) returns the storage index for an existing RowID") + func indexOfExistingRowID() { + let table = TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.index(of: .existing(0)) == 0) + #expect(table.index(of: .existing(1)) == 1) + #expect(table.index(of: .existing(2)) == 2) + } + + @Test("index(of:) returns nil for an unknown RowID") + func indexOfUnknownRowID() { + let table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.index(of: .existing(99)) == nil) + #expect(table.index(of: .inserted(UUID())) == nil) + } + + @Test("index(of:) tracks inserted rows by their UUID after appendInsertedRow") + func indexOfInsertedRow() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["new"]) + let insertedID = table.rows[1].id + #expect(table.index(of: insertedID) == 1) + } + + @Test("index(of:) reflects shifted positions after insertInsertedRow at the head") + func indexOfShiftsAfterHeadInsert() { + var table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let originalID = table.rows[0].id + _ = table.insertInsertedRow(at: 0, values: ["z"]) + #expect(table.index(of: originalID) == 1) + } + + @Test("index(of:) reflects shifted positions after remove") + func indexOfShiftsAfterRemove() { + var table = TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.remove(at: IndexSet(integer: 0)) + #expect(table.index(of: .existing(0)) == nil) + #expect(table.index(of: .existing(1)) == 0) + #expect(table.index(of: .existing(2)) == 1) + } + + @Test("row(withID:) returns the matching Row for an existing ID") + func rowWithIDReturnsMatch() { + let table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let row = table.row(withID: .existing(1)) + #expect(row?.values == ["b"]) + #expect(row?.id == .existing(1)) + } + + @Test("row(withID:) returns nil for an unknown ID") + func rowWithIDReturnsNilForUnknown() { + let table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.row(withID: .existing(99)) == nil) + } + + @Test("row(withID:) reads back an inserted row by its UUID") + func rowWithIDReturnsInsertedRow() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["v"]) + let insertedID = table.rows[0].id + #expect(table.row(withID: insertedID)?.values == ["v"]) + } +} + @Suite("TableRows - edit") struct TableRowsEditTests { private static func makeTable() -> TableRows { @@ -207,6 +304,104 @@ struct TableRowsInsertTests { #expect(table.rows[0].values == ["only-one", nil, nil]) #expect(table.rows[1].values == ["a", "b", "c"]) } + + @Test("insertInsertedRow at the head shifts existing rows down") + func insertInsertedRowAtHead() { + var table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: 0, values: ["z"]) + #expect(delta == .rowsInserted(IndexSet(integer: 0))) + #expect(table.count == 3) + #expect(table.rows[0].values == ["z"]) + #expect(table.rows[0].id.isInserted) + #expect(table.rows[1].values == ["a"]) + #expect(table.rows[2].values == ["b"]) + } + + @Test("insertInsertedRow in the middle preserves surrounding rows") + func insertInsertedRowInMiddle() { + var table = TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: 1, values: ["z"]) + #expect(delta == .rowsInserted(IndexSet(integer: 1))) + #expect(table.count == 4) + #expect(table.rows[0].values == ["a"]) + #expect(table.rows[1].values == ["z"]) + #expect(table.rows[1].id.isInserted) + #expect(table.rows[2].values == ["b"]) + #expect(table.rows[3].values == ["c"]) + } + + @Test("insertInsertedRow at the tail (index == count) appends") + func insertInsertedRowAtTail() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: table.count, values: ["z"]) + #expect(delta == .rowsInserted(IndexSet(integer: 1))) + #expect(table.count == 2) + #expect(table.rows[1].values == ["z"]) + #expect(table.rows[1].id.isInserted) + } + + @Test("insertInsertedRow pads short values and truncates long values") + func insertInsertedRowPadsAndTruncates() { + var table = TableRows.from( + queryRows: [], + columns: ["c1", "c2", "c3"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil)] + ) + _ = table.insertInsertedRow(at: 0, values: ["only-one"]) + _ = table.insertInsertedRow(at: 1, values: ["a", "b", "c", "d"]) + #expect(table.rows[0].values == ["only-one", nil, nil]) + #expect(table.rows[1].values == ["a", "b", "c"]) + } + + @Test("insertInsertedRow with negative index returns Delta.none and does not mutate") + func insertInsertedRowNegativeIndexIsNoOp() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: -1, values: ["z"]) + #expect(delta == .none) + #expect(table.count == 1) + #expect(table.rows[0].values == ["a"]) + } + + @Test("insertInsertedRow past the end returns Delta.none and does not mutate") + func insertInsertedRowPastEndIsNoOp() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: 2, values: ["z"]) + #expect(delta == .none) + #expect(table.count == 1) + #expect(table.rows[0].values == ["a"]) + } + + @Test("Two insertInsertedRow calls produce different RowID UUIDs") + func insertInsertedRowProducesDistinctUUIDs() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.insertInsertedRow(at: 0, values: ["x"]) + _ = table.insertInsertedRow(at: 0, values: ["y"]) + #expect(table.rows[0].id != table.rows[1].id) + } } @Suite("TableRows - appendPage") diff --git a/TableProTests/Models/RowBufferTests.swift b/TableProTests/Models/RowBufferTests.swift deleted file mode 100644 index 5ad8bb7a0..000000000 --- a/TableProTests/Models/RowBufferTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -import Testing -@testable import TablePro - -@Suite("RowBuffer") -struct RowBufferTests { - // MARK: - Initialization - - @Test("Init with default values creates empty buffer") - func initDefaults() { - let buffer = RowBuffer() - #expect(buffer.rows.isEmpty) - #expect(buffer.columns.isEmpty) - #expect(buffer.columnTypes.isEmpty) - #expect(buffer.isEvicted == false) - } - - @Test("Init with data preserves all fields") - func initWithData() { - let rows = TestFixtures.makeRows(count: 5) - let buffer = RowBuffer( - rows: rows, - columns: ["id", "name", "email"], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "VARCHAR"), .text(rawType: "VARCHAR")] - ) - #expect(buffer.rows.count == 5) - #expect(buffer.columns == ["id", "name", "email"]) - #expect(buffer.columnTypes.count == 3) - #expect(buffer.isEvicted == false) - } - - // MARK: - Eviction - - @Test("evict() clears rows and sets isEvicted") - func evictClearsRows() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 10), columns: ["a"]) - buffer.evict() - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - } - - @Test("evict() preserves column metadata") - func evictPreservesMetadata() { - let fk = TestFixtures.makeForeignKeyInfo() - let buffer = RowBuffer( - rows: TestFixtures.makeRows(count: 3), - columns: ["id", "user_id"], - columnTypes: [.integer(rawType: "INT"), .integer(rawType: "INT")], - columnDefaults: ["id": nil], - columnForeignKeys: ["user_id": fk], - columnEnumValues: ["status": ["a", "b"]], - columnNullable: ["id": false] - ) - buffer.evict() - #expect(buffer.columns == ["id", "user_id"]) - #expect(buffer.columnTypes.count == 2) - #expect(buffer.columnForeignKeys["user_id"]?.name == "fk_user") - #expect(buffer.columnEnumValues["status"] == ["a", "b"]) - #expect(buffer.columnNullable["id"] == false) - } - - @Test("Double evict is no-op") - func doubleEvictNoOp() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - buffer.evict() - buffer.evict() - #expect(buffer.isEvicted == true) - #expect(buffer.rows.isEmpty) - } - - // MARK: - Restore - - @Test("restore() repopulates rows and clears isEvicted") - func restoreRepopulates() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - buffer.evict() - #expect(buffer.isEvicted == true) - - let newRows = TestFixtures.makeRows(count: 5) - buffer.restore(rows: newRows) - #expect(buffer.rows.count == 5) - #expect(buffer.isEvicted == false) - } - - @Test("restore() with empty rows clears eviction flag") - func restoreEmptyRows() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - buffer.evict() - buffer.restore(rows: []) - #expect(buffer.isEvicted == false) - #expect(buffer.rows.isEmpty) - } - - // MARK: - Copy - - @Test("copy() creates independent buffer") - func copyCreatesIndependent() { - let original = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a", "b"]) - let copied = original.copy() - copied.rows.removeAll() - #expect(original.rows.count == 3) - #expect(copied.rows.isEmpty) - } - - @Test("copy() preserves eviction state as false") - func copyPreservesNonEvictedState() { - let original = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - let copied = original.copy() - #expect(copied.isEvicted == false) - } -} diff --git a/TableProTests/Models/RowProviderTests.swift b/TableProTests/Models/RowProviderTests.swift deleted file mode 100644 index be48cb62e..000000000 --- a/TableProTests/Models/RowProviderTests.swift +++ /dev/null @@ -1,499 +0,0 @@ -// -// RowProviderTests.swift -// TableProTests -// -// Tests for TableRowData and InMemoryRowProvider -// - -import Foundation -import Testing -@testable import TablePro - -// MARK: - TableRowData Tests - -@Suite("TableRowData") -struct TableRowDataTests { - @Test("Stores index and values") - func storesIndexAndValues() { - let row = TableRowData(index: 5, values: ["a", "b", "c"]) - #expect(row.index == 5) - #expect(row.values == ["a", "b", "c"]) - } - - @Test("value(at:) returns value at valid index") - func valueAtValid() { - let row = TableRowData(index: 0, values: ["hello", "world"]) - #expect(row.value(at: 0) == "hello") - #expect(row.value(at: 1) == "world") - } - - @Test("value(at:) returns value at last index") - func valueAtLast() { - let row = TableRowData(index: 0, values: ["a", "b", "c"]) - #expect(row.value(at: 2) == "c") - } - - @Test("value(at:) returns nil for out-of-bounds index") - func valueAtOutOfBounds() { - let row = TableRowData(index: 0, values: ["a"]) - #expect(row.value(at: 1) == nil) - #expect(row.value(at: 100) == nil) - } - - @Test("value(at:) returns nil for nil entry") - func valueAtNilEntry() { - let row = TableRowData(index: 0, values: [nil, "b"]) - #expect(row.value(at: 0) == nil) - } - - @Test("setValue at valid index updates value") - func setValueValid() { - let row = TableRowData(index: 0, values: ["old", "keep"]) - row.setValue("new", at: 0) - #expect(row.value(at: 0) == "new") - #expect(row.value(at: 1) == "keep") - } - - @Test("setValue to nil clears value") - func setValueNil() { - let row = TableRowData(index: 0, values: ["hello"]) - row.setValue(nil, at: 0) - #expect(row.value(at: 0) == nil) - } - - @Test("setValue out-of-bounds is no-op") - func setValueOutOfBounds() { - let row = TableRowData(index: 0, values: ["a"]) - row.setValue("b", at: 5) - #expect(row.values == ["a"]) - } - - @Test("Empty values array") - func emptyValues() { - let row = TableRowData(index: 0, values: []) - #expect(row.values.isEmpty) - #expect(row.value(at: 0) == nil) - } - - @Test("Index is immutable after setValue") - func indexImmutable() { - let row = TableRowData(index: 42, values: ["x"]) - row.setValue("y", at: 0) - #expect(row.index == 42) - } - - @Test("Values array is mutable") - func valuesMutable() { - let row = TableRowData(index: 0, values: ["a", "b"]) - row.values[0] = "z" - #expect(row.values[0] == "z") - } - - @Test("Reference semantics - two refs see same mutation") - func referenceSemantics() { - let row = TableRowData(index: 0, values: ["a"]) - let ref = row - ref.setValue("b", at: 0) - #expect(row.value(at: 0) == "b") - } -} - -// MARK: - InMemoryRowProvider Tests - -@Suite("InMemoryRowProvider") -struct InMemoryRowProviderTests { - // MARK: - Init - - @Test("Init stores rows and columns") - func initStoresRowsAndColumns() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.totalRowCount == 3) - #expect(provider.columns == ["id", "name", "email"]) - } - - @Test("Init with empty rows") - func initEmptyRows() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - #expect(provider.totalRowCount == 0) - #expect(provider.columns == ["a"]) - } - - @Test("Init with column defaults") - func initColumnDefaults() { - let provider = InMemoryRowProvider( - rows: [], columns: ["id", "status"], - columnDefaults: ["status": "active"] - ) - #expect(provider.columnDefaults["status"] as? String == "active") - } - - @Test("Init with explicit column types") - func initExplicitTypes() { - let types: [ColumnType] = [.integer(rawType: "INT"), .text(rawType: "VARCHAR")] - let provider = InMemoryRowProvider(rows: [], columns: ["id", "name"], columnTypes: types) - #expect(provider.columnTypes == types) - } - - @Test("Init with nil types defaults to text") - func initNilTypesDefault() { - let provider = InMemoryRowProvider(rows: [], columns: ["a", "b"]) - #expect(provider.columnTypes.count == 2) - #expect(provider.columnTypes[0] == .text(rawType: nil)) - #expect(provider.columnTypes[1] == .text(rawType: nil)) - } - - // MARK: - Metadata - - @Test("Foreign key access") - func foreignKeyAccess() { - let fk = TestFixtures.makeForeignKeyInfo() - let provider = InMemoryRowProvider(rows: [], columns: ["user_id"], columnForeignKeys: ["user_id": fk]) - #expect(provider.columnForeignKeys["user_id"]?.name == "fk_user") - } - - @Test("Enum values access") - func enumValuesAccess() { - let provider = InMemoryRowProvider( - rows: [], columns: ["status"], - columnEnumValues: ["status": ["active", "inactive"]] - ) - #expect(provider.columnEnumValues["status"] == ["active", "inactive"]) - } - - @Test("Nullable info access") - func nullableInfoAccess() { - let provider = InMemoryRowProvider(rows: [], columns: ["name"], columnNullable: ["name": true]) - #expect(provider.columnNullable["name"] == true) - } - - @Test("Empty metadata defaults") - func emptyMetadataDefaults() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - #expect(provider.columnForeignKeys.isEmpty) - #expect(provider.columnEnumValues.isEmpty) - #expect(provider.columnNullable.isEmpty) - #expect(provider.columnDefaults.isEmpty) - } - - @Test("totalRowCount matches source rows") - func totalRowCountMatches() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 7) - #expect(provider.totalRowCount == 7) - } - - // MARK: - row(at:) - - @Test("row(at:) returns data for valid index") - func rowAtValid() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let row = provider.row(at: 0) - #expect(row != nil) - #expect(row?.index == 0) - #expect(row?.value(at: 0) == "id_0") - } - - @Test("row(at:) returns data for last index") - func rowAtLast() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let row = provider.row(at: 4) - #expect(row != nil) - #expect(row?.index == 4) - } - - @Test("row(at:) returns nil for negative index") - func rowAtNegative() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.row(at: -1) == nil) - } - - @Test("row(at:) returns nil for out-of-bounds index") - func rowAtOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.row(at: 3) == nil) - #expect(provider.row(at: 100) == nil) - } - - // MARK: - fetchRows - - @Test("fetchRows returns full range") - func fetchRowsFullRange() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let rows = provider.fetchRows(offset: 0, limit: 5) - #expect(rows.count == 5) - #expect(rows[0].index == 0) - #expect(rows[4].index == 4) - } - - @Test("fetchRows returns partial range") - func fetchRowsPartialRange() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10) - let rows = provider.fetchRows(offset: 2, limit: 3) - #expect(rows.count == 3) - #expect(rows[0].index == 2) - #expect(rows[2].index == 4) - } - - @Test("fetchRows with zero limit returns empty") - func fetchRowsZeroLimit() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let rows = provider.fetchRows(offset: 0, limit: 0) - #expect(rows.isEmpty) - } - - @Test("fetchRows offset beyond count returns empty") - func fetchRowsOffsetBeyond() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let rows = provider.fetchRows(offset: 10, limit: 5) - #expect(rows.isEmpty) - } - - @Test("fetchRows limit exceeds available returns available") - func fetchRowsLimitExceeds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let rows = provider.fetchRows(offset: 0, limit: 100) - #expect(rows.count == 3) - } - - @Test("fetchRows from middle of data") - func fetchRowsFromMiddle() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10) - let rows = provider.fetchRows(offset: 5, limit: 3) - #expect(rows.count == 3) - #expect(rows[0].index == 5) - } - - @Test("fetchRows preserves data order") - func fetchRowsPreservesOrder() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let rows = provider.fetchRows(offset: 0, limit: 5) - for (i, row) in rows.enumerated() { - #expect(row.index == i) - #expect(row.value(at: 0) == "id_\(i)") - } - } - - @Test("fetchRows on empty provider returns empty") - func fetchRowsEmpty() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - let rows = provider.fetchRows(offset: 0, limit: 10) - #expect(rows.isEmpty) - } - - // MARK: - updateValue - - @Test("updateValue changes value") - func updateValueChanges() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue("updated", at: 1, columnIndex: 0) - #expect(provider.value(atRow: 1, column: 0) == "updated") - } - - @Test("updateValue sets value to nil") - func updateValueNil() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue(nil, at: 0, columnIndex: 1) - #expect(provider.value(atRow: 0, column: 1) == nil) - } - - @Test("updateValue out-of-bounds row is no-op") - func updateValueOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue("x", at: 10, columnIndex: 0) - #expect(provider.totalRowCount == 3) - } - - @Test("updateValue reflects in direct access") - func updateValueReflectsInDirectAccess() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: 0, column: 0) == "id_0") - provider.updateValue("changed", at: 0, columnIndex: 0) - #expect(provider.value(atRow: 0, column: 0) == "changed") - } - - // MARK: - appendRow - - @Test("appendRow increases count") - func appendRowCount() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 2) - let _ = provider.appendRow(values: ["new1", "new2", "new3"]) - #expect(provider.totalRowCount == 3) - } - - @Test("appendRow returns correct index") - func appendRowIndex() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let index = provider.appendRow(values: ["a", "b", "c"]) - #expect(index == 5) - } - - @Test("Appended row is accessible via value(atRow:column:)") - func appendRowAccessible() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 1) - let index = provider.appendRow(values: ["x", "y", "z"]) - #expect(provider.value(atRow: index, column: 0) == "x") - #expect(provider.value(atRow: index, column: 2) == "z") - } - - @Test("Multiple appends work correctly") - func multipleAppends() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - let i1 = provider.appendRow(values: ["first"]) - let i2 = provider.appendRow(values: ["second"]) - let i3 = provider.appendRow(values: ["third"]) - #expect(i1 == 0) - #expect(i2 == 1) - #expect(i3 == 2) - #expect(provider.totalRowCount == 3) - } - - @Test("Append to empty provider") - func appendToEmpty() { - let provider = InMemoryRowProvider(rows: [], columns: ["col"]) - let index = provider.appendRow(values: ["val"]) - #expect(index == 0) - #expect(provider.totalRowCount == 1) - #expect(provider.value(atRow: 0, column: 0) == "val") - } - - // MARK: - removeRow - - @Test("removeRow decreases count") - func removeRowCount() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRow(at: 1) - #expect(provider.totalRowCount == 2) - } - - @Test("removeRow out-of-bounds is no-op") - func removeRowOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRow(at: 10) - #expect(provider.totalRowCount == 3) - } - - @Test("removeRow negative index is no-op") - func removeRowNegative() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRow(at: -1) - #expect(provider.totalRowCount == 3) - } - - // MARK: - removeRows - - @Test("removeRows removes multiple rows") - func removeRowsMultiple() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - provider.removeRows(at: [1, 3]) - #expect(provider.totalRowCount == 3) - } - - @Test("removeRows with empty set is no-op") - func removeRowsEmpty() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRows(at: []) - #expect(provider.totalRowCount == 3) - } - - @Test("removeRows skips invalid indices") - func removeRowsSkipsInvalid() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRows(at: [0, 10, 20]) - #expect(provider.totalRowCount == 2) - } - - @Test("removeRows can remove all") - func removeRowsAll() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRows(at: [0, 1, 2]) - #expect(provider.totalRowCount == 0) - } - - // MARK: - invalidateCache - - @Test("invalidateCache preserves data") - func invalidateCachePreservesData() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.invalidateCache() - #expect(provider.value(atRow: 0, column: 0) == "id_0") - #expect(provider.totalRowCount == 3) - } - - // MARK: - updateRows - - @Test("updateRows replaces all data") - func updateRowsReplaces() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let newRows: [[String?]] = [["new_a", "new_b", "new_c"]] - provider.updateRows(newRows) - #expect(provider.totalRowCount == 1) - #expect(provider.value(atRow: 0, column: 0) == "new_a") - } - - @Test("updateRows with empty array sets count to 0") - func updateRowsEmpty() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - provider.updateRows([]) - #expect(provider.totalRowCount == 0) - } - - // MARK: - Direct Access Methods - - @Test("value(atRow:column:) returns correct value") - func valueAtRowColumn() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: 0, column: 0) == "id_0") - #expect(provider.value(atRow: 1, column: 1) == "name_1") - #expect(provider.value(atRow: 2, column: 2) == "email_2") - } - - @Test("value(atRow:column:) returns nil for out-of-bounds row") - func valueAtRowOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: -1, column: 0) == nil) - #expect(provider.value(atRow: 3, column: 0) == nil) - } - - @Test("value(atRow:column:) returns nil for out-of-bounds column") - func valueAtColumnOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: 0, column: -1) == nil) - #expect(provider.value(atRow: 0, column: 100) == nil) - } - - @Test("rowValues(at:) returns correct array") - func rowValuesAt() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let values = provider.rowValues(at: 1) - #expect(values == ["id_1", "name_1", "email_1"]) - } - - @Test("rowValues(at:) returns nil for out-of-bounds") - func rowValuesAtOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.rowValues(at: -1) == nil) - #expect(provider.rowValues(at: 3) == nil) - } - - @Test("value(atRow:column:) reflects updateValue") - func valueReflectsUpdate() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue("changed", at: 1, columnIndex: 0) - #expect(provider.value(atRow: 1, column: 0) == "changed") - } - - @Test("rowValues(at:) reflects appendRow") - func rowValuesReflectsAppend() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 2) - let index = provider.appendRow(values: ["a", "b", "c"]) - let values = provider.rowValues(at: index) - #expect(values == ["a", "b", "c"]) - } - - @Test("Large row count direct access works") - func largeRowCountDirectAccess() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10000) - #expect(provider.value(atRow: 0, column: 0) == "id_0") - #expect(provider.value(atRow: 9999, column: 0) == "id_9999") - #expect(provider.totalRowCount == 10000) - } -} diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift index a23ed8a05..9a8d7d3bb 100644 --- a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift +++ b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift @@ -9,11 +9,13 @@ import Testing @testable import TablePro @MainActor -private final class FakeRowDeltaApplier: RowDeltaApplying { +private final class FakeTableViewCoordinator: TableViewCoordinating { var insertedCalls: [IndexSet] = [] var removedCalls: [IndexSet] = [] var fullReplaceCount: Int = 0 var invalidateCount: Int = 0 + var deltaCalls: [Delta] = [] + var commitEditCount: Int = 0 func applyInsertedRows(_ indices: IndexSet) { insertedCalls.append(indices) @@ -30,6 +32,20 @@ private final class FakeRowDeltaApplier: RowDeltaApplying { func invalidateCachesForUndoRedo() { invalidateCount += 1 } + + func applyDelta(_ delta: Delta) { + deltaCalls.append(delta) + } + + func commitActiveCellEdit() { + commitEditCount += 1 + } + + var beginEditingCalls: [(row: Int, column: Int)] = [] + + func beginEditing(displayRow: Int, column: Int) { + beginEditingCalls.append((row: displayRow, column: column)) + } } @Suite("DataTabGridDelegate row-delta forwarding") @@ -39,7 +55,7 @@ struct DataTabGridDelegateTests { @Test("dataGridDidInsertRows(at:) forwards the IndexSet to applyInsertedRows") func insertForwardsIndices() { let delegate = DataTabGridDelegate() - let applier = FakeRowDeltaApplier() + let applier = FakeTableViewCoordinator() delegate.tableViewCoordinator = applier let indices = IndexSet([1, 3, 5]) @@ -54,7 +70,7 @@ struct DataTabGridDelegateTests { @Test("dataGridDidRemoveRows(at:) forwards the IndexSet to applyRemovedRows") func removeForwardsIndices() { let delegate = DataTabGridDelegate() - let applier = FakeRowDeltaApplier() + let applier = FakeTableViewCoordinator() delegate.tableViewCoordinator = applier let indices = IndexSet(integersIn: 4..<7) @@ -69,7 +85,7 @@ struct DataTabGridDelegateTests { @Test("dataGridDidReplaceAllRows() forwards to applyFullReplace") func fullReplaceForwards() { let delegate = DataTabGridDelegate() - let applier = FakeRowDeltaApplier() + let applier = FakeTableViewCoordinator() delegate.tableViewCoordinator = applier delegate.dataGridDidReplaceAllRows() diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index 41e048da8..a8bc5c7dd 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -24,7 +24,6 @@ struct CommandActionsDispatchTests { var pendingTruncates: Set = [] var pendingDeletes: Set = [] var tableOperationOptions: [String: TableOperationOptions] = [:] - var editingCell: CellPosition? = nil let rightPanelState = RightPanelState() let actions = MainContentCommandActions( @@ -39,8 +38,7 @@ struct CommandActionsDispatchTests { get: { tableOperationOptions }, set: { tableOperationOptions = $0 } ), - rightPanelState: rightPanelState, - editingCell: Binding(get: { editingCell }, set: { editingCell = $0 }) + rightPanelState: rightPanelState ) return (actions, coordinator) diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 4da3408fb..bf6159cd8 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -38,26 +38,28 @@ struct EvictionTests { guard let index = tabManager.selectedTabIndex else { return } let rows = TestFixtures.makeRows(count: 10) let tabId = tabManager.tabs[index].id - let buffer = coordinator.rowDataStore.buffer(for: tabId) - buffer.rows = rows - buffer.columns = ["id", "name", "email"] + let columns = ["id", "name", "email"] + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) + let tableRows = TableRows.from(queryRows: rows, columns: columns, columnTypes: columnTypes) + coordinator.setActiveTableRows(tableRows, for: tabId) tabManager.tabs[index].execution.lastExecutedAt = Date() } - @Test("evictInactiveRowData evicts loaded tabs without pending changes") + @Test("evictInactiveRowData evicts background tabs without pending changes") func evictsLoadedTabs() { let (coordinator, tabManager) = makeCoordinator() addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let tabId = tabManager.tabs[0].id - let buffer = coordinator.rowDataStore.buffer(for: tabId) + let backgroundTabId = tabManager.tabs[0].id + // Add a second tab so the first becomes background (eviction skips the selected tab) + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - #expect(buffer.rows.count == 10) - #expect(buffer.isEvicted == false) + #expect(coordinator.tableRowsStore.tableRows(for: backgroundTabId).rows.count == 10) + #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == false) coordinator.evictInactiveRowData() - #expect(buffer.isEvicted == true) - #expect(buffer.rows.isEmpty) + #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == true) + #expect(coordinator.tableRowsStore.tableRows(for: backgroundTabId).rows.isEmpty) } @Test("evictInactiveRowData skips tabs with pending changes") @@ -69,45 +71,23 @@ struct EvictionTests { coordinator.evictInactiveRowData() - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - #expect(buffer.isEvicted == false) - #expect(buffer.rows.count == 10) - } - - @Test("evictInactiveRowData skips already evicted tabs") - func skipsAlreadyEvicted() { - let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - buffer.evict() - #expect(buffer.isEvicted == true) - - coordinator.evictInactiveRowData() - #expect(buffer.isEvicted == true) - } - - @Test("evictInactiveRowData skips tabs with empty results") - func skipsEmptyResults() { - let (coordinator, tabManager) = makeCoordinator() - tabManager.addTableTab(tableName: "empty_table") - - coordinator.evictInactiveRowData() - - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - #expect(buffer.isEvicted == false) + let tabId = tabManager.tabs[0].id + #expect(coordinator.tableRowsStore.isEvicted(tabId) == false) + #expect(coordinator.tableRowsStore.tableRows(for: tabId).rows.count == 10) } @Test("evictInactiveRowData preserves column metadata after eviction") func preservesMetadataAfterEviction() { let (coordinator, tabManager) = makeCoordinator() addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") + let backgroundTabId = tabManager.tabs[0].id + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") coordinator.evictInactiveRowData() - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - #expect(buffer.columns == ["id", "name", "email"]) - #expect(buffer.isEvicted == true) + let rows = coordinator.tableRowsStore.tableRows(for: backgroundTabId) + #expect(rows.columns == ["id", "name", "email"]) + #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == true) } @Test("evictInactiveRowData with no tabs is no-op") diff --git a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift index 42b1fd9c3..7e47c5255 100644 --- a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift @@ -17,7 +17,7 @@ struct MainStatusBarLayoutTests { let filterManager = FilterStateManager() let colVisManager = ColumnVisibilityManager() let view = MainStatusBarView( - snapshot: StatusBarSnapshot(tab: nil, buffer: nil), + snapshot: StatusBarSnapshot(tab: nil, tableRows: nil), filterStateManager: filterManager, columnVisibilityManager: colVisManager, allColumns: [], diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index bfba6774c..adce6eaca 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -261,20 +261,16 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var editingCell: CellPosition? - - coordinator.addNewRow(editingCell: &editingCell) + coordinator.addNewRow() #expect(coordinator.selectionState.indices.isEmpty) - #expect(editingCell == nil) coordinator.selectionState.indices = [0] coordinator.deleteSelectedRows(indices: Set([0])) #expect(coordinator.selectionState.indices == [0]) coordinator.selectionState.indices = [] - coordinator.duplicateSelectedRow(index: 0, editingCell: &editingCell) + coordinator.duplicateSelectedRow(index: 0) #expect(coordinator.selectionState.indices.isEmpty) - #expect(editingCell == nil) } @Test("row operations allowed by alert level") @@ -286,9 +282,7 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var editingCell: CellPosition? - - coordinator.addNewRow(editingCell: &editingCell) + coordinator.addNewRow() #expect(tabManager.tabs.first?.execution.errorMessage == nil) } } diff --git a/TableProTests/Views/Main/TabEvictionTests.swift b/TableProTests/Views/Main/TabEvictionTests.swift deleted file mode 100644 index 9ddb7fbb3..000000000 --- a/TableProTests/Views/Main/TabEvictionTests.swift +++ /dev/null @@ -1,248 +0,0 @@ -// -// TabEvictionTests.swift -// TableProTests -// -// Tests for tab data eviction logic: RowBuffer eviction/restore behavior -// and the candidate filtering + budget logic used by evictInactiveTabs. -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("Tab Eviction") -@MainActor -struct TabEvictionTests { - - // MARK: - Helpers - - private func makeTestRows(count: Int) -> [[String?]] { - (0.. TestTab { - var tab = QueryTab(id: id, title: "Test", query: "SELECT 1", tabType: tabType) - tab.execution.lastExecutedAt = lastExecutedAt - - let buffer: RowBuffer - if rowCount > 0 { - buffer = RowBuffer( - rows: makeTestRows(count: rowCount), - columns: ["col1"], - columnTypes: [.text(rawType: "VARCHAR")] - ) - } else { - buffer = RowBuffer() - } - store.setBuffer(buffer, for: tab.id) - - if isEvicted { - buffer.evict() - } - - if hasUnsavedChanges { - tab.pendingChanges.deletedRowIndices = [0] - } - - return TestTab(tab: tab, buffer: buffer) - } - - // MARK: - RowBuffer Eviction - - @Test("RowBuffer evict clears rows and sets isEvicted flag") - func rowBufferEvictClearsRows() { - let buffer = RowBuffer( - rows: makeTestRows(count: 5), - columns: ["id", "name"], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "VARCHAR")] - ) - - #expect(buffer.rows.count == 5) - #expect(buffer.isEvicted == false) - - buffer.evict() - - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - #expect(buffer.columns == ["id", "name"]) - #expect(buffer.columnTypes.count == 2) - } - - @Test("RowBuffer evict is idempotent") - func rowBufferEvictIdempotent() { - let buffer = RowBuffer( - rows: makeTestRows(count: 3), - columns: ["col1"], - columnTypes: [.text(rawType: nil)] - ) - - buffer.evict() - buffer.evict() - - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - } - - @Test("RowBuffer restore repopulates rows and clears evicted flag") - func rowBufferRestoreAfterEviction() { - let buffer = RowBuffer( - rows: makeTestRows(count: 5), - columns: ["col1"], - columnTypes: [.text(rawType: nil)] - ) - - buffer.evict() - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - - let newRows = makeTestRows(count: 3) - buffer.restore(rows: newRows) - - #expect(buffer.isEvicted == false) - #expect(buffer.rows.count == 3) - } - - // MARK: - Eviction Candidate Filtering - - @Test("Tabs with pending changes are excluded from eviction candidates") - func tabsWithPendingChangesExcluded() { - let store = RowDataStore() - let entry = makeTestTab( - store: store, - rowCount: 10, - lastExecutedAt: Date(), - hasUnsavedChanges: true - ) - - let isCandidate = !entry.buffer.isEvicted - && !entry.buffer.rows.isEmpty - && entry.tab.execution.lastExecutedAt != nil - && !entry.tab.pendingChanges.hasChanges - - #expect(isCandidate == false) - } - - @Test("Eviction candidate filter excludes active, evicted, empty, and unsaved tabs") - func evictionCandidateFiltering() { - let store = RowDataStore() - let activeId = UUID() - let entryA = makeTestTab(store: store, id: activeId, rowCount: 10, lastExecutedAt: Date()) - let entryB = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date(), isEvicted: true) - let entryC = makeTestTab(store: store, rowCount: 0, lastExecutedAt: Date()) - let entryD = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date(), hasUnsavedChanges: true) - let entryE = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date()) - - let activeTabIds: Set = [activeId] - let allEntries = [entryA, entryB, entryC, entryD, entryE] - - let candidates = allEntries.filter { - !activeTabIds.contains($0.tab.id) - && !$0.buffer.isEvicted - && !$0.buffer.rows.isEmpty - && $0.tab.execution.lastExecutedAt != nil - && !$0.tab.pendingChanges.hasChanges - } - - #expect(candidates.count == 1) - #expect(candidates.first?.tab.id == entryE.tab.id) - } - - // MARK: - Budget-Based Eviction - - @Test("Eviction keeps the 2 most recently executed inactive tabs") - func evictionKeepsTwoMostRecent() { - let store = RowDataStore() - let now = Date() - let entries = (0..<5).map { i in - makeTestTab( - store: store, - rowCount: 10, - lastExecutedAt: now.addingTimeInterval(Double(i) * 60) - ) - } - - let activeTabIds: Set = [] - let candidates = entries.filter { - !activeTabIds.contains($0.tab.id) - && !$0.buffer.isEvicted - && !$0.buffer.rows.isEmpty - && $0.tab.execution.lastExecutedAt != nil - && !$0.tab.pendingChanges.hasChanges - } - - let sorted = candidates.sorted { - ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) - } - - let maxInactiveLoaded = 2 - let toEvict = Array(sorted.dropLast(maxInactiveLoaded)) - - #expect(toEvict.count == 3) - - for entry in toEvict { - entry.buffer.evict() - } - - let evictedIds = Set(toEvict.map(\.tab.id)) - - // The 2 newest (index 3 and 4) should NOT be evicted - #expect(!evictedIds.contains(entries[3].tab.id)) - #expect(!evictedIds.contains(entries[4].tab.id)) - - // The 3 oldest (index 0, 1, 2) should be evicted - #expect(entries[0].buffer.isEvicted == true) - #expect(entries[1].buffer.isEvicted == true) - #expect(entries[2].buffer.isEvicted == true) - #expect(entries[3].buffer.isEvicted == false) - #expect(entries[4].buffer.isEvicted == false) - } - - @Test("No tabs evicted when candidates are within budget") - func noEvictionWithinBudget() { - let store = RowDataStore() - let now = Date() - let entries = (0..<2).map { i in - makeTestTab( - store: store, - rowCount: 10, - lastExecutedAt: now.addingTimeInterval(Double(i) * 60) - ) - } - - let activeTabIds: Set = [] - let candidates = entries.filter { - !activeTabIds.contains($0.tab.id) - && !$0.buffer.isEvicted - && !$0.buffer.rows.isEmpty - && $0.tab.execution.lastExecutedAt != nil - && !$0.tab.pendingChanges.hasChanges - } - - let sorted = candidates.sorted { - ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) - } - - let maxInactiveLoaded = 2 - let shouldEvict = sorted.count > maxInactiveLoaded - - #expect(shouldEvict == false) - - for entry in entries { - #expect(entry.buffer.isEvicted == false) - #expect(entry.buffer.rows.count == 10) - } - } -} diff --git a/TableProTests/Views/Main/TableRowsMutationTests.swift b/TableProTests/Views/Main/TableRowsMutationTests.swift new file mode 100644 index 000000000..3e1476bec --- /dev/null +++ b/TableProTests/Views/Main/TableRowsMutationTests.swift @@ -0,0 +1,127 @@ +// +// TableRowsMutationTests.swift +// TableProTests +// +// Regression tests for the setActiveTableRows / switchActiveResultSet +// dispatch path. Without applyFullReplace, the data grid coordinator's +// RowID-keyed display cache survives table switches and returns stale +// cell values for matching RowIDs across tables. +// + +import AppKit +import Foundation +import Testing +@testable import TablePro + +@MainActor +private final class FakeTableViewCoordinator: TableViewCoordinating { + var fullReplaceCount = 0 + var insertedCount = 0 + var removedCount = 0 + var deltaCount = 0 + var invalidateCount = 0 + var commitEditCount = 0 + var beginEditingCalls: [(row: Int, column: Int)] = [] + + func applyInsertedRows(_ indices: IndexSet) { insertedCount += 1 } + func applyRemovedRows(_ indices: IndexSet) { removedCount += 1 } + func applyFullReplace() { fullReplaceCount += 1 } + func applyDelta(_ delta: Delta) { deltaCount += 1 } + func invalidateCachesForUndoRedo() { invalidateCount += 1 } + func commitActiveCellEdit() { commitEditCount += 1 } + func beginEditing(displayRow: Int, column: Int) { + beginEditingCalls.append((row: displayRow, column: column)) + } +} + +@Suite("setActiveTableRows dispatch") +@MainActor +struct TableRowsMutationTests { + private struct Fixture { + let coordinator: MainContentCoordinator + let tabManager: QueryTabManager + let delegate: DataTabGridDelegate + let fake: FakeTableViewCoordinator + } + + private func makeFixture() -> Fixture { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + filterStateManager: FilterStateManager(), + columnVisibilityManager: ColumnVisibilityManager(), + toolbarState: ConnectionToolbarState() + ) + let delegate = DataTabGridDelegate() + let fake = FakeTableViewCoordinator() + delegate.tableViewCoordinator = fake + coordinator.dataTabDelegate = delegate + return Fixture(coordinator: coordinator, tabManager: tabManager, delegate: delegate, fake: fake) + } + + private func makeTableRows(rowCount: Int) -> TableRows { + let columns = ["id", "name"] + let rows = (0..= 60, "Width should be at least 60 (min)") #expect(width <= 800, "Width should be at most 800 (max)") @@ -36,12 +29,16 @@ struct ColumnWidthOptimizationTests { @Test("Header-only column returns reasonable width") func headerOnlyColumnWidth() { let factory = DataGridCellFactory() - let provider = InMemoryRowProvider(rows: [], columns: ["username"]) + let tableRows = TableRows.from( + queryRows: [], + columns: ["username"], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "username", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width >= 60) #expect(width <= 800) @@ -50,12 +47,16 @@ struct ColumnWidthOptimizationTests { @Test("Empty header with no rows returns minimum width") func emptyHeaderNoRowsReturnsMinWidth() { let factory = DataGridCellFactory() - let provider = InMemoryRowProvider(rows: [], columns: [""]) + let tableRows = TableRows.from( + queryRows: [], + columns: [""], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width >= 60, "Should return at least minimum width") } @@ -65,12 +66,16 @@ struct ColumnWidthOptimizationTests { let factory = DataGridCellFactory() let longValue = String(repeating: "X", count: 5_000) let rows: [[String?]] = [[longValue]] - let provider = InMemoryRowProvider(rows: rows, columns: ["data"]) + let tableRows = TableRows.from( + queryRows: rows, + columns: ["data"], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "data", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width <= 800, "Width should be capped at max (800)") } @@ -80,16 +85,17 @@ struct ColumnWidthOptimizationTests { let factory = DataGridCellFactory() let columnCount = 60 let columns = (0..= 60) #expect(width <= 800) @@ -116,20 +122,22 @@ struct ColumnWidthOptimizationTests { ["hello"], [nil], ] - let provider = InMemoryRowProvider(rows: rows, columns: ["name"]) + let tableRows = TableRows.from( + queryRows: rows, + columns: ["name"], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "name", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width >= 60) #expect(width <= 800) } } -// MARK: - Change Reapplication Version Tracking (P2-7) - @Suite("Change Reapplication Version Tracking") struct ChangeReapplyVersionTests { @Test("Version tracking skips redundant work") diff --git a/TableProTests/Views/Results/RowProviderCacheTests.swift b/TableProTests/Views/Results/RowProviderCacheTests.swift deleted file mode 100644 index 8f57eb913..000000000 --- a/TableProTests/Views/Results/RowProviderCacheTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// RowProviderCacheTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("RowProviderCache") -@MainActor -struct RowProviderCacheTests { - - private func makeProvider(rows: [[String?]] = [["a"]]) -> InMemoryRowProvider { - InMemoryRowProvider(rows: rows, columns: ["c"]) - } - - private func makeSortState(columnIndex: Int = 0, direction: SortDirection = .ascending) -> SortState { - var state = SortState() - state.columns = [SortColumn(columnIndex: columnIndex, direction: direction)] - return state - } - - @Test("provider(for:) returns nil when the tab id is unknown") - func providerUnknownReturnsNil() { - let cache = RowProviderCache() - let resolved = cache.provider( - for: UUID(), - schemaVersion: 1, - metadataVersion: 1, - sortState: SortState() - ) - #expect(resolved == nil) - } - - @Test("After store(...), the same key returns the stored provider") - func storeRoundTrips() { - let cache = RowProviderCache() - let tabId = UUID() - let provider = makeProvider() - - cache.store(provider, for: tabId, schemaVersion: 2, metadataVersion: 3, sortState: SortState()) - - let resolved = cache.provider(for: tabId, schemaVersion: 2, metadataVersion: 3, sortState: SortState()) - #expect(resolved != nil) - #expect(resolved.map(ObjectIdentifier.init) == ObjectIdentifier(provider)) - } - - @Test("Different schemaVersion invalidates the cache hit") - func schemaVersionMismatchReturnsNil() { - let cache = RowProviderCache() - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - let resolved = cache.provider(for: tabId, schemaVersion: 2, metadataVersion: 1, sortState: SortState()) - #expect(resolved == nil) - } - - @Test("Different metadataVersion invalidates the cache hit") - func metadataVersionMismatchReturnsNil() { - let cache = RowProviderCache() - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 99, sortState: SortState()) - #expect(resolved == nil) - } - - @Test("Different sortState invalidates the cache hit") - func sortStateMismatchReturnsNil() { - let cache = RowProviderCache() - let tabId = UUID() - let storedSort = makeSortState(columnIndex: 0, direction: .ascending) - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: storedSort) - - let differentSort = makeSortState(columnIndex: 1, direction: .descending) - let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: differentSort) - #expect(resolved == nil) - } - - @Test("remove(for:) removes the entry") - func removeRemoves() { - let cache = RowProviderCache() - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - cache.remove(for: tabId) - - let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - #expect(resolved == nil) - #expect(cache.isEmpty) - } - - @Test("retain(tabIds:) keeps only the listed tabs") - func retainKeepsListedOnly() { - let cache = RowProviderCache() - let keepId = UUID() - let dropId1 = UUID() - let dropId2 = UUID() - - cache.store(makeProvider(), for: keepId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - cache.store(makeProvider(), for: dropId1, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - cache.store(makeProvider(), for: dropId2, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - cache.retain(tabIds: [keepId]) - - #expect(cache.provider(for: keepId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) != nil) - #expect(cache.provider(for: dropId1, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) == nil) - #expect(cache.provider(for: dropId2, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) == nil) - } - - @Test("removeAll() clears the cache") - func removeAllClears() { - let cache = RowProviderCache() - cache.store(makeProvider(), for: UUID(), schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - cache.store(makeProvider(), for: UUID(), schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - cache.removeAll() - - #expect(cache.isEmpty) - } - - @Test("isEmpty reflects state across mutations") - func isEmptyReflectsState() { - let cache = RowProviderCache() - #expect(cache.isEmpty) - - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - #expect(!cache.isEmpty) - - cache.remove(for: tabId) - #expect(cache.isEmpty) - } -} diff --git a/TableProTests/Views/Results/RowProviderSyncTests.swift b/TableProTests/Views/Results/RowProviderSyncTests.swift deleted file mode 100644 index a7b8fe608..000000000 --- a/TableProTests/Views/Results/RowProviderSyncTests.swift +++ /dev/null @@ -1,340 +0,0 @@ -// -// RowProviderSyncTests.swift -// TableProTests -// -// Tests for the regression fix: re-applying pending cell edits from -// DataChangeManager to a fresh (stale/cached) InMemoryRowProvider. -// Simulates the scenario in DataGridView.updateNSView where SwiftUI -// provides a cached rowProvider that doesn't reflect in-flight edits. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("RowProvider Sync After Replacement") -@MainActor -struct RowProviderSyncTests { - // MARK: - Helpers - - private func makeScenario( - rowCount: Int = 3, - columns: [String] = ["id", "name", "email"] - ) -> (manager: DataChangeManager, provider: InMemoryRowProvider) { - let rows = TestFixtures.makeRows(count: rowCount, columns: columns) - let provider = InMemoryRowProvider(rows: rows, columns: columns) - let manager = DataChangeManager() - manager.configureForTable(tableName: "test", columns: columns, primaryKeyColumns: ["id"]) - return (manager, provider) - } - - /// Simulates the re-apply logic from DataGridView.updateNSView - private func reapplyChanges(from manager: DataChangeManager, to provider: InMemoryRowProvider) { - for change in manager.changes { - for cellChange in change.cellChanges { - provider.updateValue( - cellChange.newValue, - at: change.rowIndex, - columnIndex: cellChange.columnIndex - ) - } - } - } - - // MARK: - Tests - - @Test("Single cell edit syncs to new provider") - func singleCellEditSyncsToNewProvider() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - // Edit row 1, col 1: "name_1" → "new" - manager.recordCellChange( - rowIndex: 1, - columnIndex: 1, - columnName: "name", - oldValue: "name_1", - newValue: "new", - originalRow: originalRow - ) - providerA.updateValue("new", at: 1, columnIndex: 1) - - // Simulate SwiftUI providing a stale cached provider - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 1, column: 1) == "new") - } - - @Test("Multiple cell edits on same row sync correctly") - func multipleCellEditsSameRowSync() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "updated_name", - originalRow: originalRow - ) - manager.recordCellChange( - rowIndex: 0, - columnIndex: 2, - columnName: "email", - oldValue: "email_0", - newValue: "updated_email", - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "updated_name") - #expect(providerB.value(atRow: 0, column: 2) == "updated_email") - } - - @Test("Multiple cell edits on different rows sync correctly") - func multipleCellEditsDifferentRowsSync() { - let (manager, providerA) = makeScenario() - let originalRow0 = providerA.rowValues(at: 0)! - let originalRow2 = providerA.rowValues(at: 2)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "new_name_0", - originalRow: originalRow0 - ) - manager.recordCellChange( - rowIndex: 2, - columnIndex: 2, - columnName: "email", - oldValue: "email_2", - newValue: "new_email_2", - originalRow: originalRow2 - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "new_name_0") - #expect(providerB.value(atRow: 2, column: 2) == "new_email_2") - } - - @Test("Edit then undo leaves provider unchanged") - func editThenUndoLeavesProviderUnchanged() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - manager.recordCellChange( - rowIndex: 1, - columnIndex: 1, - columnName: "name", - oldValue: "name_1", - newValue: "edited", - originalRow: originalRow - ) - - _ = manager.undoLastChange() - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - } - - @Test("Edit, undo, redo syncs correctly") - func editUndoRedoSyncsCorrectly() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - manager.recordCellChange( - rowIndex: 1, - columnIndex: 1, - columnName: "name", - oldValue: "name_1", - newValue: "new", - originalRow: originalRow - ) - - _ = manager.undoLastChange() - _ = manager.redoLastChange() - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 1, column: 1) == "new") - } - - @Test("Inserted row cell edit syncs to new provider") - func insertedRowCellEditSyncs() { - let columns = ["id", "name", "email"] - let (manager, providerA) = makeScenario(columns: columns) - - // Insert a new row at index 3 - manager.recordRowInsertion(rowIndex: 3, values: ["", "", ""]) - _ = providerA.appendRow(values: ["", "", ""]) - - // Edit cell on the inserted row - manager.recordCellChange( - rowIndex: 3, - columnIndex: 1, - columnName: "name", - oldValue: "", - newValue: "inserted_val", - originalRow: nil - ) - - // Fresh providerB needs 4 rows to match - var rows = TestFixtures.makeRows(count: 3, columns: columns) - rows.append(["", "", ""]) - let providerB = InMemoryRowProvider(rows: rows, columns: columns) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 3, column: 1) == "inserted_val") - } - - @Test("Deleted row does not affect sync") - func deletedRowDoesNotAffectSync() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - manager.recordRowDeletion(rowIndex: 1, originalRow: originalRow) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - // Should not crash; delete changes have no cellChanges - reapplyChanges(from: manager, to: providerB) - - // ProviderB values remain unchanged - #expect(providerB.value(atRow: 0, column: 0) == "id_0") - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - #expect(providerB.value(atRow: 2, column: 2) == "email_2") - } - - @Test("Multiple edits to same cell — last value wins") - func multipleEditsToSameCellLastValueWins() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "b", - originalRow: originalRow - ) - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "b", - newValue: "c", - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "c") - } - - @Test("Reapply is idempotent") - func reapplyIsIdempotent() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "updated", - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "updated") - - // Apply again — should remain correct, no corruption - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "updated") - } - - @Test("Null value syncs correctly") - func nullValueSyncsCorrectly() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: nil, - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == nil) - } - - @Test("Reapply with no changes is a no-op") - func reapplyWithNoChangesIsNoOp() { - let (manager, _) = makeScenario() - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - - #expect(providerB.value(atRow: 0, column: 0) == "id_0") - #expect(providerB.value(atRow: 0, column: 1) == "name_0") - #expect(providerB.value(atRow: 0, column: 2) == "email_0") - #expect(providerB.value(atRow: 1, column: 0) == "id_1") - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - #expect(providerB.value(atRow: 2, column: 2) == "email_2") - } - - @Test("Batch delete does not crash") - func batchDeleteDoesNotCrash() { - let (manager, providerA) = makeScenario() - let originalRow0 = providerA.rowValues(at: 0)! - let originalRow1 = providerA.rowValues(at: 1)! - - manager.recordBatchRowDeletion(rows: [ - (rowIndex: 0, originalRow: originalRow0), - (rowIndex: 1, originalRow: originalRow1), - ]) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - // Should not crash; batch delete changes have no cellChanges - reapplyChanges(from: manager, to: providerB) - - // Values remain unchanged - #expect(providerB.value(atRow: 0, column: 0) == "id_0") - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - #expect(providerB.value(atRow: 2, column: 2) == "email_2") - } -} diff --git a/TableProTests/Views/Results/TableRowsControllerTests.swift b/TableProTests/Views/Results/TableRowsControllerTests.swift new file mode 100644 index 000000000..d638aacde --- /dev/null +++ b/TableProTests/Views/Results/TableRowsControllerTests.swift @@ -0,0 +1,155 @@ +import AppKit +import Foundation +import Testing +@testable import TablePro + +@Suite("TableRowsController") +@MainActor +struct TableRowsControllerTests { + + final class RecordingTableView: NSTableView { + struct Reload { + let rows: IndexSet + let columns: IndexSet + } + + var insertCalls: [(IndexSet, NSTableView.AnimationOptions)] = [] + var removeCalls: [(IndexSet, NSTableView.AnimationOptions)] = [] + var rangeReloadCalls: [Reload] = [] + var fullReloadCount = 0 + var stubbedRowCount = 0 + + override var numberOfRows: Int { stubbedRowCount } + + override func insertRows(at indexes: IndexSet, withAnimation animationOptions: NSTableView.AnimationOptions = []) { + insertCalls.append((indexes, animationOptions)) + } + + override func removeRows(at indexes: IndexSet, withAnimation animationOptions: NSTableView.AnimationOptions = []) { + removeCalls.append((indexes, animationOptions)) + } + + override func reloadData(forRowIndexes rowIndexes: IndexSet, columnIndexes: IndexSet) { + rangeReloadCalls.append(Reload(rows: rowIndexes, columns: columnIndexes)) + } + + override func reloadData() { + fullReloadCount += 1 + } + } + + private func makeTableView(rows: Int, columns: Int) -> RecordingTableView { + let view = RecordingTableView(frame: .zero) + for index in 0.. = [ + CellPosition(row: 0, column: 0), + CellPosition(row: 0, column: 2), + CellPosition(row: 3, column: 1) + ] + controller.apply(.cellsChanged(positions)) + #expect(table.rangeReloadCalls.count == 1) + #expect(table.rangeReloadCalls.first?.rows == IndexSet([0, 3])) + #expect(table.rangeReloadCalls.first?.columns == IndexSet([0, 1, 2])) + } + + @Test("apply(.cellsChanged) with empty set is a no-op") + func cellsChangedEmptyNoOp() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.cellsChanged([])) + #expect(table.rangeReloadCalls.isEmpty) + } + + @Test("apply(.rowsInserted) calls insertRows with the configured animation") + func rowsInsertedCallsInsert() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.rowsInserted(IndexSet([5, 6]))) + #expect(table.insertCalls.count == 1) + #expect(table.insertCalls.first?.0 == IndexSet([5, 6])) + #expect(table.insertCalls.first?.1 == .slideDown) + } + + @Test("apply(.rowsInserted) with empty set is a no-op") + func rowsInsertedEmptyNoOp() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.rowsInserted(IndexSet())) + #expect(table.insertCalls.isEmpty) + } + + @Test("apply(.rowsRemoved) calls removeRows") + func rowsRemovedCallsRemove() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.rowsRemoved(IndexSet([1, 2]))) + #expect(table.removeCalls.count == 1) + #expect(table.removeCalls.first?.0 == IndexSet([1, 2])) + #expect(table.removeCalls.first?.1 == .slideUp) + } + + @Test("apply(.fullReplace) calls reloadData") + func fullReplaceReloadsAll() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.fullReplace) + #expect(table.fullReloadCount == 1) + } + + @Test("apply(.columnsReplaced) calls reloadData") + func columnsReplacedReloadsAll() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.columnsReplaced) + #expect(table.fullReloadCount == 1) + } + + @Test("apply with detached tableView is a no-op") + func detachedNoOp() { + let controller = TableRowsController() + controller.apply(.fullReplace) + } + + @Test("animation options are configurable") + func animationsConfigurable() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.insertAnimation = .effectFade + controller.removeAnimation = .effectGap + + controller.apply(.rowsInserted(IndexSet(integer: 3))) + controller.apply(.rowsRemoved(IndexSet(integer: 1))) + + #expect(table.insertCalls.first?.1 == .effectFade) + #expect(table.removeCalls.first?.1 == .effectGap) + } +}