diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5411ff3..637662915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OpenSSL shared as dylib across app and plugins, saving ~15MB in bundle size - Data grid uses single cell reuse identifier with typed stored properties instead of 3 identifiers and viewWithTag - Boolean dropdown menu includes Set NULL option for nullable columns +- Tab persistence triggers on a structural counter, not on every tabs write. Cell edits, row mutations, and per-keystroke query text no longer invoke disk I/O. +- Inspector sidebar edit state runs inside the existing 50ms debounce instead of synchronously per row click. +- Row add, delete, duplicate, undo, redo, and paste drive NSTableView insertRows / removeRows directly through the data grid delegate. SwiftUI no longer re-evaluates the editor view tree on row mutations. +- QueryTab.resultVersion split: schemaVersion (column shape) on QueryTab, row mutations through delegate deltas, sort completion through a single delegate replace call. Pin toggle, sort completion, and applyMultiStatementResults no longer fan out a redundant reload signal. +- Row data lives in a per-coordinator RowDataStore keyed by tab.id rather than on QueryTab itself, so SwiftUI's @Observable tracking on tabManager.tabs no longer fires for row writes. +- DataGridConfiguration is Equatable; DataGridIdentity covers tabType, tableName, and primaryKeyColumns so updateNSView short-circuits when nothing structural changed. DataTabGridDelegate properties are wired in onAppear / onChange instead of in the body. ### Fixed diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 73bfebc7c..3af4eb7ee 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -120,21 +120,6 @@ internal final class TabPersistenceCoordinator { ) } - // MARK: - Last Query - - /// Save the editor's last query text for this connection. - internal func saveLastQuery(_ query: String) { - let connId = connectionId - Task { - await TabDiskActor.shared.saveLastQuery(query, for: connId) - } - } - - /// Load the editor's last query text for this connection. - internal func loadLastQuery() async -> String? { - await TabDiskActor.shared.loadLastQuery(for: connectionId) - } - // MARK: - Private private func convertToPersistedTab(_ tab: QueryTab) -> PersistedTab { diff --git a/TablePro/Core/Services/Query/RowDataStore.swift b/TablePro/Core/Services/Query/RowDataStore.swift new file mode 100644 index 000000000..135519b64 --- /dev/null +++ b/TablePro/Core/Services/Query/RowDataStore.swift @@ -0,0 +1,44 @@ +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 bb0554d0e..5283502cd 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -91,16 +91,18 @@ final class RowOperationsManager { // MARK: - Delete Rows - /// Delete selected rows - /// - Parameters: - /// - selectedIndices: Indices of rows to delete - /// - resultRows: Current rows (will be mutated) - /// - Returns: Next row index to select after deletion, or -1 if no rows left + struct DeleteRowsResult { + let nextRowToSelect: Int + let physicallyRemovedIndices: [Int] + } + func deleteSelectedRows( selectedIndices: Set, resultRows: inout [[String?]] - ) -> Int { - guard !selectedIndices.isEmpty else { return -1 } + ) -> DeleteRowsResult { + guard !selectedIndices.isEmpty else { + return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: []) + } var insertedRowsToDelete: [Int] = [] var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = [] @@ -118,40 +120,40 @@ final class RowOperationsManager { } } - // Process inserted rows deletion - if !insertedRowsToDelete.isEmpty { - let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) + let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) - // Remove from resultRows first (descending order) + if !sortedInsertedRows.isEmpty { for rowIndex in sortedInsertedRows { guard rowIndex < resultRows.count else { continue } resultRows.remove(at: rowIndex) } - - // Update changeManager for ALL deleted inserted rows at once changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) } - // Record batch deletion for existing rows (single undo action for all rows) if !existingRowsToDelete.isEmpty { changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) } - // Calculate next row selection, accounting for deleted inserted rows let totalRows = resultRows.count - let rowsDeleted = insertedRowsToDelete.count + let rowsDeleted = sortedInsertedRows.count let adjustedMaxRow = maxSelectedRow - rowsDeleted - let adjustedMinRow = minSelectedRow - insertedRowsToDelete.count(where: { $0 < minSelectedRow }) + let adjustedMinRow = minSelectedRow - sortedInsertedRows.count(where: { $0 < minSelectedRow }) + let nextRow: Int if adjustedMaxRow + 1 < totalRows { - return min(adjustedMaxRow + 1, totalRows - 1) + nextRow = min(adjustedMaxRow + 1, totalRows - 1) } else if adjustedMinRow > 0 { - return adjustedMinRow - 1 + nextRow = adjustedMinRow - 1 } else if totalRows > 0 { - return 0 + nextRow = 0 } else { - return -1 + nextRow = -1 } + + return DeleteRowsResult( + nextRowToSelect: nextRow, + physicallyRemovedIndices: sortedInsertedRows + ) } // MARK: - Undo/Redo diff --git a/TablePro/Core/Storage/TabDiskActor.swift b/TablePro/Core/Storage/TabDiskActor.swift index 8be6a0c43..8f3382948 100644 --- a/TablePro/Core/Storage/TabDiskActor.swift +++ b/TablePro/Core/Storage/TabDiskActor.swift @@ -20,9 +20,6 @@ internal struct TabDiskState: Codable { /// /// Data is stored as individual JSON files per connection in: /// `~/Library/Application Support/TablePro/TabState/` -/// -/// Last-query strings are stored in a sibling directory: -/// `~/Library/Application Support/TablePro/LastQuery/` internal actor TabDiskActor { internal static let shared = TabDiskActor() @@ -31,39 +28,26 @@ internal actor TabDiskActor { // MARK: - Legacy UserDefaults Keys (for migration) private static let legacyTabStateKeyPrefix = "com.TablePro.tabs." - private static let legacyLastQueryKeyPrefix = "com.TablePro.lastquery." private static let migrationCompleteKey = "com.TablePro.tabStateMigrationComplete" // MARK: - File Storage private let tabStateDirectory: URL - private let lastQueryDirectory: URL private let encoder: JSONEncoder private let decoder: JSONDecoder private init() { - tabStateDirectory = Self.resolvedTabStateDirectory() - - let baseDirectory = tabStateDirectory.deletingLastPathComponent() - lastQueryDirectory = baseDirectory.appendingPathComponent("LastQuery", isDirectory: true) - + let directory = Self.resolvedTabStateDirectory() + tabStateDirectory = directory encoder = JSONEncoder() decoder = JSONDecoder() - // Directory creation and migration run synchronously at init. - // Safe because init is the only caller and runs before any concurrent access. - let fm = FileManager.default - for directory in [tabStateDirectory, lastQueryDirectory] { - do { - try fm.createDirectory(at: directory, withIntermediateDirectories: true) - } catch { - Self.logger.error("Failed to create directory \(directory.path): \(error.localizedDescription)") - } + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + Self.logger.error("Failed to create directory \(directory.path): \(error.localizedDescription)") } - Self.performMigrationIfNeeded( - tabStateDirectory: tabStateDirectory, - lastQueryDirectory: lastQueryDirectory - ) + Self.performMigrationIfNeeded(tabStateDirectory: directory) } // MARK: - Public API @@ -111,52 +95,6 @@ internal actor TabDiskActor { } } - /// Save the last query text for a connection. Skips if query exceeds 500KB. - internal func saveLastQuery(_ query: String, for connectionId: UUID) { - guard (query as NSString).length < TabQueryContent.maxPersistableQuerySize else { return } - - let fileURL = lastQueryFileURL(for: connectionId) - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.isEmpty { - if FileManager.default.fileExists(atPath: fileURL.path) { - do { - try FileManager.default.removeItem(at: fileURL) - } catch { - Self.logger.error( - "Failed to remove last query for \(connectionId): \(error.localizedDescription)" - ) - } - } - } else { - do { - let data = Data(trimmed.utf8) - try data.write(to: fileURL, options: .atomic) - } catch { - Self.logger.error( - "Failed to save last query for \(connectionId): \(error.localizedDescription)" - ) - } - } - } - - /// Load the last query text for a connection. - internal func loadLastQuery(for connectionId: UUID) -> String? { - let fileURL = lastQueryFileURL(for: connectionId) - - guard FileManager.default.fileExists(atPath: fileURL.path) else { - return nil - } - - do { - let data = try Data(contentsOf: fileURL) - return String(data: data, encoding: .utf8) - } catch { - Self.logger.error("Failed to load last query for \(connectionId): \(error.localizedDescription)") - return nil - } - } - /// List all connection IDs that have saved tab state on disk. internal func connectionIdsWithSavedState() -> [UUID] { let fm = FileManager.default @@ -217,16 +155,12 @@ internal actor TabDiskActor { tabStateDirectory.appendingPathComponent("\(connectionId.uuidString).json") } - private func lastQueryFileURL(for connectionId: UUID) -> URL { - lastQueryDirectory.appendingPathComponent("\(connectionId.uuidString).txt") - } - // MARK: - Migration from UserDefaults - /// One-time migration: reads existing tab state and last-query data from UserDefaults, + /// One-time migration: reads existing tab state from UserDefaults, /// writes it to file storage, then clears the old UserDefaults keys. /// This is a static method to avoid actor-isolation issues during init. - private static func performMigrationIfNeeded(tabStateDirectory: URL, lastQueryDirectory: URL) { + private static func performMigrationIfNeeded(tabStateDirectory: URL) { let defaults = UserDefaults.standard guard !defaults.bool(forKey: migrationCompleteKey) else { return } @@ -234,11 +168,9 @@ internal actor TabDiskActor { logger.trace("Starting one-time migration of tab state from UserDefaults to file storage") var migratedTabStates = 0 - var migratedLastQueries = 0 let allKeys = defaults.dictionaryRepresentation().keys let tabStateKeys = allKeys.filter { $0.hasPrefix(legacyTabStateKeyPrefix) } - let lastQueryKeys = allKeys.filter { $0.hasPrefix(legacyLastQueryKeyPrefix) } for key in tabStateKeys { let uuidString = String(key.dropFirst(legacyTabStateKeyPrefix.count)) @@ -255,28 +187,10 @@ internal actor TabDiskActor { } } - for key in lastQueryKeys { - let uuidString = String(key.dropFirst(legacyLastQueryKeyPrefix.count)) - guard let connectionId = UUID(uuidString: uuidString), - let query = defaults.string(forKey: key) else { continue } - - let fileURL = lastQueryDirectory.appendingPathComponent("\(connectionId.uuidString).txt") - do { - let data = Data(query.utf8) - try data.write(to: fileURL, options: .atomic) - defaults.removeObject(forKey: key) - migratedLastQueries += 1 - } catch { - logger.error("Failed to migrate last query for \(uuidString): \(error.localizedDescription)") - } - } - defaults.set(true, forKey: migrationCompleteKey) - if migratedTabStates > 0 || migratedLastQueries > 0 { - logger.trace( - "Migration complete: \(migratedTabStates) tab states, \(migratedLastQueries) last queries" - ) + if migratedTabStates > 0 { + logger.trace("Migration complete: \(migratedTabStates) tab states") } else { logger.trace("Migration complete: no legacy data found") } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 0993389be..fc43cd4b7 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -20,43 +20,6 @@ struct QueryTab: Identifiable, Equatable { var tableContext: TabTableContext var display: TabDisplayState - var rowBuffer: RowBuffer - - var resultColumns: [String] { - get { rowBuffer.columns } - set { rowBuffer.columns = newValue } - } - - 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 resultRows: [[String?]] { - get { rowBuffer.rows } - set { rowBuffer.rows = newValue } - } - var pendingChanges: TabPendingChanges var selectedRowIndices: Set var sortState: SortState @@ -64,7 +27,7 @@ struct QueryTab: Identifiable, Equatable { var columnLayout: ColumnLayoutState var pagination: PaginationState var hasUserInteraction: Bool - var resultVersion: Int + var schemaVersion: Int var metadataVersion: Int var paginationVersion: Int @@ -83,7 +46,6 @@ struct QueryTab: Identifiable, Equatable { self.execution = TabExecutionState() self.tableContext = TabTableContext(tableName: tableName, isEditable: tabType == .table) self.display = TabDisplayState() - self.rowBuffer = RowBuffer() self.pendingChanges = TabPendingChanges() self.selectedRowIndices = [] self.sortState = SortState() @@ -91,7 +53,7 @@ struct QueryTab: Identifiable, Equatable { self.columnLayout = ColumnLayoutState() self.pagination = PaginationState() self.hasUserInteraction = false - self.resultVersion = 0 + self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 } @@ -115,7 +77,6 @@ struct QueryTab: Identifiable, Equatable { isView: persisted.isView ) self.display = TabDisplayState(erDiagramSchemaKey: persisted.erDiagramSchemaKey) - self.rowBuffer = RowBuffer() self.pendingChanges = TabPendingChanges() self.selectedRowIndices = [] self.sortState = SortState() @@ -123,7 +84,7 @@ struct QueryTab: Identifiable, Equatable { self.columnLayout = ColumnLayoutState() self.pagination = PaginationState() self.hasUserInteraction = false - self.resultVersion = 0 + self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 } @@ -194,7 +155,7 @@ struct QueryTab: Identifiable, Equatable { lhs.id == rhs.id && lhs.title == rhs.title && lhs.execution == rhs.execution - && lhs.resultVersion == rhs.resultVersion + && lhs.schemaVersion == rhs.schemaVersion && lhs.paginationVersion == rhs.paginationVersion && lhs.pagination == rhs.pagination && lhs.sortState == rhs.sortState diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index f694d5885..72e40a731 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -11,11 +11,18 @@ import os @MainActor @Observable final class QueryTabManager { var tabs: [QueryTab] = [] { - didSet { _tabIndexMapDirty = true } + didSet { + _tabIndexMapDirty = true + if oldValue.map(\.id) != tabs.map(\.id) { + tabStructureVersion += 1 + } + } } var selectedTabId: UUID? + var tabStructureVersion: Int = 0 + @ObservationIgnored private var _tabIndexMap: [UUID: Int] = [:] @ObservationIgnored private var _tabIndexMapDirty = true @@ -39,7 +46,6 @@ final class QueryTabManager { } init() { - // Start with no tabs - shows empty state tabs = [] selectedTabId = nil } @@ -212,12 +218,11 @@ final class QueryTabManager { let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize var tab = tabs[selectedIndex] - tab.rowBuffer = RowBuffer() tab.tabType = .table tab.title = tableName tab.tableContext.tableName = tableName tab.content.query = query - tab.resultVersion += 1 + tab.schemaVersion += 1 tab.execution.executionTime = nil tab.execution.statusMessage = nil tab.execution.errorMessage = nil @@ -236,6 +241,7 @@ final class QueryTabManager { tab.tableContext.schemaName = schemaName tab.isPreview = isPreview tabs[selectedIndex] = tab + tabStructureVersion += 1 return true } @@ -245,6 +251,11 @@ final class QueryTabManager { } } + func markTabRenamed(_ tabId: UUID) { + guard tabs.contains(where: { $0.id == tabId }) else { return } + tabStructureVersion += 1 + } + deinit { #if DEBUG Logger(subsystem: "com.TablePro", category: "QueryTabManager") diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index e93f2cde5..01997ddb7 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -22,7 +22,6 @@ final class ResultSet: Identifiable { var tableName: String? var isEditable: Bool = false var isPinned: Bool = false - var resultVersion: Int = 0 var metadataVersion: Int = 0 var sortState = SortState() var pagination = PaginationState() diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 58971a14a..5db4a7248 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -110,4 +110,22 @@ final class DataTabGridDelegate: DataGridViewDelegate { menu.addItem(item) return menu } + + weak var tableViewCoordinator: (any RowDeltaApplying)? + + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + self.tableViewCoordinator = tableViewCoordinator + } + + func dataGridDidInsertRows(at indices: IndexSet) { + tableViewCoordinator?.applyInsertedRows(indices) + } + + func dataGridDidRemoveRows(at indices: IndexSet) { + tableViewCoordinator?.applyRemovedRows(indices) + } + + func dataGridDidReplaceAllRows() { + tableViewCoordinator?.applyFullReplace() + } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index aca0adcbc..ff0d9d1b3 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -15,7 +15,7 @@ private struct SortedRowsCache { let sortedIndices: [Int] let columnIndex: Int let direction: SortDirection - let resultVersion: Int + let schemaVersion: Int } /// Main editor content with tab bar and content switching @@ -117,7 +117,8 @@ struct MainEditorContentView: View { guard let query = notification.userInfo?["query"] as? String else { return } favoriteDialogQuery = FavoriteDialogQuery(query: query) } - .onChange(of: tabManager.tabIds) { _, newIds in + .onChange(of: tabManager.tabStructureVersion) { _, _ in + let newIds = tabManager.tabIds guard !sortCache.isEmpty || !providerCache.isEmpty || !erDiagramViewModels.isEmpty || !serverDashboardViewModels.isEmpty else { coordinator.cleanupSortCache(openTabIds: Set(newIds)) @@ -133,10 +134,12 @@ struct MainEditorContentView: View { .onChange(of: tabManager.selectedTabId) { _, _ in updateHasQueryText() - guard let tab = tabManager.selectedTab else { return } + guard let tab = tabManager.selectedTab, + let existing = coordinator.rowDataStore.existingBuffer(for: tab.id), + !existing.isEvicted else { return } if providerCache.provider( for: tab.id, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, sortState: tab.sortState ) == nil { @@ -146,16 +149,22 @@ struct MainEditorContentView: View { .onAppear { updateHasQueryText() cachedChangeManager = AnyChangeManager(changeManager) - if let tab = tabManager.selectedTab { + 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?.resultVersion) { _, newVersion in + .onChange(of: tabManager.selectedTab?.schemaVersion) { _, newVersion in guard let tab = tabManager.selectedTab, newVersion != nil else { return } cacheRowProvider(for: tab) } @@ -170,6 +179,42 @@ struct MainEditorContentView: View { .onChange(of: selectionState.indices) { _, newIndices in onSelectionChange(newIndices) } + .onChange(of: tabManager.selectedTab?.tableContext.isEditable) { _, _ in + refreshDataTabDelegateMutableRefs() + } + .onChange(of: tabManager.selectedTab?.tableContext.isView) { _, _ in + refreshDataTabDelegateMutableRefs() + } + .onChange(of: tabManager.selectedTab?.tableContext.tableName) { _, _ in + refreshDataTabDelegateMutableRefs() + } + .onChange(of: coordinator.safeModeLevel) { _, _ in + refreshDataTabDelegateMutableRefs() + } + } + + private func wireDataTabDelegateStableRefs() { + dataTabDelegate.coordinator = coordinator + dataTabDelegate.columnVisibilityManager = columnVisibilityManager + dataTabDelegate.selectionState = selectionState + dataTabDelegate.editingCell = $editingCell + dataTabDelegate.onCellEdit = onCellEdit + dataTabDelegate.onSort = onSort + dataTabDelegate.onUndoInsert = onUndoInsert + dataTabDelegate.onFilterColumn = onFilterColumn + dataTabDelegate.onRefresh = onRefresh + } + + private func refreshDataTabDelegateMutableRefs() { + dataTabDelegate.onAddRow = currentTabAllowsAddRow ? onAddRow : nil + } + + private var currentTabAllowsAddRow: Bool { + guard let tab = tabManager.selectedTab else { return false } + let isEditable = tab.tableContext.isEditable + && !tab.tableContext.isView + && !coordinator.safeModeLevel.blocksAllWrites + return isEditable && tab.tableContext.tableName != nil } // MARK: - Tab Content @@ -335,7 +380,6 @@ struct MainEditorContentView: View { tabManager.tabs[index].content.query = newValue - // Update window dirty indicator and toolbar for file-backed tabs if tabManager.tabs[index].content.sourceFileURL != nil { let isDirty = tabManager.tabs[index].content.isFileDirty Task { @MainActor in @@ -344,13 +388,6 @@ struct MainEditorContentView: View { } } } - - // Skip persistence for very large queries (e.g., imported SQL dumps). - // JSON-encoding 40MB freezes the main thread. - let queryLength = (newValue as NSString).length - guard queryLength < TabQueryContent.maxPersistableQuerySize else { return } - - coordinator.persistence.saveLastQuery(newValue) } ) } @@ -400,10 +437,11 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } case .json: + let jsonBuffer = coordinator.rowDataStore.buffer(for: tab.id) ResultsJsonView( - columns: tab.resultColumns, - columnTypes: tab.columnTypes, - rows: tab.resultRows, + columns: jsonBuffer.columns, + columnTypes: jsonBuffer.columnTypes, + rows: jsonBuffer.rows, selectedRowIndices: selectionState.indices ) case .data: @@ -427,6 +465,7 @@ struct MainEditorContentView: View { } // Content: success view OR filter+grid + let resolvedBuffer = coordinator.rowDataStore.buffer(for: tab.id) if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, !tab.execution.isExecuting { @@ -435,7 +474,7 @@ struct MainEditorContentView: View { executionTime: rs.executionTime, statusMessage: rs.statusMessage ) - } else if tab.resultColumns.isEmpty && tab.execution.errorMessage == nil + } else if resolvedBuffer.columns.isEmpty && tab.execution.errorMessage == nil && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting { if tab.display.resultSets.isEmpty { @@ -452,7 +491,7 @@ struct MainEditorContentView: View { if filterStateManager.isVisible && tab.tabType == .table { FilterPanelView( filterState: filterStateManager, - columns: tab.resultColumns, + columns: resolvedBuffer.columns, primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, onApply: onApplyFilters, @@ -461,8 +500,8 @@ struct MainEditorContentView: View { Divider() } - if tab.tabType == .query && !tab.resultColumns.isEmpty - && tab.resultRows.isEmpty && tab.execution.lastExecutedAt != nil + if tab.tabType == .query && !resolvedBuffer.columns.isEmpty + && resolvedBuffer.rows.isEmpty && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting && !filterStateManager.hasAppliedFilters { emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime) @@ -497,7 +536,6 @@ struct MainEditorContentView: View { onPin: { id in guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } coordinator.tabManager.tabs[tabIdx].display.resultSets.first { $0.id == id }?.isPinned.toggle() - coordinator.tabManager.tabs[tabIdx].resultVersion += 1 } ) } @@ -517,26 +555,11 @@ struct MainEditorContentView: View { @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { let isEditable = tab.tableContext.isEditable && !tab.tableContext.isView && !coordinator.safeModeLevel.blocksAllWrites - let showEmptySpaceMenu = isEditable && tab.tableContext.tableName != nil - - // Update delegate state for current render - let _ = { // swiftlint:disable:this redundant_discardable_let - dataTabDelegate.coordinator = coordinator - dataTabDelegate.columnVisibilityManager = columnVisibilityManager - dataTabDelegate.selectionState = selectionState - dataTabDelegate.editingCell = $editingCell - dataTabDelegate.onCellEdit = onCellEdit - dataTabDelegate.onSort = onSort - dataTabDelegate.onAddRow = showEmptySpaceMenu ? onAddRow : nil - dataTabDelegate.onUndoInsert = onUndoInsert - dataTabDelegate.onFilterColumn = onFilterColumn - dataTabDelegate.onRefresh = onRefresh - }() DataGridView( rowProvider: rowProvider(for: tab), changeManager: currentChangeManager, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, paginationVersion: tab.paginationVersion, isEditable: isEditable, @@ -562,13 +585,14 @@ struct MainEditorContentView: View { } private func rowProvider(for tab: QueryTab) -> InMemoryRowProvider { - if tab.rowBuffer.isEvicted { + 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, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, sortState: tab.sortState ) { @@ -578,7 +602,7 @@ struct MainEditorContentView: View { providerCache.store( provider, for: tab.id, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, sortState: tab.sortState ) @@ -590,7 +614,7 @@ struct MainEditorContentView: View { providerCache.store( provider, for: tab.id, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, sortState: tab.sortState ) @@ -612,15 +636,16 @@ struct MainEditorContentView: View { columnNullable: rs.columnNullable ) } else { + let buffer = coordinator.rowDataStore.buffer(for: tab.id) provider = InMemoryRowProvider( - rowBuffer: tab.rowBuffer, + rowBuffer: buffer, sortIndices: sortIndicesForTab(tab), - columns: tab.resultColumns, - columnDefaults: tab.columnDefaults, - columnTypes: tab.columnTypes, - columnForeignKeys: tab.columnForeignKeys, - columnEnumValues: tab.columnEnumValues, - columnNullable: tab.columnNullable + columns: buffer.columns, + columnDefaults: buffer.columnDefaults, + columnTypes: buffer.columnTypes, + columnForeignKeys: buffer.columnForeignKeys, + columnEnumValues: buffer.columnEnumValues, + columnNullable: buffer.columnNullable ) } @@ -640,7 +665,7 @@ struct MainEditorContentView: View { var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) if settings.enableSmartValueDetection { let sampleRows: [[String?]]? = { - let rows = tab.display.activeResultSet?.resultRows ?? tab.resultRows + let rows = tab.display.activeResultSet?.resultRows ?? coordinator.rowDataStore.buffer(for: tab.id).rows return rows.isEmpty ? nil : Array(rows.prefix(10)) }() detected = ValueDisplayDetector.detect( @@ -695,9 +720,10 @@ struct MainEditorContentView: View { rows = rs.resultRows colTypes = rs.columnTypes } else { - rowBuffer = tab.rowBuffer - rows = tab.resultRows - colTypes = tab.columnTypes + let buffer = coordinator.rowDataStore.buffer(for: tab.id) + rowBuffer = buffer + rows = buffer.rows + colTypes = buffer.columnTypes } guard !rowBuffer.isEvicted else { return nil } @@ -716,7 +742,7 @@ struct MainEditorContentView: View { if let cached = coordinator.querySortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, - cached.resultVersion == tab.resultVersion + cached.schemaVersion == tab.schemaVersion { return cached.sortedIndices } @@ -730,7 +756,7 @@ struct MainEditorContentView: View { if let cached = sortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, - cached.resultVersion == tab.resultVersion + cached.schemaVersion == tab.schemaVersion { return cached.sortedIndices } @@ -764,7 +790,7 @@ struct MainEditorContentView: View { sortedIndices: sortedIndices, columnIndex: tab.sortState.columnIndex ?? -1, direction: tab.sortState.direction, - resultVersion: tab.resultVersion + schemaVersion: tab.schemaVersion ) return sortedIndices @@ -800,11 +826,12 @@ struct MainEditorContentView: View { // MARK: - Status Bar private func statusBar(tab: QueryTab) -> some View { - MainStatusBarView( - snapshot: StatusBarSnapshot(tab: tab), + let buffer = coordinator.rowDataStore.buffer(for: tab.id) + return MainStatusBarView( + snapshot: StatusBarSnapshot(tab: tab, buffer: buffer), filterStateManager: filterStateManager, columnVisibilityManager: columnVisibilityManager, - allColumns: tab.resultColumns, + allColumns: buffer.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 188e6e814..65f201bbf 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?) { + init(tab: QueryTab?, buffer: RowBuffer?) { self.tabId = tab?.id self.tabType = tab?.tabType - self.hasRows = !(tab?.resultRows.isEmpty ?? true) - self.hasColumns = !(tab?.resultColumns.isEmpty ?? true) - self.rowCount = tab?.resultRows.count ?? 0 + self.hasRows = !(buffer?.rows.isEmpty ?? true) + self.hasColumns = !(buffer?.columns.isEmpty ?? true) + self.rowCount = buffer?.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 f0d1f4aa6..93ebc63fe 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -72,17 +72,19 @@ extension MainContentCoordinator { ) { let originalValues = changeManager.getOriginalValues() 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 < tabManager.tabs[index].resultRows.count, - columnIndex < tabManager.tabs[index].resultRows[rowIndex].count { - tabManager.tabs[index].resultRows[rowIndex][columnIndex] = originalValue + if rowIndex < buffer.rows.count, + columnIndex < buffer.rows[rowIndex].count { + buffer.rows[rowIndex][columnIndex] = originalValue } } let insertedIndices = changeManager.insertedRowIndices.sorted(by: >) for rowIndex in insertedIndices { - if rowIndex < tabManager.tabs[index].resultRows.count { - tabManager.tabs[index].resultRows.remove(at: rowIndex) + if rowIndex < buffer.rows.count { + buffer.rows.remove(at: rowIndex) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index a192a11b8..a2a19e400 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -83,6 +83,8 @@ extension MainContentCoordinator { ) if needsQuery, let tabIndex = tabManager.selectedTabIndex { + let tabId = tabManager.tabs[tabIndex].id + rowDataStore.setBuffer(RowBuffer(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() } @@ -98,11 +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 filteredQuery = queryBuilder.buildFilteredQuery( tableName: referencedTable, schemaName: fkInfo.referencedSchema, filters: [filter], - columns: tab.resultColumns, + columns: buffer.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 4a1476e50..945456474 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift @@ -26,13 +26,14 @@ extension MainContentCoordinator { self.tabManager.tabs[capturedTabIndex].pagination.reset() let tab = self.tabManager.tabs[capturedTabIndex] + let buffer = self.rowDataStore.buffer(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildFilteredQuery( tableName: capturedTableName, filters: capturedFilters, logicMode: self.filterStateManager.filterLogicMode, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions @@ -63,11 +64,12 @@ 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 exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildBaseQuery( tableName: capturedTableName, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions @@ -93,6 +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 hasFilters = filterStateManager.hasAppliedFilters let exclusions = columnExclusions(for: tableName) @@ -103,7 +106,7 @@ extension MainContentCoordinator { filters: filterStateManager.appliedFilters, logicMode: filterStateManager.filterLogicMode, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions @@ -112,7 +115,7 @@ extension MainContentCoordinator { newQuery = queryBuilder.buildBaseQuery( tableName: tableName, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index 2538b1a23..9107f5361 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -96,23 +96,21 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - tab.rowBuffer.rows.append(contentsOf: pagedResult.rows) - tab.resultVersion += 1 + let buffer = rowDataStore.buffer(for: tab.id) + buffer.rows.append(contentsOf: pagedResult.rows) + tab.schemaVersion += 1 tab.pagination.loadMoreOffset = pagedResult.nextOffset tab.pagination.hasMoreRows = pagedResult.hasMore tab.pagination.isLoadingMore = false if !pagedResult.hasMore { tab.pagination.baseQueryForMore = nil } - if let rs = tab.display.activeResultSet { - rs.resultVersion = tab.resultVersion - } tabManager.tabs[idx] = tab toolbarState.setExecuting(false) if capturedGeneration == queryGeneration { currentQueryTask = nil } - progressLog.info("[loadMore] applied totalRows=\(tab.rowBuffer.rows.count)") + progressLog.info("[loadMore] applied totalRows=\(buffer.rows.count)") } } catch { await MainActor.run { [weak self] in @@ -138,7 +136,7 @@ extension MainContentCoordinator { tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore else { return } - let loadedCount = tab.resultRows.count + let loadedCount = rowDataStore.buffer(for: tab.id).rows.count let totalEstimate = tab.pagination.totalRowCount let message: String @@ -219,13 +217,11 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - tab.rowBuffer.rows = result.rows + let buffer = rowDataStore.buffer(for: tab.id) + buffer.rows = result.rows tab.execution.executionTime = result.executionTime - tab.resultVersion += 1 + tab.schemaVersion += 1 tab.pagination.resetLoadMore() - if let rs = tab.display.activeResultSet { - rs.resultVersion = tab.resultVersion - } tabManager.tabs[idx] = tab toolbarState.setExecuting(false) toolbarState.lastQueryDuration = result.executionTime diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index bccc155fe..b3ec4e71b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -94,7 +94,6 @@ extension MainContentCoordinator { rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage rs.tableName = stmtTableName - rs.resultVersion = 1 newResultSets.append(rs) let historySQL = sql.hasSuffix(";") ? sql : sql + ";" @@ -234,22 +233,21 @@ extension MainContentCoordinator { tableName = lastSelectSQL.flatMap { extractTableName(from: $0) } } - updatedTab.resultColumns = safeColumns - updatedTab.columnTypes = safeColumnTypes - updatedTab.resultRows = safeRows + rowDataStore.setBuffer( + RowBuffer(rows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), + for: updatedTab.id + ) updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = tableName != nil && updatedTab.tableContext.isEditable } else { - updatedTab.resultColumns = [] - updatedTab.columnTypes = [] - updatedTab.resultRows = [] + rowDataStore.setBuffer(RowBuffer(), for: updatedTab.id) if updatedTab.tabType != .table { updatedTab.tableContext.tableName = nil } updatedTab.tableContext.isEditable = false } - updatedTab.resultVersion += 1 + updatedTab.schemaVersion += 1 updatedTab.execution.executionTime = cumulativeTime updatedTab.execution.rowsAffected = totalRowsAffected updatedTab.execution.isExecuting = false @@ -268,7 +266,6 @@ extension MainContentCoordinator { if tabManager.selectedTabId == tabId { changeManager.clearChangesAndUndoHistory() - changeManager.reloadVersion += 1 } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 62f9c5f42..973358f32 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -124,6 +124,8 @@ extension MainContentCoordinator { ) { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { + let tabId = tabManager.tabs[tabIndex].id + rowDataStore.setBuffer(RowBuffer(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } @@ -204,6 +206,8 @@ 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.tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() previewCoordinator.toolbarState.isTableTab = true @@ -274,6 +278,8 @@ extension MainContentCoordinator { ) filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { + let tabId = tabManager.tabs[tabIndex].id + rowDataStore.setBuffer(RowBuffer(), for: tabId) tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true @@ -383,6 +389,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + rowDataStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in @@ -417,6 +424,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + rowDataStore.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 ef05e0f87..71cd55be0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -188,19 +188,20 @@ extension MainContentCoordinator { return false } let tab = tabManager.tabs[idx] + let buffer = rowDataStore.buffer(for: tab.id) guard tab.tableContext.tableName == tableName, - !tab.columnDefaults.isEmpty, + !buffer.columnDefaults.isEmpty, !tab.tableContext.primaryKeyColumns.isEmpty else { return false } // Ensure every ENUM/SET column has its allowed values loaded - let enumSetColumnNames: [String] = tab.resultColumns.enumerated().compactMap { i, name in - guard i < tab.columnTypes.count, - tab.columnTypes[i].isEnumType || tab.columnTypes[i].isSetType else { return nil } + 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 } return name } if !enumSetColumnNames.isEmpty, - !enumSetColumnNames.allSatisfy({ tab.columnEnumValues[$0] != nil }) { + !enumSetColumnNames.allSatisfy({ buffer.columnEnumValues[$0] != nil }) { return false } return true @@ -248,10 +249,8 @@ extension MainContentCoordinator { guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } var updatedTab = tabManager.tabs[idx] - updatedTab.resultColumns = columns - updatedTab.columnTypes = columnTypes - updatedTab.resultRows = rows - updatedTab.resultVersion += 1 + let newBuffer = RowBuffer(rows: rows, columns: columns, columnTypes: columnTypes) + updatedTab.schemaVersion += 1 updatedTab.execution.executionTime = executionTime updatedTab.execution.rowsAffected = rowsAffected updatedTab.execution.statusMessage = statusMessage @@ -260,19 +259,19 @@ extension MainContentCoordinator { updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = isEditable // Populate enum values from column types for the enum popover - for (index, colType) in updatedTab.columnTypes.enumerated() { - if case .enumType(_, let values) = colType, let vals = values, index < updatedTab.resultColumns.count { - updatedTab.columnEnumValues[updatedTab.resultColumns[index]] = vals + 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 } } // Merge FK metadata into the same update if available if let metadata { - updatedTab.columnDefaults = metadata.columnDefaults - updatedTab.columnForeignKeys = metadata.columnForeignKeys - updatedTab.columnNullable = metadata.columnNullable + newBuffer.columnDefaults = metadata.columnDefaults + newBuffer.columnForeignKeys = metadata.columnForeignKeys + newBuffer.columnNullable = metadata.columnNullable for (col, vals) in metadata.columnEnumValues { - updatedTab.columnEnumValues[col] = vals + newBuffer.columnEnumValues[col] = vals } if let approxCount = metadata.approximateRowCount, approxCount > 0 { updatedTab.pagination.totalRowCount = approxCount @@ -283,15 +282,16 @@ extension MainContentCoordinator { updatedTab.metadataVersion += 1 } + rowDataStore.setBuffer(newBuffer, for: updatedTab.id) + // Create a ResultSet for this single-statement execution let rs = ResultSet(label: tableName ?? "Result") - rs.rowBuffer = updatedTab.rowBuffer + rs.rowBuffer = newBuffer rs.executionTime = updatedTab.execution.executionTime rs.rowsAffected = updatedTab.execution.rowsAffected rs.statusMessage = updatedTab.execution.statusMessage rs.tableName = updatedTab.tableContext.tableName rs.isEditable = updatedTab.tableContext.isEditable - rs.resultVersion = updatedTab.resultVersion rs.metadataVersion = updatedTab.metadataVersion // Keep pinned results, replace unpinned @@ -466,13 +466,13 @@ extension MainContentCoordinator { guard capturedGeneration == queryGeneration else { return } guard !Task.isCancelled else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - let existing = tabManager.tabs[idx].columnEnumValues + let buffer = rowDataStore.buffer(for: tabId) let hasNewValues = columnEnumValues.contains { key, value in - existing[key] != value + buffer.columnEnumValues[key] != value } if hasNewValues { for (col, vals) in columnEnumValues { - tabManager.tabs[idx].columnEnumValues[col] = vals + buffer.columnEnumValues[col] = vals } tabManager.tabs[idx].metadataVersion += 1 } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift index 993618587..841ecba45 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift @@ -341,7 +341,6 @@ extension MainContentCoordinator { rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage rs.tableName = stmtTableName - rs.resultVersion = 1 newResultSets.append(rs) let historySQL = stmtSQL.hasSuffix(";") ? stmtSQL : stmtSQL + ";" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 90c9f38b8..2c20996aa 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -18,16 +18,18 @@ 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: tab.resultColumns, - columnDefaults: tab.columnDefaults, - resultRows: &tabManager.tabs[tabIndex].resultRows + columns: buffer.columns, + columnDefaults: buffer.columnDefaults, + resultRows: &buffer.rows ) else { return } selectionState.indices = [result.rowIndex] editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) } func deleteSelectedRows(indices: Set) { @@ -37,19 +39,28 @@ extension MainContentCoordinator { tabManager.tabs[tabIndex].tableContext.isEditable, !indices.isEmpty else { return } - let nextRow = rowOperationsManager.deleteSelectedRows( + let tabId = tabManager.tabs[tabIndex].id + let buffer = rowDataStore.buffer(for: tabId) + let result = rowOperationsManager.deleteSelectedRows( selectedIndices: indices, - resultRows: &tabManager.tabs[tabIndex].resultRows + resultRows: &buffer.rows ) - if nextRow >= 0 && nextRow < tabManager.tabs[tabIndex].resultRows.count { - selectionState.indices = [nextRow] + if result.nextRowToSelect >= 0 + && result.nextRowToSelect < buffer.rows.count { + selectionState.indices = [result.nextRowToSelect] } else { selectionState.indices.removeAll() } tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + + if !result.physicallyRemovedIndices.isEmpty { + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.dataGridDidRemoveRows( + at: IndexSet(result.physicallyRemovedIndices) + ) + } } func duplicateSelectedRow(index: Int, editingCell: inout CellPosition?) { @@ -58,45 +69,53 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - guard tab.tableContext.isEditable, tab.tableContext.tableName != nil, - index < tab.resultRows.count else { return } + 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: tab.resultColumns, - resultRows: &tabManager.tabs[tabIndex].resultRows + columns: buffer.columns, + resultRows: &buffer.rows ) else { return } selectionState.indices = [result.rowIndex] editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) } func undoInsertRow(at rowIndex: Int) { guard let tabIndex = tabManager.selectedTabIndex, 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: &tabManager.tabs[tabIndex].resultRows, + resultRows: &buffer.rows, selectedIndices: selectionState.indices ) - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(integer: rowIndex)) } func undoLastChange() { guard let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } + let tabId = tabManager.tabs[tabIndex].id + let buffer = rowDataStore.buffer(for: tabId) if let adjustedSelection = rowOperationsManager.undoLastChange( - resultRows: &tabManager.tabs[tabIndex].resultRows + resultRows: &buffer.rows ) { selectionState.indices = adjustedSelection } tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.dataGridDidReplaceAllRows() } func redoLastChange() { @@ -104,13 +123,15 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] + let buffer = rowDataStore.buffer(for: tab.id) _ = rowOperationsManager.redoLastChange( - resultRows: &tabManager.tabs[tabIndex].resultRows, - columns: tab.resultColumns + resultRows: &buffer.rows, + columns: buffer.columns ) tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidReplaceAllRows() } func copySelectedRowsToClipboard(indices: Set) { @@ -118,9 +139,10 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] + let buffer = rowDataStore.buffer(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: tab.resultRows + resultRows: buffer.rows ) } @@ -129,10 +151,11 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] + let buffer = rowDataStore.buffer(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: tab.resultRows, - columns: tab.resultColumns, + resultRows: buffer.rows, + columns: buffer.columns, includeHeaders: true ) } @@ -141,14 +164,15 @@ extension MainContentCoordinator { guard let index = tabManager.selectedTabIndex, !indices.isEmpty else { return } let tab = tabManager.tabs[index] + let buffer = rowDataStore.buffer(for: tab.id) let rows = indices.sorted().compactMap { idx -> [String?]? in - guard idx < tab.resultRows.count else { return nil } - return tab.resultRows[idx] + guard idx < buffer.rows.count else { return nil } + return buffer.rows[idx] } guard !rows.isEmpty else { return } let converter = JsonRowConverter( - columns: tab.resultColumns, - columnTypes: tab.columnTypes + columns: buffer.columns, + columnTypes: buffer.columnTypes ) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } @@ -157,35 +181,37 @@ extension MainContentCoordinator { guard !safeModeLevel.blocksAllWrites, let index = tabManager.selectedTabIndex else { return } - var tab = tabManager.tabs[index] + let tab = tabManager.tabs[index] guard tab.tabType == .table else { return } + let buffer = rowDataStore.buffer(for: tab.id) let pastedRows = rowOperationsManager.pasteRowsFromClipboard( - columns: tab.resultColumns, + columns: buffer.columns, primaryKeyColumns: changeManager.primaryKeyColumns, - resultRows: &tab.resultRows + resultRows: &buffer.rows ) - tabManager.tabs[index].resultRows = tab.resultRows - tabManager.tabs[index].resultVersion += 1 - if !pastedRows.isEmpty { let newIndices = Set(pastedRows.map { $0.rowIndex }) selectionState.indices = newIndices tabManager.tabs[index].selectedRowIndices = newIndices tabManager.tabs[index].hasUserInteraction = true + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(newIndices)) } } // MARK: - Cell Operations func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { - guard let index = tabManager.selectedTabIndex, - rowIndex < tabManager.tabs[index].resultRows.count else { return } + 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 } - tabManager.tabs[index].resultRows[rowIndex][columnIndex] = value + buffer.rows[rowIndex][columnIndex] = value tabManager.tabs[index].hasUserInteraction = true } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 2bec53d1f..2446cee9d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -248,6 +248,9 @@ extension MainContentCoordinator { if !tabIdsToRemove.isEmpty { let firstRemovedIndex = tabManager.tabs .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 + for tabId in tabIdsToRemove { + rowDataStore.removeBuffer(for: tabId) + } tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } if !tabManager.tabs.isEmpty { let neighborIndex = min(firstRemovedIndex, tabManager.tabs.count - 1) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 6f37b8d08..7e65f610b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -22,15 +22,12 @@ extension MainContentCoordinator { tabManager.tabs[tabIdx].display.activeResultSetId = tabManager.tabs[tabIdx].display.resultSets.last?.id } if tabManager.tabs[tabIdx].display.resultSets.isEmpty { - tabManager.tabs[tabIdx].rowBuffer = RowBuffer() - tabManager.tabs[tabIdx].resultColumns = [] - tabManager.tabs[tabIdx].columnTypes = [] - tabManager.tabs[tabIdx].resultRows = [] + rowDataStore.setBuffer(RowBuffer(), for: tabManager.tabs[tabIdx].id) tabManager.tabs[tabIdx].execution.errorMessage = nil tabManager.tabs[tabIdx].execution.rowsAffected = 0 tabManager.tabs[tabIdx].execution.executionTime = nil tabManager.tabs[tabIdx].execution.statusMessage = nil - tabManager.tabs[tabIdx].resultVersion += 1 + tabManager.tabs[tabIdx].schemaVersion += 1 tabManager.tabs[tabIdx].display.isResultsCollapsed = true toolbarState.isResultsCollapsed = true } @@ -107,7 +104,8 @@ extension MainContentCoordinator { } func openExportQueryResultsDialog() { - guard let tab = tabManager.selectedTab, !tab.rowBuffer.rows.isEmpty else { return } + guard let tab = tabManager.selectedTab, + !rowDataStore.buffer(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 35919933e..364bec943 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -23,9 +23,10 @@ extension MainContentCoordinator { let editedFields = editState.getEditedFields() guard !editedFields.isEmpty else { return } + let buffer = rowDataStore.buffer(for: tab.id) let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex in - guard rowIndex < tab.resultRows.count else { return nil } - let originalRow = tab.resultRows[rowIndex] + guard rowIndex < buffer.rows.count else { return nil } + let originalRow = buffer.rows[rowIndex] return RowChange( rowIndex: rowIndex, type: .update, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 2eaa7bd77..015d18604 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -57,6 +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) // Restore filter state for new tab filterStateManager.restoreFromTabState(newTab.filterState) @@ -74,9 +75,9 @@ extension MainContentCoordinator { } else { changeManager.configureForTable( tableName: newTab.tableContext.tableName ?? "", - columns: newTab.resultColumns, + columns: newBuffer.columns, primaryKeyColumns: newTab.tableContext.primaryKeyColumns.isEmpty - ? newTab.resultColumns.prefix(1).map { $0 } + ? newBuffer.columns.prefix(1).map { $0 } : newTab.tableContext.primaryKeyColumns, databaseType: connection.type, triggerReload: false @@ -111,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 && newTab.resultRows.isEmpty && newTab.execution.lastExecutedAt == nil { + if newTab.execution.isExecuting && newBuffer.rows.isEmpty && newTab.execution.lastExecutedAt == nil { let tabId = newId Task { [weak self] in guard let self, @@ -121,9 +122,9 @@ extension MainContentCoordinator { } } - let isEvicted = newTab.rowBuffer.isEvicted + let isEvicted = newBuffer.isEvicted let needsLazyQuery = newTab.tabType == .table - && (newTab.resultRows.isEmpty || isEvicted) + && (newBuffer.rows.isEmpty || isEvicted) && (newTab.execution.lastExecutedAt == nil || isEvicted) && newTab.execution.errorMessage == nil && !newTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -153,25 +154,28 @@ extension MainContentCoordinator { private func evictInactiveTabs(excluding activeTabIds: Set) { let start = Date() - let candidates = tabManager.tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + let candidates: [(tab: QueryTab, buffer: RowBuffer)] = 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 + else { return nil } + return (tab, buffer) } let sorted = candidates.sorted { - let t0 = $0.execution.lastExecutedAt ?? .distantFuture - let t1 = $1.execution.lastExecutedAt ?? .distantFuture + let t0 = $0.tab.execution.lastExecutedAt ?? .distantFuture + let t1 = $1.tab.execution.lastExecutedAt ?? .distantFuture if t0 != t1 { return t0 < t1 } let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.rowBuffer.rows.count, - columnCount: $0.rowBuffer.columns.count + rowCount: $0.buffer.rows.count, + columnCount: $0.buffer.columns.count ) let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.rowBuffer.rows.count, - columnCount: $1.rowBuffer.columns.count + rowCount: $1.buffer.rows.count, + columnCount: $1.buffer.columns.count ) return size0 > size1 } @@ -185,8 +189,8 @@ extension MainContentCoordinator { } let toEvict = sorted.dropLast(maxInactiveLoaded) - for tab in toEvict { - tab.rowBuffer.evict() + for entry in toEvict { + entry.buffer.evict() } 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+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 17e3ad0e7..70806c38e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -40,9 +40,10 @@ extension MainContentCoordinator { DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false let needsLazyLoad = tabManager.selectedTab.map { tab in - tab.tabType == .table - && (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted) - && (tab.execution.lastExecutedAt == nil || tab.rowBuffer.isEvicted) + let buffer = rowDataStore.buffer(for: tab.id) + return tab.tabType == .table + && (buffer.rows.isEmpty || buffer.isEvicted) + && (tab.execution.lastExecutedAt == nil || buffer.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 e06e71962..7066017a1 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -15,19 +15,20 @@ extension MainContentView { var selectedRowDataForSidebar: [(column: String, value: String?, type: String)]? { guard let tab = coordinator.tabManager.selectedTab, !coordinator.selectionState.indices.isEmpty, - let firstIndex = coordinator.selectionState.indices.min(), - firstIndex < tab.resultRows.count else { return nil } + 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 row = tab.resultRows[firstIndex] + let row = buffer.rows[firstIndex] 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 tab.resultColumns.enumerated() { + for (i, col) in buffer.columns.enumerated() { var value = i < row.count ? row[i] : nil - let type = i < tab.columnTypes.count ? tab.columnTypes[i].displayName : "string" + let type = i < buffer.columnTypes.count ? buffer.columnTypes[i].displayName : "string" // Apply display format if active if let rawValue = value { @@ -103,30 +104,19 @@ extension MainContentView { // MARK: - Consolidated onChange Triggers - /// Trigger for inspector updates — combines result version and table metadata name. - /// Replaces separate handlers for `currentTab?.resultRows` and - /// `coordinator.tableMetadata?.tableName` that both only called `scheduleInspectorUpdate()`. - /// Uses `resultVersion` instead of the full `resultRows` array to avoid deep equality checks. var inspectorTrigger: InspectorTrigger { InspectorTrigger( tableName: currentTab?.tableContext.tableName, - resultVersion: currentTab?.resultVersion ?? -1, - metadataVersion: currentTab?.metadataVersion ?? -1, - metadataTableName: coordinator.tableMetadata?.tableName + schemaVersion: currentTab?.schemaVersion ?? -1, + metadataVersion: currentTab?.metadataVersion ?? -1 ) } } -// MARK: - Equatable Trigger Types - -/// Lightweight equatable value combining tab table name, result version, and metadata table name -/// for consolidated inspector onChange observation. Folding `tableName` here avoids a separate -/// `onChange(of: currentTab?.tableName)` handler that would cascade with this trigger. struct InspectorTrigger: Equatable { let tableName: String? - let resultVersion: Int + let schemaVersion: Int let metadataVersion: Int - let metadataTableName: String? } /// Lightweight equatable value combining all pending-change sources diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 6cd2f6c4d..9c761d52a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -41,15 +41,13 @@ extension MainContentView { ) } - func handleTabsChange(_ newTabs: [QueryTab]) { + func handleStructureChange() { guard !coordinator.isTearingDown else { - MainContentView.lifecycleLogger.debug("[switch] handleTabsChange SKIPPED (tearingDown) tabCount=\(newTabs.count) connId=\(coordinator.connectionId, privacy: .public)") + MainContentView.lifecycleLogger.debug("[switch] handleStructureChange SKIPPED (tearingDown) tabCount=\(tabManager.tabs.count) connId=\(coordinator.connectionId, privacy: .public)") return } let t0 = Date() - // Only update title when the tab array changes independently of a tab switch. - // During a tab switch, handleTabSelectionChange already updates the title. if !coordinator.isHandlingTabSwitch { updateWindowTitleAndFileState() } @@ -60,7 +58,7 @@ extension MainContentView { coordinator.promotePreviewTab() } - let persistableTabs = newTabs.filter { !$0.isPreview } + let persistableTabs = tabManager.tabs.filter { !$0.isPreview } if persistableTabs.isEmpty { coordinator.persistence.clearSavedState() } else { @@ -73,7 +71,7 @@ extension MainContentView { ) } MainContentView.lifecycleLogger.debug( - "[switch] handleTabsChange tabCount=\(newTabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" + "[switch] handleStructureChange tabCount=\(tabManager.tabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) } @@ -178,18 +176,19 @@ extension MainContentView { rightPanelState.editState.onFieldChanged = nil return } + let buffer = coordinator.rowDataStore.buffer(for: tab.id) var allRows: [[String?]] = [] for index in selectedIndices.sorted() { - if index < tab.resultRows.count { - allRows.append(tab.resultRows[index]) + if index < buffer.rows.count { + allRows.append(buffer.rows[index]) } } // Enrich column types with loaded enum values from Phase 2b - var columnTypes = tab.columnTypes - for (i, col) in tab.resultColumns.enumerated() where i < columnTypes.count { - if let values = tab.columnEnumValues[col], !values.isEmpty { + var columnTypes = buffer.columnTypes + for (i, col) in buffer.columns.enumerated() where i < columnTypes.count { + if let values = buffer.columnEnumValues[col], !values.isEmpty { let ct = columnTypes[i] if ct.isEnumType { columnTypes[i] = .enumType(rawType: ct.rawType, values: values) @@ -218,12 +217,12 @@ extension MainContentView { } let pkColumns = Set(tab.tableContext.primaryKeyColumns) - let fkColumns = Set(tab.columnForeignKeys.keys) + let fkColumns = Set(buffer.columnForeignKeys.keys) rightPanelState.editState.configure( selectedRowIndices: selectedIndices, allRows: allRows, - columns: tab.resultColumns, + columns: buffer.columns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, excludedColumnNames: excludedNames, @@ -240,12 +239,13 @@ 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 columnName = - columnIndex < tab.resultColumns.count ? tab.resultColumns[columnIndex] : "" + columnIndex < buffer.columns.count ? buffer.columns[columnIndex] : "" for rowIndex in capturedEditState.selectedRowIndices { - guard rowIndex < tab.resultRows.count else { continue } - let originalRow = tab.resultRows[rowIndex] + guard rowIndex < buffer.rows.count else { continue } + let originalRow = buffer.rows[rowIndex] // Use full (lazy-loaded) original value if available, not truncated row data let oldValue: String? @@ -267,7 +267,6 @@ extension MainContentView { ) } } - } func lazyLoadExcludedColumnsIfNeeded() { @@ -284,15 +283,16 @@ extension MainContentView { let capturedCoordinator = coordinator let capturedEditState = rightPanelState.editState + let buffer = coordinator.rowDataStore.buffer(for: tab.id) if !excludedNames.isEmpty, selectedIndices.count == 1, let tableName = tab.tableContext.tableName, let pkColumn = tab.tableContext.primaryKeyColumn, let rowIndex = selectedIndices.first, - rowIndex < tab.resultRows.count + rowIndex < buffer.rows.count { - let row = tab.resultRows[rowIndex] - if let pkColIndex = tab.resultColumns.firstIndex(of: pkColumn), + let row = buffer.rows[rowIndex] + if let pkColIndex = buffer.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 ac4b74597..d2f0dbe1c 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -63,11 +63,11 @@ extension MainContentView { // MARK: - Inspector Context func scheduleInspectorUpdate(lazyLoadExcludedColumns: Bool = false) { - updateSidebarEditState() inspectorUpdateTask?.cancel() inspectorUpdateTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled else { return } + updateSidebarEditState() updateInspectorContext() if lazyLoadExcludedColumns { lazyLoadExcludedColumnsIfNeeded() @@ -90,23 +90,22 @@ extension MainContentView { private func cachedQueryResultsSummary() -> String? { guard let tab = currentTab else { return nil } if let cache = queryResultsSummaryCache, - cache.tabId == tab.id, cache.version == tab.resultVersion + cache.tabId == tab.id, cache.version == tab.schemaVersion { return cache.summary } let summary = buildQueryResultsSummary() - queryResultsSummaryCache = (tabId: tab.id, version: tab.resultVersion, summary: summary) + queryResultsSummaryCache = (tabId: tab.id, version: tab.schemaVersion, summary: summary) return summary } private func buildQueryResultsSummary() -> String? { - guard let tab = currentTab, - !tab.resultColumns.isEmpty, - !tab.resultRows.isEmpty - else { return nil } + 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 columns = tab.resultColumns - let rows = tab.resultRows + let columns = buffer.columns + let rows = buffer.rows let maxRows = 10 let displayRows = Array(rows.prefix(maxRows)) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 3a2712eb0..db053689a 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -380,9 +380,7 @@ final class MainContentCommandActions { } else if coordinator?.tabManager.tabs.isEmpty == true { window.close() } else { - for tab in coordinator?.tabManager.tabs ?? [] { - tab.rowBuffer.evict() - } + coordinator?.rowDataStore.evictAll(except: nil) coordinator?.tabManager.tabs.removeAll() coordinator?.tabManager.selectedTabId = nil coordinator?.toolbarState.isTableTab = false diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f1951067e..6e50c2188 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -26,7 +26,7 @@ struct QuerySortCacheEntry { let sortedIndices: [Int] let columnIndex: Int let direction: SortDirection - let resultVersion: Int + let schemaVersion: Int } /// Sidebar table loading state — single source of truth for sidebar UI @@ -87,6 +87,7 @@ final class MainContentCoordinator { let filterStateManager: FilterStateManager let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState + let rowDataStore = RowDataStore() // MARK: - Services @@ -111,6 +112,10 @@ final class MainContentCoordinator { /// Direct reference to right panel state — enables showing AI panel programmatically @ObservationIgnored weak var rightPanelState: RightPanelState? + /// Direct reference to the data tab grid delegate — enables row mutation operations to + /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. + @ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate? + /// Proxy for toggling the inspector NSSplitViewItem from coordinator code @ObservationIgnored weak var inspectorProxy: InspectorVisibilityProxy? @@ -138,7 +143,7 @@ final class MainContentCoordinator { var sidebarLoadingState: SidebarLoadingState = .idle /// Cache for async-sorted query tab rows (large datasets sorted on background thread) - @ObservationIgnored private(set) var querySortCache: [UUID: QuerySortCacheEntry] = [:] + @ObservationIgnored var querySortCache: [UUID: QuerySortCacheEntry] = [:] // MARK: - Internal State @@ -158,7 +163,7 @@ final class MainContentCoordinator { @ObservationIgnored private var fileWatcher: DatabaseFileWatcher? @ObservationIgnored private var lastSchemaRefreshDate = Date.distantPast - /// Set during handleTabChange to suppress redundant onChange(of: resultColumns) reconfiguration + /// Set during handleTabChange to suppress redundant column-change reconfiguration @ObservationIgnored internal var isHandlingTabSwitch = false @ObservationIgnored var isUpdatingColumnLayout = false @@ -332,12 +337,10 @@ final class MainContentCoordinator { /// Background tabs are re-fetched automatically when selected. func evictInactiveRowData() { let selectedId = tabManager.selectedTabId - for tab in tabManager.tabs where !tab.rowBuffer.isEvicted - && !tab.resultRows.isEmpty - && !tab.pendingChanges.hasChanges - && tab.id != selectedId - { - tab.rowBuffer.evict() + 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() } } @@ -581,9 +584,7 @@ final class MainContentCoordinator { ) // Release heavy data so memory drops even if SwiftUI delays deallocation - for tab in tabManager.tabs { - tab.rowBuffer.evict() - } + rowDataStore.tearDown() querySortCache.removeAll() cachedTableColumnTypes.removeAll() cachedTableColumnNames.removeAll() @@ -769,6 +770,7 @@ final class MainContentCoordinator { return } + tabManager.tabStructureVersion += 1 dispatchParameterizedStatements( paramStatements, parameters: reconciled, @@ -781,6 +783,7 @@ final class MainContentCoordinator { let statements = SQLStatementScanner.allStatements(in: sql) guard !statements.isEmpty else { return } + tabManager.tabStructureVersion += 1 dispatchStatements(statements, tabIndex: index) } @@ -1304,7 +1307,8 @@ final class MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - guard columnIndex >= 0 && columnIndex < tab.resultColumns.count else { return } + let buffer = rowDataStore.buffer(for: tab.id) + guard columnIndex >= 0 && columnIndex < buffer.columns.count else { return } var currentSort = tab.sortState let newDirection: SortDirection = ascending ? .ascending : .descending @@ -1332,7 +1336,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 = tab.resultColumns[columnIndex] + let columnName = buffer.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) @@ -1349,11 +1353,11 @@ final class MainContentCoordinator { tabManager.tabs[tabIndex].sortState = currentSort tabManager.tabs[tabIndex].hasUserInteraction = true tabManager.tabs[tabIndex].pagination.reset() - let rows = tab.resultRows + let rows = buffer.rows let tabId = tab.id - let resultVersion = tab.resultVersion + let schemaVersion = tab.schemaVersion let sortColumns = currentSort.columns - let colTypes = tab.columnTypes + let colTypes = buffer.columnTypes if rows.count > 1_000 { // Sort on background thread to avoid UI freeze @@ -1383,7 +1387,7 @@ final class MainContentCoordinator { sortedIndices: sortedIndices, columnIndex: sortColumns.first?.columnIndex ?? 0, direction: sortColumns.first?.direction ?? .ascending, - resultVersion: resultVersion + schemaVersion: schemaVersion ) var sortedTab = self.tabManager.tabs[idx] sortedTab.execution.isExecuting = false @@ -1392,13 +1396,12 @@ final class MainContentCoordinator { self.toolbarState.setExecuting(false) self.toolbarState.lastQueryDuration = sortDuration self.activeSortTasks.removeValue(forKey: tabId) - self.changeManager.reloadVersion += 1 + self.dataTabDelegate?.dataGridDidReplaceAllRows() } } activeSortTasks[tabId] = task } else { - // Small dataset: view sorts synchronously, just trigger reload - changeManager.reloadVersion += 1 + dataTabDelegate?.dataGridDidReplaceAllRows() } return } @@ -1406,7 +1409,7 @@ final class MainContentCoordinator { let tabId = tab.id let capturedSort = currentSort let capturedQuery = tab.content.query - let capturedColumns = tab.resultColumns + let capturedColumns = buffer.columns confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in guard let self, confirmed, let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 55dee0736..914fe7e13 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -180,7 +180,7 @@ struct MainContentView: View { isPresented: dismissBinding, mode: .queryResults( connection: connectionWithCurrentDatabase, - rowBuffer: tab.rowBuffer, + rowBuffer: coordinator.rowDataStore.buffer(for: tab.id), suggestedFileName: fileName ) ) @@ -242,10 +242,9 @@ struct MainContentView: View { } } .task(id: currentTab?.tableContext.tableName) { - // Only load metadata after the tab has executed at least once — - // avoids a redundant DB query racing with the initial data query guard currentTab?.execution.lastExecutedAt != nil else { return } await loadTableMetadataIfNeeded() + scheduleInspectorUpdate() } .onChange(of: inspectorTrigger) { scheduleInspectorUpdate() @@ -348,11 +347,12 @@ struct MainContentView: View { (viewWindow?.windowController as? TabWindowController)?.refreshUserActivity() handleTabSelectionChange(from: oldTabId, to: newTabId) } - .onChange(of: tabManager.tabs) { _, newTabs in - handleTabsChange(newTabs) + .onChange(of: tabManager.tabStructureVersion) { _, _ in + handleStructureChange() } - .onChange(of: currentTab?.resultColumns) { _, newColumns in - handleColumnsChange(newColumns: newColumns) + .onChange(of: currentTab?.schemaVersion) { _, _ in + let columns = currentTab.map { coordinator.rowDataStore.buffer(for: $0.id).columns } + handleColumnsChange(newColumns: columns) } .task { handleConnectionStatusChange() } .onReceive( diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index d6ab26d16..7100c6710 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -240,6 +240,30 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData cachedColumnCount = rowProvider.columns.count } + func applyInsertedRows(_ indices: IndexSet) { + guard let tableView else { return } + rebuildVisualStateCache() + updateCache() + tableView.insertRows(at: indices, withAnimation: .slideDown) + lastIdentity = nil + } + + func applyRemovedRows(_ indices: IndexSet) { + guard let tableView else { return } + rebuildVisualStateCache() + updateCache() + tableView.removeRows(at: indices, withAnimation: .slideUp) + lastIdentity = nil + } + + func applyFullReplace() { + guard let tableView else { return } + rebuildVisualStateCache() + updateCache() + tableView.reloadData() + lastIdentity = nil + } + func rebuildColumnMetadataCache() { var enumSet = Set() var fkSet = Set() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index b6932424a..f04b30c55 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -27,35 +27,29 @@ struct RowVisualState { /// Identity snapshot used to skip redundant updateNSView work when nothing has changed struct DataGridIdentity: Equatable { let reloadVersion: Int - let resultVersion: Int + let schemaVersion: Int let metadataVersion: Int let paginationVersion: Int let rowCount: Int let columnCount: Int let isEditable: Bool + let tabType: TabType? + let tableName: String? + let primaryKeyColumns: [String] let hiddenColumns: Set - init(reloadVersion: Int, resultVersion: Int, metadataVersion: Int, paginationVersion: Int, - rowCount: Int, columnCount: Int, isEditable: Bool, hiddenColumns: Set) { - self.reloadVersion = reloadVersion - self.resultVersion = resultVersion - self.metadataVersion = metadataVersion - self.paginationVersion = paginationVersion - self.rowCount = rowCount - self.columnCount = columnCount - self.isEditable = isEditable - self.hiddenColumns = hiddenColumns - } - - init(reloadVersion: Int, resultVersion: Int, metadataVersion: Int, paginationVersion: Int, + init(reloadVersion: Int, schemaVersion: Int, metadataVersion: Int, paginationVersion: Int, rowCount: Int, columnCount: Int, isEditable: Bool, configuration: DataGridConfiguration) { self.reloadVersion = reloadVersion - self.resultVersion = resultVersion + self.schemaVersion = schemaVersion self.metadataVersion = metadataVersion self.paginationVersion = paginationVersion self.rowCount = rowCount self.columnCount = columnCount self.isEditable = isEditable + self.tabType = configuration.tabType + self.tableName = configuration.tableName + self.primaryKeyColumns = configuration.primaryKeyColumns self.hiddenColumns = configuration.hiddenColumns } } @@ -64,7 +58,7 @@ struct DataGridIdentity: Equatable { struct DataGridView: NSViewRepresentable { let rowProvider: InMemoryRowProvider var changeManager: AnyChangeManager - var resultVersion: Int = 0 + var schemaVersion: Int = 0 var metadataVersion: Int = 0 var paginationVersion: Int = 0 let isEditable: Bool @@ -185,6 +179,7 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView context.coordinator.delegate = delegate + delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns context.coordinator.typePickerColumns = configuration.typePickerColumns context.coordinator.customDropdownOptions = configuration.customDropdownOptions @@ -241,7 +236,7 @@ struct DataGridView: NSViewRepresentable { // AppSettingsManager access on every SwiftUI re-evaluation. let currentIdentity = DataGridIdentity( reloadVersion: changeManager.reloadVersion, - resultVersion: resultVersion, + schemaVersion: schemaVersion, metadataVersion: metadataVersion, paginationVersion: paginationVersion, rowCount: rowProvider.totalRowCount, @@ -252,6 +247,7 @@ struct DataGridView: NSViewRepresentable { if currentIdentity == coordinator.lastIdentity { // Only refresh delegate reference — it may have changed between body evals coordinator.delegate = delegate + delegate?.dataGridAttach(tableViewCoordinator: coordinator) return } let previousIdentity = coordinator.lastIdentity @@ -308,6 +304,7 @@ struct DataGridView: NSViewRepresentable { coordinator.changeManager = changeManager coordinator.isEditable = isEditable coordinator.delegate = delegate + delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns coordinator.typePickerColumns = configuration.typePickerColumns coordinator.customDropdownOptions = configuration.customDropdownOptions diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 803939b2e..7ea875e49 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -29,6 +29,10 @@ protocol DataGridViewDelegate: AnyObject { func dataGridVisualState(forRow row: Int) -> RowVisualState? func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? func dataGridEmptySpaceMenu() -> NSMenu? + func dataGridDidInsertRows(at indices: IndexSet) + func dataGridDidRemoveRows(at indices: IndexSet) + func dataGridDidReplaceAllRows() + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) } extension DataGridViewDelegate { @@ -52,4 +56,8 @@ extension DataGridViewDelegate { func dataGridVisualState(forRow row: Int) -> RowVisualState? { nil } func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { nil } func dataGridEmptySpaceMenu() -> NSMenu? { nil } + func dataGridDidInsertRows(at indices: IndexSet) {} + func dataGridDidRemoveRows(at indices: IndexSet) {} + func dataGridDidReplaceAllRows() {} + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) {} } diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/RowDeltaApplying.swift new file mode 100644 index 000000000..2b3a225d8 --- /dev/null +++ b/TablePro/Views/Results/RowDeltaApplying.swift @@ -0,0 +1,10 @@ +import Foundation + +@MainActor +protocol RowDeltaApplying: AnyObject { + func applyInsertedRows(_ indices: IndexSet) + func applyRemovedRows(_ indices: IndexSet) + func applyFullReplace() +} + +extension TableViewCoordinator: RowDeltaApplying {} diff --git a/TablePro/Views/Results/RowProviderCache.swift b/TablePro/Views/Results/RowProviderCache.swift index 1f8fe7bf0..f97becb93 100644 --- a/TablePro/Views/Results/RowProviderCache.swift +++ b/TablePro/Views/Results/RowProviderCache.swift @@ -4,7 +4,7 @@ import Foundation final class RowProviderCache { private struct Entry { let provider: InMemoryRowProvider - let resultVersion: Int + let schemaVersion: Int let metadataVersion: Int let sortState: SortState } @@ -13,12 +13,12 @@ final class RowProviderCache { func provider( for tabId: UUID, - resultVersion: Int, + schemaVersion: Int, metadataVersion: Int, sortState: SortState ) -> InMemoryRowProvider? { guard let entry = entries[tabId], - entry.resultVersion == resultVersion, + entry.schemaVersion == schemaVersion, entry.metadataVersion == metadataVersion, entry.sortState == sortState else { @@ -30,13 +30,13 @@ final class RowProviderCache { func store( _ provider: InMemoryRowProvider, for tabId: UUID, - resultVersion: Int, + schemaVersion: Int, metadataVersion: Int, sortState: SortState ) { entries[tabId] = Entry( provider: provider, - resultVersion: resultVersion, + schemaVersion: schemaVersion, metadataVersion: metadataVersion, sortState: sortState ) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 2ede1713c..5bcd35fd4 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -271,7 +271,7 @@ struct TableStructureView: View { return DataGridView( rowProvider: provider.asInMemoryProvider(), changeManager: wrappedChangeManager, - resultVersion: displayVersion, + schemaVersion: displayVersion, isEditable: canEdit, configuration: DataGridConfiguration( dropdownColumns: allDropdownColumns, diff --git a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift index 51a6fae82..3a221296a 100644 --- a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift @@ -18,7 +18,7 @@ struct AnyChangeManagerTests { func dataManagerHasChangesForwards() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) #expect(wrapper.hasChanges == false) @@ -32,7 +32,7 @@ struct AnyChangeManagerTests { func dataManagerReloadVersionForwards() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) let initialVersion = wrapper.reloadVersion dataManager.reloadVersion += 1 @@ -44,7 +44,7 @@ struct AnyChangeManagerTests { func isRowDeletedDelegatesCorrectly() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) #expect(wrapper.isRowDeleted(0) == false) @@ -57,12 +57,12 @@ struct AnyChangeManagerTests { func recordCellChangeForwards() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) wrapper.recordCellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob", originalRow: ["1", "Alice"]) #expect(dataManager.hasChanges == true) - #expect(!wrapper.changes.isEmpty) + #expect(!wrapper.rowChanges.isEmpty) } @Test("No retain cycle — wrapper can be deallocated") @@ -73,7 +73,7 @@ struct AnyChangeManagerTests { weak var weakWrapper: AnyChangeManager? do { - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) weakWrapper = wrapper #expect(weakWrapper != nil) } @@ -86,7 +86,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: isRowDeleted always returns false") func structureManagerIsRowDeletedAlwaysFalse() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) #expect(wrapper.isRowDeleted(0) == false) #expect(wrapper.isRowDeleted(100) == false) @@ -95,7 +95,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: consumeChangedRowIndices returns empty set") func structureManagerConsumeChangedRowIndicesEmpty() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) let indices = wrapper.consumeChangedRowIndices() #expect(indices.isEmpty) @@ -104,7 +104,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: hasChanges forwards correctly when false") func structureManagerHasChangesForwardsFalse() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) #expect(wrapper.hasChanges == false) } @@ -112,7 +112,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: hasChanges forwards correctly when true") func structureManagerHasChangesForwardsTrue() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) structureManager.addNewColumn() @@ -122,7 +122,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: reloadVersion forwards correctly") func structureManagerReloadVersionForwards() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) let initialVersion = wrapper.reloadVersion structureManager.reloadVersion = 5 diff --git a/TableProTests/Core/Services/Query/RowDataStoreTests.swift b/TableProTests/Core/Services/Query/RowDataStoreTests.swift new file mode 100644 index 000000000..3a7883426 --- /dev/null +++ b/TableProTests/Core/Services/Query/RowDataStoreTests.swift @@ -0,0 +1,151 @@ +// +// 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/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index 025107eeb..af362c956 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -237,18 +237,69 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var rows = TestFixtures.makeRows(count: 5) - // Insert a row, then delete it — next selection should be valid _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) #expect(rows.count == 6) - let nextRow = manager.deleteSelectedRows( + let result = manager.deleteSelectedRows( selectedIndices: [5], resultRows: &rows ) - // After removing the last row, should select the new last row - #expect(nextRow >= 0) - #expect(nextRow < rows.count) + #expect(result.nextRowToSelect >= 0) + #expect(result.nextRowToSelect < rows.count) + } + + @Test("deleteSelectedRows returns empty physicallyRemovedIndices for empty selection") + func deleteSelectedRowsEmptySelection() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 3) + + let result = manager.deleteSelectedRows(selectedIndices: [], resultRows: &rows) + + #expect(result.physicallyRemovedIndices.isEmpty) + #expect(result.nextRowToSelect == -1) + #expect(rows.count == 3) + } + + @Test("deleteSelectedRows: deleting only existing rows leaves physicallyRemovedIndices empty") + func deleteSelectedRowsExistingOnly() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 5) + + let result = manager.deleteSelectedRows(selectedIndices: [1, 3], resultRows: &rows) + + #expect(result.physicallyRemovedIndices.isEmpty) + #expect(rows.count == 5) + } + + @Test("deleteSelectedRows: deleting only inserted rows reports each in physicallyRemovedIndices") + func deleteSelectedRowsInsertedOnly() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 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) + + let result = manager.deleteSelectedRows(selectedIndices: [2, 3, 4], resultRows: &rows) + + #expect(result.physicallyRemovedIndices == [4, 3, 2]) + #expect(rows.count == 2) + } + + @Test("deleteSelectedRows: mixed inserted and existing rows reports only inserted indices") + func deleteSelectedRowsMixed() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 3) + + _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) + #expect(rows.count == 4) + + let result = manager.deleteSelectedRows(selectedIndices: [0, 3], resultRows: &rows) + + #expect(result.physicallyRemovedIndices == [3]) + #expect(rows.count == 3) } // MARK: - Integration Tests diff --git a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift index 84d77c372..7d267b654 100644 --- a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift +++ b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift @@ -127,31 +127,6 @@ struct TabPersistenceCoordinatorTests { await sleep() } - @Test("saveLastQuery + loadLastQuery round-trip") - func saveAndLoadLastQueryRoundTrip() async { - let coordinator = makeCoordinator() - let query = "SELECT * FROM products WHERE active = 1" - - coordinator.saveLastQuery(query) - await sleep() - - let loaded = await coordinator.loadLastQuery() - - #expect(loaded == query) - - coordinator.clearSavedState() - await sleep() - } - - @Test("loadLastQuery returns nil when nothing saved") - func loadLastQueryReturnsNilWhenEmpty() async { - let coordinator = makeCoordinator() - - let loaded = await coordinator.loadLastQuery() - - #expect(loaded == nil) - } - @Test("Large query over 500KB is truncated to empty string in persisted tab") func largeQueryIsTruncated() async { let coordinator = makeCoordinator() diff --git a/TableProTests/Core/Storage/TabDiskActorTests.swift b/TableProTests/Core/Storage/TabDiskActorTests.swift index 70208a10c..8310df007 100644 --- a/TableProTests/Core/Storage/TabDiskActorTests.swift +++ b/TableProTests/Core/Storage/TabDiskActorTests.swift @@ -153,75 +153,6 @@ struct TabDiskActorTests { await actor.clear(connectionId: connectionId) } - // MARK: - saveLastQuery / loadLastQuery round-trip - - @Test("saveLastQuery then loadLastQuery round-trips") - func lastQueryRoundTrip() async throws { - let connectionId = UUID() - let query = "SELECT * FROM products WHERE active = true" - - await actor.saveLastQuery(query, for: connectionId) - let loaded = await actor.loadLastQuery(for: connectionId) - - #expect(loaded == query) - - await actor.saveLastQuery("", for: connectionId) - } - - // MARK: - loadLastQuery returns nil for unknown connectionId - - @Test("loadLastQuery returns nil for unknown connectionId") - func loadLastQueryReturnsNilForUnknown() async throws { - let result = await actor.loadLastQuery(for: UUID()) - #expect(result == nil) - } - - // MARK: - saveLastQuery with empty string removes the file - - @Test("saveLastQuery with empty string removes the file") - func saveLastQueryEmptyRemovesFile() async throws { - let connectionId = UUID() - - await actor.saveLastQuery("SELECT 1", for: connectionId) - #expect(await actor.loadLastQuery(for: connectionId) != nil) - - await actor.saveLastQuery("", for: connectionId) - let result = await actor.loadLastQuery(for: connectionId) - #expect(result == nil) - } - - // MARK: - saveLastQuery with whitespace-only string removes the file - - @Test("saveLastQuery with whitespace-only string removes the file") - func saveLastQueryWhitespaceOnlyRemovesFile() async throws { - let connectionId = UUID() - - await actor.saveLastQuery("SELECT 1", for: connectionId) - await actor.saveLastQuery(" \n\t ", for: connectionId) - - let result = await actor.loadLastQuery(for: connectionId) - #expect(result == nil) - } - - // MARK: - saveLastQuery skips queries exceeding 500KB - - @Test("saveLastQuery skips queries exceeding 500KB") - func saveLastQuerySkipsLargeQueries() async throws { - let connectionId = UUID() - let smallQuery = "SELECT 1" - - await actor.saveLastQuery(smallQuery, for: connectionId) - #expect(await actor.loadLastQuery(for: connectionId) == smallQuery) - - let largeQuery = String(repeating: "A", count: 500_001) - await actor.saveLastQuery(largeQuery, for: connectionId) - - let result = await actor.loadLastQuery(for: connectionId) - #expect(result == smallQuery) - - await actor.saveLastQuery("", for: connectionId) - } - // MARK: - Tab with all fields round-trips @Test("Tab with all fields including isView and databaseName round-trips") diff --git a/TableProTests/Models/Query/TabStructureVersionTests.swift b/TableProTests/Models/Query/TabStructureVersionTests.swift new file mode 100644 index 000000000..c01323302 --- /dev/null +++ b/TableProTests/Models/Query/TabStructureVersionTests.swift @@ -0,0 +1,144 @@ +// +// TabStructureVersionTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("QueryTabManager.tabStructureVersion") +@MainActor +struct TabStructureVersionTests { + + @Test("New manager starts at version 0") + func initialVersionIsZero() { + let manager = QueryTabManager() + #expect(manager.tabStructureVersion == 0) + } + + @Test("addTab(...) bumps the version once") + func addTabBumpsOnce() { + let manager = QueryTabManager() + let before = manager.tabStructureVersion + + manager.addTab(initialQuery: "SELECT 1", title: "Q") + + #expect(manager.tabStructureVersion == before + 1) + } + + @Test("addTableTab(...) for a new table bumps once; activating an existing table does NOT bump") + func addTableTabBumpsOnceAndIdempotent() { + let manager = QueryTabManager() + + manager.addTableTab(tableName: "users") + let afterFirstAdd = manager.tabStructureVersion + #expect(afterFirstAdd == 1) + + manager.addTableTab(tableName: "users") + + #expect(manager.tabStructureVersion == afterFirstAdd) + } + + @Test("addTerminalTab(...) for a new tab bumps once; activating existing terminal does NOT bump") + func addTerminalTabBumpsOnceAndIdempotent() { + let manager = QueryTabManager() + + manager.addTerminalTab() + let afterFirstAdd = manager.tabStructureVersion + #expect(afterFirstAdd == 1) + + manager.addTerminalTab() + + #expect(manager.tabStructureVersion == afterFirstAdd) + } + + @Test("addServerDashboardTab() for a new tab bumps once; activating existing does NOT bump") + func addServerDashboardBumpsOnceAndIdempotent() { + let manager = QueryTabManager() + + manager.addServerDashboardTab() + let afterFirstAdd = manager.tabStructureVersion + #expect(afterFirstAdd == 1) + + manager.addServerDashboardTab() + + #expect(manager.tabStructureVersion == afterFirstAdd) + } + + @Test("replaceTabContent(...) bumps the version (in-place mutation, same id)") + func replaceTabContentBumps() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + let beforeReplace = manager.tabStructureVersion + + let didReplace = manager.replaceTabContent(tableName: "orders") + + #expect(didReplace) + #expect(manager.tabStructureVersion == beforeReplace + 1) + } + + @Test("markTabRenamed bumps when the tab id exists; no-op when it does not") + func markTabRenamedBumpsOnlyForKnownIds() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + let knownId = manager.tabs[0].id + let before = manager.tabStructureVersion + + manager.markTabRenamed(knownId) + #expect(manager.tabStructureVersion == before + 1) + + let unknownVersion = manager.tabStructureVersion + manager.markTabRenamed(UUID()) + #expect(manager.tabStructureVersion == unknownVersion) + } + + @Test("updateTab(...) does NOT bump the version (content-only update)") + func updateTabDoesNotBump() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + var tab = manager.tabs[0] + let before = manager.tabStructureVersion + + tab.content.query = "SELECT 99" + manager.updateTab(tab) + + #expect(manager.tabStructureVersion == before) + } + + @Test("Mutating a tab's content directly via tabs[i] does NOT bump (id array unchanged)") + func directContentMutationDoesNotBump() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + let before = manager.tabStructureVersion + + manager.tabs[0].content.query = "SELECT * FROM users WHERE id = 1" + + #expect(manager.tabStructureVersion == before) + } + + @Test("Removing a tab via tabs.remove(at:) bumps via the didSet") + func tabsRemovalBumps() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + manager.addTableTab(tableName: "orders") + let before = manager.tabStructureVersion + + manager.tabs.remove(at: 0) + + #expect(manager.tabStructureVersion == before + 1) + } + + @Test("Drag-reordering tabs (id array reordered) bumps via the didSet") + func tabsReorderBumps() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + manager.addTableTab(tableName: "orders") + manager.addTableTab(tableName: "products") + let before = manager.tabStructureVersion + + manager.tabs.swapAt(0, 2) + + #expect(manager.tabStructureVersion == before + 1) + } +} diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift new file mode 100644 index 000000000..3fd071b56 --- /dev/null +++ b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift @@ -0,0 +1,86 @@ +// +// DataTabGridDelegateTests.swift +// TableProTests +// + +import AppKit +import Foundation +import Testing +@testable import TablePro + +@MainActor +private final class FakeRowDeltaApplier: RowDeltaApplying { + var insertedCalls: [IndexSet] = [] + var removedCalls: [IndexSet] = [] + var fullReplaceCount: Int = 0 + + func applyInsertedRows(_ indices: IndexSet) { + insertedCalls.append(indices) + } + + func applyRemovedRows(_ indices: IndexSet) { + removedCalls.append(indices) + } + + func applyFullReplace() { + fullReplaceCount += 1 + } +} + +@Suite("DataTabGridDelegate row-delta forwarding") +@MainActor +struct DataTabGridDelegateTests { + + @Test("dataGridDidInsertRows(at:) forwards the IndexSet to applyInsertedRows") + func insertForwardsIndices() { + let delegate = DataTabGridDelegate() + let applier = FakeRowDeltaApplier() + delegate.tableViewCoordinator = applier + + let indices = IndexSet([1, 3, 5]) + delegate.dataGridDidInsertRows(at: indices) + + #expect(applier.insertedCalls.count == 1) + #expect(applier.insertedCalls.first == indices) + #expect(applier.removedCalls.isEmpty) + #expect(applier.fullReplaceCount == 0) + } + + @Test("dataGridDidRemoveRows(at:) forwards the IndexSet to applyRemovedRows") + func removeForwardsIndices() { + let delegate = DataTabGridDelegate() + let applier = FakeRowDeltaApplier() + delegate.tableViewCoordinator = applier + + let indices = IndexSet(integersIn: 4..<7) + delegate.dataGridDidRemoveRows(at: indices) + + #expect(applier.removedCalls.count == 1) + #expect(applier.removedCalls.first == indices) + #expect(applier.insertedCalls.isEmpty) + #expect(applier.fullReplaceCount == 0) + } + + @Test("dataGridDidReplaceAllRows() forwards to applyFullReplace") + func fullReplaceForwards() { + let delegate = DataTabGridDelegate() + let applier = FakeRowDeltaApplier() + delegate.tableViewCoordinator = applier + + delegate.dataGridDidReplaceAllRows() + + #expect(applier.fullReplaceCount == 1) + #expect(applier.insertedCalls.isEmpty) + #expect(applier.removedCalls.isEmpty) + } + + @Test("Calls are no-ops when tableViewCoordinator is nil") + func nilCoordinatorIsNoOp() { + let delegate = DataTabGridDelegate() + #expect(delegate.tableViewCoordinator == nil) + + delegate.dataGridDidInsertRows(at: IndexSet([0])) + delegate.dataGridDidRemoveRows(at: IndexSet([0])) + delegate.dataGridDidReplaceAllRows() + } +} diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index 14332b4b6..41e048da8 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -20,7 +20,6 @@ struct CommandActionsDispatchTests { let state = SessionStateFactory.create(connection: connection, payload: nil) let coordinator = state.coordinator - var selectedRowIndices: Set = [] var selectedTables: Set = [] var pendingTruncates: Set = [] var pendingDeletes: Set = [] @@ -32,7 +31,7 @@ struct CommandActionsDispatchTests { coordinator: coordinator, filterStateManager: state.filterStateManager, connection: connection, - selectedRowIndices: Binding(get: { selectedRowIndices }, set: { selectedRowIndices = $0 }), + selectionState: coordinator.selectionState, selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 5c778c5f0..4da3408fb 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -29,85 +29,90 @@ struct EvictionTests { return (coordinator, tabManager) } - private func addLoadedTab(to tabManager: QueryTabManager, tableName: String = "users") { + private func addLoadedTab( + to coordinator: MainContentCoordinator, + tabManager: QueryTabManager, + tableName: String = "users" + ) { tabManager.addTableTab(tableName: tableName) guard let index = tabManager.selectedTabIndex else { return } let rows = TestFixtures.makeRows(count: 10) - tabManager.tabs[index].rowBuffer.rows = rows - tabManager.tabs[index].rowBuffer.columns = ["id", "name", "email"] + let tabId = tabManager.tabs[index].id + let buffer = coordinator.rowDataStore.buffer(for: tabId) + buffer.rows = rows + buffer.columns = ["id", "name", "email"] tabManager.tabs[index].execution.lastExecutedAt = Date() } @Test("evictInactiveRowData evicts loaded tabs without pending changes") func evictsLoadedTabs() { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") + let tabId = tabManager.tabs[0].id + let buffer = coordinator.rowDataStore.buffer(for: tabId) - #expect(tabManager.tabs[0].resultRows.count == 10) - #expect(tabManager.tabs[0].rowBuffer.isEvicted == false) + #expect(buffer.rows.count == 10) + #expect(buffer.isEvicted == false) coordinator.evictInactiveRowData() - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) - #expect(tabManager.tabs[0].resultRows.isEmpty) + #expect(buffer.isEvicted == true) + #expect(buffer.rows.isEmpty) } @Test("evictInactiveRowData skips tabs with pending changes") func skipsTabsWithPendingChanges() { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - // Add a pending change tabManager.tabs[0].pendingChanges.deletedRowIndices = [0] coordinator.evictInactiveRowData() - // Should NOT be evicted because it has pending changes - #expect(tabManager.tabs[0].rowBuffer.isEvicted == false) - #expect(tabManager.tabs[0].resultRows.count == 10) + 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: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - // Pre-evict - tabManager.tabs[0].rowBuffer.evict() - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) + let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) + buffer.evict() + #expect(buffer.isEvicted == true) - // Should not crash or change state coordinator.evictInactiveRowData() - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) + #expect(buffer.isEvicted == true) } @Test("evictInactiveRowData skips tabs with empty results") func skipsEmptyResults() { let (coordinator, tabManager) = makeCoordinator() tabManager.addTableTab(tableName: "empty_table") - // Don't add any rows — resultRows is empty coordinator.evictInactiveRowData() - // Should not evict (nothing to evict) - #expect(tabManager.tabs[0].rowBuffer.isEvicted == false) + let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) + #expect(buffer.isEvicted == false) } @Test("evictInactiveRowData preserves column metadata after eviction") func preservesMetadataAfterEviction() { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") coordinator.evictInactiveRowData() - #expect(tabManager.tabs[0].rowBuffer.columns == ["id", "name", "email"]) - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) + let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) + #expect(buffer.columns == ["id", "name", "email"]) + #expect(buffer.isEvicted == true) } @Test("evictInactiveRowData with no tabs is no-op") func noTabsIsNoOp() { let (coordinator, _) = makeCoordinator() - // No tabs added — should not crash coordinator.evictInactiveRowData() } } diff --git a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift index 34e32a837..42b1fd9c3 100644 --- a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift @@ -12,12 +12,12 @@ import Testing @Suite("MainStatusBarView Layout") @MainActor struct MainStatusBarLayoutTests { - @Test("Status bar can be instantiated with nil tab") - func instantiateWithNilTab() { + @Test("Status bar can be instantiated with empty snapshot") + func instantiateWithEmptySnapshot() { let filterManager = FilterStateManager() let colVisManager = ColumnVisibilityManager() let view = MainStatusBarView( - tab: nil, + snapshot: StatusBarSnapshot(tab: nil, buffer: nil), filterStateManager: filterManager, columnVisibilityManager: colVisManager, allColumns: [], @@ -31,7 +31,6 @@ struct MainStatusBarLayoutTests { onOffsetChange: { _ in }, onPaginationGo: {} ) - // Smoke test: view constructs without error #expect(type(of: view.body) != Never.self) } } diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index 4bef58523..bfba6774c 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -261,20 +261,19 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var selectedRows: Set = [] var editingCell: CellPosition? - coordinator.addNewRow(selectedRowIndices: &selectedRows, editingCell: &editingCell) - #expect(selectedRows.isEmpty) + coordinator.addNewRow(editingCell: &editingCell) + #expect(coordinator.selectionState.indices.isEmpty) #expect(editingCell == nil) - selectedRows = [0] - coordinator.deleteSelectedRows(indices: Set([0]), selectedRowIndices: &selectedRows) - #expect(selectedRows == [0]) + coordinator.selectionState.indices = [0] + coordinator.deleteSelectedRows(indices: Set([0])) + #expect(coordinator.selectionState.indices == [0]) - selectedRows = [] - coordinator.duplicateSelectedRow(index: 0, selectedRowIndices: &selectedRows, editingCell: &editingCell) - #expect(selectedRows.isEmpty) + coordinator.selectionState.indices = [] + coordinator.duplicateSelectedRow(index: 0, editingCell: &editingCell) + #expect(coordinator.selectionState.indices.isEmpty) #expect(editingCell == nil) } @@ -287,11 +286,9 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var selectedRows: Set = [] var editingCell: CellPosition? - // Alert level doesn't block row staging — only gates at execution time - coordinator.addNewRow(selectedRowIndices: &selectedRows, editingCell: &editingCell) + coordinator.addNewRow(editingCell: &editingCell) #expect(tabManager.tabs.first?.execution.errorMessage == nil) } } diff --git a/TableProTests/Views/Main/TabEvictionTests.swift b/TableProTests/Views/Main/TabEvictionTests.swift index 2efb19cba..9ddb7fbb3 100644 --- a/TableProTests/Views/Main/TabEvictionTests.swift +++ b/TableProTests/Views/Main/TabEvictionTests.swift @@ -11,6 +11,7 @@ import Testing @testable import TablePro @Suite("Tab Eviction") +@MainActor struct TabEvictionTests { // MARK: - Helpers @@ -19,35 +20,44 @@ struct TabEvictionTests { (0.. QueryTab { + ) -> TestTab { var tab = QueryTab(id: id, title: "Test", query: "SELECT 1", tabType: tabType) tab.execution.lastExecutedAt = lastExecutedAt + let buffer: RowBuffer if rowCount > 0 { - let rows = makeTestRows(count: rowCount) - tab.rowBuffer = RowBuffer( - rows: rows, + buffer = RowBuffer( + rows: makeTestRows(count: rowCount), columns: ["col1"], columnTypes: [.text(rawType: "VARCHAR")] ) + } else { + buffer = RowBuffer() } + store.setBuffer(buffer, for: tab.id) if isEvicted { - tab.rowBuffer.evict() + buffer.evict() } if hasUnsavedChanges { tab.pendingChanges.deletedRowIndices = [0] } - return tab + return TestTab(tab: tab, buffer: buffer) } // MARK: - RowBuffer Eviction @@ -109,67 +119,72 @@ struct TabEvictionTests { @Test("Tabs with pending changes are excluded from eviction candidates") func tabsWithPendingChangesExcluded() { - let tab = makeTestTab( + let store = RowDataStore() + let entry = makeTestTab( + store: store, rowCount: 10, lastExecutedAt: Date(), hasUnsavedChanges: true ) - let isCandidate = !tab.rowBuffer.isEvicted - && !tab.resultRows.isEmpty - && tab.execution.lastExecutedAt != nil - && !tab.pendingChanges.hasChanges + 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 tabA = makeTestTab(id: activeId, rowCount: 10, lastExecutedAt: Date()) - let tabB = makeTestTab(rowCount: 10, lastExecutedAt: Date(), isEvicted: true) - let tabC = makeTestTab(rowCount: 0, lastExecutedAt: Date()) - let tabD = makeTestTab(rowCount: 10, lastExecutedAt: Date(), hasUnsavedChanges: true) - let tabE = makeTestTab(rowCount: 10, lastExecutedAt: Date()) + 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 allTabs = [tabA, tabB, tabC, tabD, tabE] - - let candidates = allTabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + 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?.id == tabE.id) + #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 tabs = (0..<5).map { i in + let entries = (0..<5).map { i in makeTestTab( + store: store, rowCount: 10, lastExecutedAt: now.addingTimeInterval(Double(i) * 60) ) } let activeTabIds: Set = [] - let candidates = tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + 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.execution.lastExecutedAt ?? .distantFuture) < ($1.execution.lastExecutedAt ?? .distantFuture) + ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) } let maxInactiveLoaded = 2 @@ -177,45 +192,47 @@ struct TabEvictionTests { #expect(toEvict.count == 3) - for tab in toEvict { - tab.rowBuffer.evict() + for entry in toEvict { + entry.buffer.evict() } - let evictedIds = Set(toEvict.map(\.id)) + let evictedIds = Set(toEvict.map(\.tab.id)) // The 2 newest (index 3 and 4) should NOT be evicted - #expect(!evictedIds.contains(tabs[3].id)) - #expect(!evictedIds.contains(tabs[4].id)) + #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(tabs[0].rowBuffer.isEvicted == true) - #expect(tabs[1].rowBuffer.isEvicted == true) - #expect(tabs[2].rowBuffer.isEvicted == true) - #expect(tabs[3].rowBuffer.isEvicted == false) - #expect(tabs[4].rowBuffer.isEvicted == false) + #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 tabs = (0..<2).map { i in + let entries = (0..<2).map { i in makeTestTab( + store: store, rowCount: 10, lastExecutedAt: now.addingTimeInterval(Double(i) * 60) ) } let activeTabIds: Set = [] - let candidates = tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + 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.execution.lastExecutedAt ?? .distantFuture) < ($1.execution.lastExecutedAt ?? .distantFuture) + ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) } let maxInactiveLoaded = 2 @@ -223,10 +240,9 @@ struct TabEvictionTests { #expect(shouldEvict == false) - // Verify no tabs were evicted - for tab in tabs { - #expect(tab.rowBuffer.isEvicted == false) - #expect(tab.resultRows.count == 10) + for entry in entries { + #expect(entry.buffer.isEvicted == false) + #expect(entry.buffer.rows.count == 10) } } } diff --git a/TableProTests/Views/Main/TriggerStructTests.swift b/TableProTests/Views/Main/TriggerStructTests.swift index cf35b4d7d..8c11fa59f 100644 --- a/TableProTests/Views/Main/TriggerStructTests.swift +++ b/TableProTests/Views/Main/TriggerStructTests.swift @@ -15,43 +15,43 @@ import Testing struct InspectorTriggerTests { @Test("Same values are equal") func sameValuesAreEqual() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) #expect(a == b) } @Test("Both nil fields are equal") func bothNilFieldsAreEqual() { - let a = InspectorTrigger(tableName: nil, resultVersion: 0, metadataVersion: 0, metadataTableName: nil) - let b = InspectorTrigger(tableName: nil, resultVersion: 0, metadataVersion: 0, metadataTableName: nil) + let a = InspectorTrigger(tableName: nil, schemaVersion: 0, metadataVersion: 0) + let b = InspectorTrigger(tableName: nil, schemaVersion: 0, metadataVersion: 0) #expect(a == b) } @Test("Different tableName produces unequal triggers") func differentTableName() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "orders", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "orders", schemaVersion: 1, metadataVersion: 0) #expect(a != b) } @Test("nil vs non-nil tableName produces unequal triggers") func nilVsNonNilTableName() { - let a = InspectorTrigger(tableName: nil, resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") + let a = InspectorTrigger(tableName: nil, schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) #expect(a != b) } - @Test("Different resultVersion produces unequal triggers") - func differentResultVersion() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 2, metadataVersion: 0, metadataTableName: "users") + @Test("Different schemaVersion produces unequal triggers") + func differentSchemaVersion() { + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 2, metadataVersion: 0) #expect(a != b) } - @Test("Different metadataTableName produces unequal triggers") - func differentMetadataTableName() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "orders") + @Test("Different metadataVersion produces unequal triggers") + func differentMetadataVersion() { + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 1) #expect(a != b) } } diff --git a/TableProTests/Views/Results/DataGridIdentityTests.swift b/TableProTests/Views/Results/DataGridIdentityTests.swift index 384f3adfb..d57044fdb 100644 --- a/TableProTests/Views/Results/DataGridIdentityTests.swift +++ b/TableProTests/Views/Results/DataGridIdentityTests.swift @@ -11,66 +11,98 @@ import Testing @Suite("DataGridIdentity") struct DataGridIdentityTests { + private func makeIdentity( + reloadVersion: Int = 1, + schemaVersion: Int = 2, + metadataVersion: Int = 3, + paginationVersion: Int = 0, + rowCount: Int = 100, + columnCount: Int = 5, + isEditable: Bool = true, + tabType: TabType? = .table, + tableName: String? = "users", + primaryKeyColumns: [String] = ["id"], + hiddenColumns: Set = [] + ) -> DataGridIdentity { + var config = DataGridConfiguration() + config.tabType = tabType + config.tableName = tableName + config.primaryKeyColumns = primaryKeyColumns + config.hiddenColumns = hiddenColumns + return DataGridIdentity( + reloadVersion: reloadVersion, + schemaVersion: schemaVersion, + metadataVersion: metadataVersion, + paginationVersion: paginationVersion, + rowCount: rowCount, + columnCount: columnCount, + isEditable: isEditable, + configuration: config + ) + } + @Test("Same values produce equal identities") func sameValuesAreEqual() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a == b) + #expect(makeIdentity() == makeIdentity()) } @Test("Different reloadVersion produces unequal identities") func differentReloadVersion() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 2, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(reloadVersion: 1) != makeIdentity(reloadVersion: 2)) } - @Test("Different resultVersion produces unequal identities") - func differentResultVersion() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 3, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + @Test("Different schemaVersion produces unequal identities") + func differentSchemaVersion() { + #expect(makeIdentity(schemaVersion: 2) != makeIdentity(schemaVersion: 3)) } @Test("Different metadataVersion produces unequal identities") func differentMetadataVersion() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 4, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(metadataVersion: 3) != makeIdentity(metadataVersion: 4)) + } + + @Test("Different paginationVersion produces unequal identities") + func differentPaginationVersion() { + #expect(makeIdentity(paginationVersion: 0) != makeIdentity(paginationVersion: 1)) } @Test("Different rowCount produces unequal identities") func differentRowCount() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 200, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(rowCount: 100) != makeIdentity(rowCount: 200)) } @Test("Different columnCount produces unequal identities") func differentColumnCount() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 10, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(columnCount: 5) != makeIdentity(columnCount: 10)) } @Test("Different isEditable produces unequal identities") func differentIsEditable() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: false, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(isEditable: true) != makeIdentity(isEditable: false)) + } + + @Test("Different tabType produces unequal identities") + func differentTabType() { + #expect(makeIdentity(tabType: .table) != makeIdentity(tabType: .query)) + } + + @Test("Different tableName produces unequal identities") + func differentTableName() { + #expect(makeIdentity(tableName: "users") != makeIdentity(tableName: "orders")) + } + + @Test("Different primaryKeyColumns produces unequal identities") + func differentPrimaryKeyColumns() { + #expect(makeIdentity(primaryKeyColumns: ["id"]) != makeIdentity(primaryKeyColumns: ["uuid"])) } @Test("Different hiddenColumns produces unequal identities") func differentHiddenColumns() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: ["name"]) - #expect(a != b) + #expect(makeIdentity(hiddenColumns: []) != makeIdentity(hiddenColumns: ["name"])) } @Test("Same hiddenColumns produces equal identities") func sameHiddenColumns() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: ["name", "email"]) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: ["name", "email"]) - #expect(a == b) + #expect(makeIdentity(hiddenColumns: ["name", "email"]) == makeIdentity(hiddenColumns: ["name", "email"])) } } diff --git a/TableProTests/Views/Results/RowProviderCacheTests.swift b/TableProTests/Views/Results/RowProviderCacheTests.swift new file mode 100644 index 000000000..8f57eb913 --- /dev/null +++ b/TableProTests/Views/Results/RowProviderCacheTests.swift @@ -0,0 +1,135 @@ +// +// 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) + } +}