From da111ec86443790b21c4c29458317748801511a6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:38:16 +0700 Subject: [PATCH 01/31] refactor(datagrid): introduce TableRowsStore alongside RowDataStore --- .../Core/Services/Query/TableRowsStore.swift | 65 ++++++ .../Views/Main/MainContentCoordinator.swift | 2 + .../Services/Query/TableRowsStoreTests.swift | 212 ++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 TablePro/Core/Services/Query/TableRowsStore.swift create mode 100644 TableProTests/Core/Services/Query/TableRowsStoreTests.swift diff --git a/TablePro/Core/Services/Query/TableRowsStore.swift b/TablePro/Core/Services/Query/TableRowsStore.swift new file mode 100644 index 000000000..94be10cf5 --- /dev/null +++ b/TablePro/Core/Services/Query/TableRowsStore.swift @@ -0,0 +1,65 @@ +import Foundation + +@MainActor +@Observable +final class TableRowsStore { + @ObservationIgnored private var store: [UUID: TableRows] = [:] + @ObservationIgnored private var evictedSet: Set = [] + + func tableRows(for tabId: UUID) -> TableRows { + if let existing = store[tabId] { + return existing + } + let rows = TableRows() + store[tabId] = rows + return rows + } + + func existingTableRows(for tabId: UUID) -> TableRows? { + store[tabId] + } + + func setTableRows(_ rows: TableRows, for tabId: UUID) { + store[tabId] = rows + evictedSet.remove(tabId) + } + + func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Void) { + var rows = store[tabId] ?? TableRows() + mutate(&rows) + store[tabId] = rows + evictedSet.remove(tabId) + } + + func removeTableRows(for tabId: UUID) { + store.removeValue(forKey: tabId) + evictedSet.remove(tabId) + } + + func isEvicted(_ tabId: UUID) -> Bool { + evictedSet.contains(tabId) + } + + func evict(for tabId: UUID) { + guard var rows = store[tabId] else { return } + guard !rows.rows.isEmpty else { return } + rows.rows = [] + store[tabId] = rows + evictedSet.insert(tabId) + } + + func evictAll(except activeTabId: UUID?) { + for (id, rows) in store where id != activeTabId { + guard !rows.rows.isEmpty, !evictedSet.contains(id) else { continue } + var copy = rows + copy.rows = [] + store[id] = copy + evictedSet.insert(id) + } + } + + func tearDown() { + store.removeAll() + evictedSet.removeAll() + } +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index ee1dd418f..83fa7b10a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -88,6 +88,7 @@ final class MainContentCoordinator { let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState let rowDataStore = RowDataStore() + let tableRowsStore = TableRowsStore() // MARK: - Services @@ -587,6 +588,7 @@ final class MainContentCoordinator { // Release heavy data so memory drops even if SwiftUI delays deallocation rowDataStore.tearDown() + tableRowsStore.tearDown() querySortCache.removeAll() cachedTableColumnTypes.removeAll() cachedTableColumnNames.removeAll() diff --git a/TableProTests/Core/Services/Query/TableRowsStoreTests.swift b/TableProTests/Core/Services/Query/TableRowsStoreTests.swift new file mode 100644 index 000000000..2cced79cf --- /dev/null +++ b/TableProTests/Core/Services/Query/TableRowsStoreTests.swift @@ -0,0 +1,212 @@ +import Foundation +import Testing +@testable import TablePro + +@Suite("TableRowsStore") +@MainActor +struct TableRowsStoreTests { + + @Test("tableRows(for:) creates empty TableRows on first access and returns the same on second") + func tableRowsCreatesAndReturnsSameValue() { + let store = TableRowsStore() + let tabId = UUID() + + let first = store.tableRows(for: tabId) + #expect(first.rows.isEmpty) + #expect(first.columns.isEmpty) + #expect(store.isEvicted(tabId) == false) + + let second = store.tableRows(for: tabId) + #expect(second.rows.count == first.rows.count) + #expect(second.columns == first.columns) + } + + @Test("setTableRows(_:for:) replaces stored value") + func setTableRowsReplacesEntry() { + let store = TableRowsStore() + let tabId = UUID() + + _ = store.tableRows(for: tabId) + let replacement = TableRows.from( + queryRows: [["a"]], + columns: ["c"], + columnTypes: [.text(rawType: nil)] + ) + store.setTableRows(replacement, for: tabId) + + let resolved = store.tableRows(for: tabId) + #expect(resolved.rows.count == 1) + #expect(resolved.columns == ["c"]) + } + + @Test("existingTableRows(for:) returns nil before set and value after") + func existingTableRowsReflectsState() { + let store = TableRowsStore() + let tabId = UUID() + + #expect(store.existingTableRows(for: tabId) == nil) + + let rows = TableRows.from( + queryRows: [["x"]], + columns: ["c"], + columnTypes: [.text(rawType: nil)] + ) + store.setTableRows(rows, for: tabId) + + let resolved = store.existingTableRows(for: tabId) + #expect(resolved != nil) + #expect(resolved?.rows.count == 1) + } + + @Test("removeTableRows(for:) deletes the entry and clears evicted state") + func removeTableRowsDeletes() { + let store = TableRowsStore() + let tabId = UUID() + + store.setTableRows( + TableRows.from(queryRows: [["x"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + store.evict(for: tabId) + #expect(store.isEvicted(tabId) == true) + + store.removeTableRows(for: tabId) + #expect(store.existingTableRows(for: tabId) == nil) + #expect(store.isEvicted(tabId) == false) + } + + @Test("evict(for:) clears rows and marks evicted while preserving columns") + func evictMarksEvicted() { + let store = TableRowsStore() + let tabId = UUID() + let rows = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c"], + columnTypes: [.text(rawType: nil)] + ) + store.setTableRows(rows, for: tabId) + + #expect(store.isEvicted(tabId) == false) + store.evict(for: tabId) + + #expect(store.isEvicted(tabId) == true) + let evicted = store.existingTableRows(for: tabId) + #expect(evicted?.rows.isEmpty == true) + #expect(evicted?.columns == ["c"]) + } + + @Test("evict(for:) is no-op for unknown tab") + func evictUnknownTabIsNoOp() { + let store = TableRowsStore() + store.evict(for: UUID()) + } + + @Test("evictAll(except:) evicts other tabs and spares the active one") + func evictAllSparesActive() { + let store = TableRowsStore() + let activeId = UUID() + let otherId1 = UUID() + let otherId2 = UUID() + + let active = TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]) + let other1 = TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]) + let other2 = TableRows.from(queryRows: [["d"]], columns: ["c"], columnTypes: [.text(rawType: nil)]) + + store.setTableRows(active, for: activeId) + store.setTableRows(other1, for: otherId1) + store.setTableRows(other2, for: otherId2) + + store.evictAll(except: activeId) + + #expect(store.isEvicted(activeId) == false) + #expect(store.existingTableRows(for: activeId)?.rows.count == 1) + #expect(store.isEvicted(otherId1) == true) + #expect(store.existingTableRows(for: otherId1)?.rows.isEmpty == true) + #expect(store.isEvicted(otherId2) == true) + } + + @Test("evictAll(except: nil) evicts every loaded tab") + func evictAllNoActiveEvictsAll() { + let store = TableRowsStore() + let id1 = UUID() + let id2 = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id1 + ) + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id2 + ) + + store.evictAll(except: nil) + + #expect(store.isEvicted(id1) == true) + #expect(store.isEvicted(id2) == true) + } + + @Test("evictAll(except:) skips empty tables") + func evictAllSkipsEmpty() { + let store = TableRowsStore() + let tabId = UUID() + store.setTableRows(TableRows(), for: tabId) + + store.evictAll(except: nil) + #expect(store.isEvicted(tabId) == false) + } + + @Test("setTableRows clears evicted flag") + func setClearsEvicted() { + let store = TableRowsStore() + let tabId = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + store.evict(for: tabId) + #expect(store.isEvicted(tabId) == true) + + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + #expect(store.isEvicted(tabId) == false) + } + + @Test("updateTableRows applies mutation in place") + func updateTableRowsAppliesMutation() { + let store = TableRowsStore() + let tabId = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + + store.updateTableRows(for: tabId) { rows in + _ = rows.edit(row: 0, column: 0, value: "z") + } + + let resolved = store.existingTableRows(for: tabId) + #expect(resolved?.value(at: 0, column: 0) == "z") + } + + @Test("tearDown() clears the store") + func tearDownClearsAll() { + let store = TableRowsStore() + let id1 = UUID() + let id2 = UUID() + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id1 + ) + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: id2 + ) + + store.tearDown() + + #expect(store.existingTableRows(for: id1) == nil) + #expect(store.existingTableRows(for: id2) == nil) + } +} From 3e98e3c9957be9a86b28857ef5435f1c0a5933d9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:40:50 +0700 Subject: [PATCH 02/31] refactor(datagrid): introduce TableRowsController to drive NSTableView from Delta --- .../Views/Results/TableRowsController.swift | 54 ++++++ .../Results/TableRowsControllerTests.swift | 155 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 TablePro/Views/Results/TableRowsController.swift create mode 100644 TableProTests/Views/Results/TableRowsControllerTests.swift diff --git a/TablePro/Views/Results/TableRowsController.swift b/TablePro/Views/Results/TableRowsController.swift new file mode 100644 index 000000000..60bc48d7a --- /dev/null +++ b/TablePro/Views/Results/TableRowsController.swift @@ -0,0 +1,54 @@ +import AppKit +import Foundation + +@MainActor +final class TableRowsController { + weak var tableView: NSTableView? + + var insertAnimation: NSTableView.AnimationOptions = .slideDown + var removeAnimation: NSTableView.AnimationOptions = .slideUp + + init(tableView: NSTableView? = nil) { + self.tableView = tableView + } + + func attach(_ tableView: NSTableView) { + self.tableView = tableView + } + + func detach() { + tableView = nil + } + + func apply(_ delta: Delta) { + guard let tableView else { return } + switch delta { + case .cellChanged(let row, let column): + guard row >= 0, row < tableView.numberOfRows else { return } + guard column >= 0, column < tableView.numberOfColumns else { return } + tableView.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column)) + case .cellsChanged(let positions): + guard !positions.isEmpty else { return } + var rowSet = IndexSet() + var colSet = IndexSet() + for position in positions { + if position.row >= 0, position.row < tableView.numberOfRows { + rowSet.insert(position.row) + } + if position.column >= 0, position.column < tableView.numberOfColumns { + colSet.insert(position.column) + } + } + guard !rowSet.isEmpty, !colSet.isEmpty else { return } + tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) + case .rowsInserted(let indices): + guard !indices.isEmpty else { return } + tableView.insertRows(at: indices, withAnimation: insertAnimation) + case .rowsRemoved(let indices): + guard !indices.isEmpty else { return } + tableView.removeRows(at: indices, withAnimation: removeAnimation) + case .columnsReplaced, .fullReplace: + tableView.reloadData() + } + } +} diff --git a/TableProTests/Views/Results/TableRowsControllerTests.swift b/TableProTests/Views/Results/TableRowsControllerTests.swift new file mode 100644 index 000000000..d638aacde --- /dev/null +++ b/TableProTests/Views/Results/TableRowsControllerTests.swift @@ -0,0 +1,155 @@ +import AppKit +import Foundation +import Testing +@testable import TablePro + +@Suite("TableRowsController") +@MainActor +struct TableRowsControllerTests { + + final class RecordingTableView: NSTableView { + struct Reload { + let rows: IndexSet + let columns: IndexSet + } + + var insertCalls: [(IndexSet, NSTableView.AnimationOptions)] = [] + var removeCalls: [(IndexSet, NSTableView.AnimationOptions)] = [] + var rangeReloadCalls: [Reload] = [] + var fullReloadCount = 0 + var stubbedRowCount = 0 + + override var numberOfRows: Int { stubbedRowCount } + + override func insertRows(at indexes: IndexSet, withAnimation animationOptions: NSTableView.AnimationOptions = []) { + insertCalls.append((indexes, animationOptions)) + } + + override func removeRows(at indexes: IndexSet, withAnimation animationOptions: NSTableView.AnimationOptions = []) { + removeCalls.append((indexes, animationOptions)) + } + + override func reloadData(forRowIndexes rowIndexes: IndexSet, columnIndexes: IndexSet) { + rangeReloadCalls.append(Reload(rows: rowIndexes, columns: columnIndexes)) + } + + override func reloadData() { + fullReloadCount += 1 + } + } + + private func makeTableView(rows: Int, columns: Int) -> RecordingTableView { + let view = RecordingTableView(frame: .zero) + for index in 0.. = [ + CellPosition(row: 0, column: 0), + CellPosition(row: 0, column: 2), + CellPosition(row: 3, column: 1) + ] + controller.apply(.cellsChanged(positions)) + #expect(table.rangeReloadCalls.count == 1) + #expect(table.rangeReloadCalls.first?.rows == IndexSet([0, 3])) + #expect(table.rangeReloadCalls.first?.columns == IndexSet([0, 1, 2])) + } + + @Test("apply(.cellsChanged) with empty set is a no-op") + func cellsChangedEmptyNoOp() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.cellsChanged([])) + #expect(table.rangeReloadCalls.isEmpty) + } + + @Test("apply(.rowsInserted) calls insertRows with the configured animation") + func rowsInsertedCallsInsert() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.rowsInserted(IndexSet([5, 6]))) + #expect(table.insertCalls.count == 1) + #expect(table.insertCalls.first?.0 == IndexSet([5, 6])) + #expect(table.insertCalls.first?.1 == .slideDown) + } + + @Test("apply(.rowsInserted) with empty set is a no-op") + func rowsInsertedEmptyNoOp() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.rowsInserted(IndexSet())) + #expect(table.insertCalls.isEmpty) + } + + @Test("apply(.rowsRemoved) calls removeRows") + func rowsRemovedCallsRemove() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.rowsRemoved(IndexSet([1, 2]))) + #expect(table.removeCalls.count == 1) + #expect(table.removeCalls.first?.0 == IndexSet([1, 2])) + #expect(table.removeCalls.first?.1 == .slideUp) + } + + @Test("apply(.fullReplace) calls reloadData") + func fullReplaceReloadsAll() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.fullReplace) + #expect(table.fullReloadCount == 1) + } + + @Test("apply(.columnsReplaced) calls reloadData") + func columnsReplacedReloadsAll() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.apply(.columnsReplaced) + #expect(table.fullReloadCount == 1) + } + + @Test("apply with detached tableView is a no-op") + func detachedNoOp() { + let controller = TableRowsController() + controller.apply(.fullReplace) + } + + @Test("animation options are configurable") + func animationsConfigurable() { + let table = makeTableView(rows: 5, columns: 3) + let controller = TableRowsController(tableView: table) + controller.insertAnimation = .effectFade + controller.removeAnimation = .effectGap + + controller.apply(.rowsInserted(IndexSet(integer: 3))) + controller.apply(.rowsRemoved(IndexSet(integer: 1))) + + #expect(table.insertCalls.first?.1 == .effectFade) + #expect(table.removeCalls.first?.1 == .effectGap) + } +} From b7bd7a4bc14167db5904b0b9a275768313b79b84 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:43:57 +0700 Subject: [PATCH 03/31] refactor(datagrid): mirror result delivery writes into TableRowsStore --- .../MainContentCoordinator+LoadMore.swift | 7 +++++++ .../MainContentCoordinator+MultiStatement.swift | 5 +++++ .../MainContentCoordinator+Navigation.swift | 5 +++++ .../MainContentCoordinator+QueryHelpers.swift | 16 ++++++++++++++++ .../MainContentCoordinator+SaveChanges.swift | 1 + .../MainContentCoordinator+TabSwitch.swift | 1 + 6 files changed, 35 insertions(+) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index 9107f5361..e42629602 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -97,7 +97,11 @@ extension MainContentCoordinator { var tab = tabManager.tabs[idx] let buffer = rowDataStore.buffer(for: tab.id) + let pageOffset = buffer.rows.count buffer.rows.append(contentsOf: pagedResult.rows) + tableRowsStore.updateTableRows(for: tab.id) { rows in + _ = rows.appendPage(pagedResult.rows, startingAt: pageOffset) + } tab.schemaVersion += 1 tab.pagination.loadMoreOffset = pagedResult.nextOffset tab.pagination.hasMoreRows = pagedResult.hasMore @@ -219,6 +223,9 @@ extension MainContentCoordinator { var tab = tabManager.tabs[idx] let buffer = rowDataStore.buffer(for: tab.id) buffer.rows = result.rows + tableRowsStore.updateTableRows(for: tab.id) { rows in + _ = rows.replace(rows: result.rows) + } tab.execution.executionTime = result.executionTime tab.schemaVersion += 1 tab.pagination.resetLoadMore() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index b3ec4e71b..0aad9cf24 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -237,10 +237,15 @@ extension MainContentCoordinator { RowBuffer(rows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), for: updatedTab.id ) + tableRowsStore.setTableRows( + TableRows.from(queryRows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), + for: updatedTab.id + ) updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = tableName != nil && updatedTab.tableContext.isEditable } else { rowDataStore.setBuffer(RowBuffer(), for: updatedTab.id) + tableRowsStore.setTableRows(TableRows(), for: updatedTab.id) if updatedTab.tabType != .table { updatedTab.tableContext.tableName = nil } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 973358f32..f937e075f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -126,6 +126,7 @@ extension MainContentCoordinator { if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id rowDataStore.setBuffer(RowBuffer(), for: tabId) + tableRowsStore.setTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } @@ -208,6 +209,7 @@ extension MainContentCoordinator { if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { let tabId = previewCoordinator.tabManager.tabs[tabIndex].id previewCoordinator.rowDataStore.setBuffer(RowBuffer(), for: tabId) + previewCoordinator.tableRowsStore.setTableRows(TableRows(), for: tabId) previewCoordinator.tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() previewCoordinator.toolbarState.isTableTab = true @@ -280,6 +282,7 @@ extension MainContentCoordinator { if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id rowDataStore.setBuffer(RowBuffer(), for: tabId) + tableRowsStore.setTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true @@ -390,6 +393,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) rowDataStore.tearDown() + tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in @@ -425,6 +429,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) rowDataStore.tearDown() + tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 71cd55be0..451142c4b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -284,6 +284,17 @@ extension MainContentCoordinator { rowDataStore.setBuffer(newBuffer, for: updatedTab.id) + let newTableRows = TableRows.from( + queryRows: newBuffer.rows, + columns: newBuffer.columns, + columnTypes: newBuffer.columnTypes, + columnDefaults: newBuffer.columnDefaults, + columnForeignKeys: newBuffer.columnForeignKeys, + columnEnumValues: newBuffer.columnEnumValues, + columnNullable: newBuffer.columnNullable + ) + tableRowsStore.setTableRows(newTableRows, for: updatedTab.id) + // Create a ResultSet for this single-statement execution let rs = ResultSet(label: tableName ?? "Result") rs.rowBuffer = newBuffer @@ -474,6 +485,11 @@ extension MainContentCoordinator { for (col, vals) in columnEnumValues { buffer.columnEnumValues[col] = vals } + tableRowsStore.updateTableRows(for: tabId) { rows in + for (col, vals) in columnEnumValues { + rows.columnEnumValues[col] = vals + } + } tabManager.tabs[idx].metadataVersion += 1 } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index d9909f5c6..7e8b6ad99 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -250,6 +250,7 @@ extension MainContentCoordinator { .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 for tabId in tabIdsToRemove { rowDataStore.removeBuffer(for: tabId) + tableRowsStore.removeTableRows(for: tabId) } tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } if !tabManager.tabs.isEmpty { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 015d18604..bc7eb833f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -191,6 +191,7 @@ extension MainContentCoordinator { for entry in toEvict { entry.buffer.evict() + tableRowsStore.evict(for: entry.tab.id) } Self.lifecycleLogger.debug( "[switch] evictInactiveTabs evicted=\(toEvict.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" From 9b62697f9f1a6db3b3644f55be9d5126f9899fc0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:54:10 +0700 Subject: [PATCH 04/31] refactor(datagrid): switch JSON view and export to read TableRows --- .../Plugins/QueryResultExportDataSource.swift | 13 +++------- .../Core/Services/Export/ExportService.swift | 6 ++--- TablePro/Models/Export/ExportModels.swift | 2 +- TablePro/Views/Export/ExportDialog.swift | 8 +++--- .../Main/Child/MainEditorContentView.swift | 5 +--- TablePro/Views/Main/MainContentView.swift | 2 +- TablePro/Views/Results/ResultsJsonView.swift | 25 +++++++++---------- 7 files changed, 26 insertions(+), 35 deletions(-) diff --git a/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/TablePro/Core/Plugins/QueryResultExportDataSource.swift index 0e12d0c3d..4706f55c7 100644 --- a/TablePro/Core/Plugins/QueryResultExportDataSource.swift +++ b/TablePro/Core/Plugins/QueryResultExportDataSource.swift @@ -7,9 +7,6 @@ import Foundation import os import TableProPluginKit -/// In-memory `PluginExportDataSource` backed by a RowBuffer snapshot. -/// Allows export plugins (CSV, JSON, SQL, XLSX, MQL) to export query results -/// without modification to the plugins themselves. final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Sendable { let databaseTypeId: String @@ -20,14 +17,12 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send private static let logger = Logger(subsystem: "com.TablePro", category: "QueryResultExportDataSource") - init(rowBuffer: RowBuffer, databaseType: DatabaseType, driver: DatabaseDriver?) { + init(tableRows: TableRows, databaseType: DatabaseType, driver: DatabaseDriver?) { self.databaseTypeId = databaseType.rawValue self.driver = driver - - // Snapshot data at init time for thread safety - self.columns = rowBuffer.columns - self.columnTypeNames = rowBuffer.columnTypes.map { $0.rawType ?? "" } - self.rows = rowBuffer.rows + self.columns = tableRows.columns + self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" } + self.rows = tableRows.rows.map(\.values) } func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index d9e199dbb..4c1d21863 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -181,7 +181,7 @@ final class ExportService { // MARK: - Query Results Export func exportQueryResults( - rowBuffer: RowBuffer, + tableRows: TableRows, config: ExportConfiguration, to url: URL ) async throws { @@ -189,7 +189,7 @@ final class ExportService { throw ExportError.formatNotFound(config.formatId) } - let totalRows = rowBuffer.rows.count + let totalRows = tableRows.count state = ExportState(isExporting: true, totalTables: 1, totalRows: totalRows) isCancelled = false @@ -201,7 +201,7 @@ final class ExportService { } let dataSource = QueryResultExportDataSource( - rowBuffer: rowBuffer, + tableRows: tableRows, databaseType: databaseType, driver: driver ) diff --git a/TablePro/Models/Export/ExportModels.swift b/TablePro/Models/Export/ExportModels.swift index 238732573..9902724c7 100644 --- a/TablePro/Models/Export/ExportModels.swift +++ b/TablePro/Models/Export/ExportModels.swift @@ -11,7 +11,7 @@ import TableProPluginKit /// Defines the export mode: either exporting database tables or in-memory query results. enum ExportMode { case tables(connection: DatabaseConnection, preselectedTables: Set) - case queryResults(connection: DatabaseConnection, rowBuffer: RowBuffer, suggestedFileName: String) + case queryResults(connection: DatabaseConnection, tableRows: TableRows, suggestedFileName: String) case streamingQuery(connection: DatabaseConnection, query: String, suggestedFileName: String) } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 0b9e0571f..87acdef6f 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -54,8 +54,8 @@ struct ExportDialog: View { } private var queryResultsRowCount: Int { - if case .queryResults(_, let rowBuffer, _) = mode { - return rowBuffer.rows.count + if case .queryResults(_, let tableRows, _) = mode { + return tableRows.count } return 0 } @@ -867,10 +867,10 @@ struct ExportDialog: View { service = ExportService(driver: driver, databaseType: connection.type) exportService = service try await service.exportStreamingQuery(query: query, config: config, to: url) - case .queryResults(_, let rowBuffer, _): + case .queryResults(_, let tableRows, _): service = ExportService(databaseType: connection.type) exportService = service - try await service.exportQueryResults(rowBuffer: rowBuffer, config: config, to: url) + try await service.exportQueryResults(tableRows: tableRows, config: config, to: url) default: return } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ff0d9d1b3..52315fb7b 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -437,11 +437,8 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } case .json: - let jsonBuffer = coordinator.rowDataStore.buffer(for: tab.id) ResultsJsonView( - columns: jsonBuffer.columns, - columnTypes: jsonBuffer.columnTypes, - rows: jsonBuffer.rows, + tableRows: coordinator.tableRowsStore.tableRows(for: tab.id), selectedRowIndices: selectionState.indices ) case .data: diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 914fe7e13..0945c0a0e 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: coordinator.rowDataStore.buffer(for: tab.id), + tableRows: coordinator.tableRowsStore.tableRows(for: tab.id), suggestedFileName: fileName ) ) diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index c7e3a856e..b30fd0004 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -6,9 +6,7 @@ import SwiftUI internal struct ResultsJsonView: View { - let columns: [String] - let columnTypes: [ColumnType] - let rows: [[String?]] + let tableRows: TableRows let selectedRowIndices: Set @State private var viewMode: JSONViewMode @@ -20,19 +18,20 @@ internal struct ResultsJsonView: View { @State private var copied = false init( - columns: [String], - columnTypes: [ColumnType], - rows: [[String?]], + tableRows: TableRows, selectedRowIndices: Set ) { - self.columns = columns - self.columnTypes = columnTypes - self.rows = rows + self.tableRows = tableRows self.selectedRowIndices = selectedRowIndices self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode) } + private var allRows: [[String?]] { + tableRows.rows.map(\.values) + } + private var displayRows: [[String?]] { + let rows = allRows if selectedRowIndices.isEmpty { return rows } @@ -43,7 +42,7 @@ internal struct ResultsJsonView: View { private var rowCountText: String { let displaying = displayRows.count - let total = rows.count + let total = tableRows.count if selectedRowIndices.isEmpty || displaying == total { return String(format: String(localized: "%d rows"), total) } @@ -59,7 +58,7 @@ internal struct ResultsJsonView: View { } .onAppear { rebuildJson() } .onChange(of: selectedRowIndices) { rebuildJson() } - .onChange(of: rows.count) { rebuildJson() } + .onChange(of: tableRows.count) { rebuildJson() } .onChange(of: viewMode) { AppSettingsManager.shared.editor.jsonViewerPreferredMode = viewMode } @@ -107,7 +106,7 @@ internal struct ResultsJsonView: View { @ViewBuilder private var content: some View { - if rows.isEmpty { + if tableRows.rows.isEmpty { ContentUnavailableView( String(localized: "No Data"), systemImage: "curlybraces", @@ -153,7 +152,7 @@ internal struct ResultsJsonView: View { // MARK: - JSON Generation private func rebuildJson() { - let converter = JsonRowConverter(columns: columns, columnTypes: columnTypes) + let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: tableRows.columnTypes) let json = converter.generateJson(rows: displayRows) cachedJson = json prettyText = json.prettyPrintedAsJson() ?? json From 5e1ba86e3d4e524474e93249f34ebcffebf29095 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 20:55:50 +0700 Subject: [PATCH 05/31] refactor(datagrid): switch sidebar reads to TableRows --- .../MainContentView+EventHandlers.swift | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 9c761d52a..0ce5ace93 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -176,19 +176,18 @@ extension MainContentView { rightPanelState.editState.onFieldChanged = nil return } - let buffer = coordinator.rowDataStore.buffer(for: tab.id) + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) var allRows: [[String?]] = [] for index in selectedIndices.sorted() { - if index < buffer.rows.count { - allRows.append(buffer.rows[index]) + if index < tableRows.rows.count { + allRows.append(tableRows.rows[index].values) } } - // Enrich column types with loaded enum values from Phase 2b - var columnTypes = buffer.columnTypes - for (i, col) in buffer.columns.enumerated() where i < columnTypes.count { - if let values = buffer.columnEnumValues[col], !values.isEmpty { + var columnTypes = tableRows.columnTypes + for (i, col) in tableRows.columns.enumerated() where i < columnTypes.count { + if let values = tableRows.columnEnumValues[col], !values.isEmpty { let ct = columnTypes[i] if ct.isEnumType { columnTypes[i] = .enumType(rawType: ct.rawType, values: values) @@ -198,12 +197,10 @@ extension MainContentView { } } - // Clear stale sidebar edits after refresh/discard if !changeManager.hasChanges { rightPanelState.editState.clearEdits() } - // Collect columns modified in data grid so sidebar shows green dots var modifiedColumns = Set() for rowIndex in selectedIndices { modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(rowIndex)) @@ -217,12 +214,12 @@ extension MainContentView { } let pkColumns = Set(tab.tableContext.primaryKeyColumns) - let fkColumns = Set(buffer.columnForeignKeys.keys) + let fkColumns = Set(tableRows.columnForeignKeys.keys) rightPanelState.editState.configure( selectedRowIndices: selectedIndices, allRows: allRows, - columns: buffer.columns, + columns: tableRows.columns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, excludedColumnNames: excludedNames, @@ -239,15 +236,14 @@ extension MainContentView { let capturedEditState = rightPanelState.editState rightPanelState.editState.onFieldChanged = { columnIndex, newValue in guard let tab = capturedCoordinator.tabManager.selectedTab else { return } - let buffer = capturedCoordinator.rowDataStore.buffer(for: tab.id) + let tableRows = capturedCoordinator.tableRowsStore.tableRows(for: tab.id) let columnName = - columnIndex < buffer.columns.count ? buffer.columns[columnIndex] : "" + columnIndex < tableRows.columns.count ? tableRows.columns[columnIndex] : "" for rowIndex in capturedEditState.selectedRowIndices { - guard rowIndex < buffer.rows.count else { continue } - let originalRow = buffer.rows[rowIndex] + guard rowIndex < tableRows.rows.count else { continue } + let originalRow = tableRows.rows[rowIndex].values - // Use full (lazy-loaded) original value if available, not truncated row data let oldValue: String? if columnIndex < capturedEditState.fields.count, !capturedEditState.fields[columnIndex].isTruncated @@ -283,16 +279,16 @@ extension MainContentView { let capturedCoordinator = coordinator let capturedEditState = rightPanelState.editState - let buffer = coordinator.rowDataStore.buffer(for: tab.id) + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) if !excludedNames.isEmpty, selectedIndices.count == 1, let tableName = tab.tableContext.tableName, let pkColumn = tab.tableContext.primaryKeyColumn, let rowIndex = selectedIndices.first, - rowIndex < buffer.rows.count + rowIndex < tableRows.rows.count { - let row = buffer.rows[rowIndex] - if let pkColIndex = buffer.columns.firstIndex(of: pkColumn), + let row = tableRows.rows[rowIndex].values + if let pkColIndex = tableRows.columns.firstIndex(of: pkColumn), pkColIndex < row.count, let pkValue = row[pkColIndex] { From a2f5a4ce085bbac469d3284ccf4934b39cd3c480 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 21:31:55 +0700 Subject: [PATCH 06/31] refactor(datagrid): switch NSTableView delegate reads to TableRows --- CHANGELOG.md | 2 +- .../Main/Child/MainEditorContentView.swift | 9 +++++-- .../Views/Results/DataGridCoordinator.swift | 24 ++++++++++++++----- TablePro/Views/Results/DataGridView.swift | 15 ++++++++++++ .../Extensions/DataGridView+Columns.swift | 7 +++--- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d519cfac..3b93d2a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor). +- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. RowBuffer still backs sorting and the display cache pending later phases (Phase C.1 / C.2 of the DataGrid refactor). - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 52315fb7b..1b959ac7f 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -553,8 +553,12 @@ struct MainEditorContentView: View { private func dataGridView(tab: QueryTab) -> some View { let isEditable = tab.tableContext.isEditable && !tab.tableContext.isView && !coordinator.safeModeLevel.blocksAllWrites + let tabId = tab.id DataGridView( rowProvider: rowProvider(for: tab), + tableRowsProvider: { [coordinator] in + coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() + }, changeManager: currentChangeManager, schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, @@ -662,8 +666,9 @@ struct MainEditorContentView: View { var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) if settings.enableSmartValueDetection { let sampleRows: [[String?]]? = { - let rows = tab.display.activeResultSet?.resultRows ?? coordinator.rowDataStore.buffer(for: tab.id).rows - return rows.isEmpty ? nil : Array(rows.prefix(10)) + let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id) + let rows = tableRows?.rows.prefix(10).map(\.values) ?? [] + return rows.isEmpty ? nil : Array(rows) }() detected = ValueDisplayDetector.detect( columns: columns, diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 898748a5d..311245454 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -16,6 +16,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate { var rowProvider: InMemoryRowProvider + var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } var changeManager: AnyChangeManager var isEditable: Bool weak var delegate: (any DataGridViewDelegate)? @@ -326,17 +327,20 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData rowVisualStateCache.removeAll(keepingCapacity: true) - // Always clear cache, then rebuild if there are changes - // This ensures deleted state is cleared when changeManager.clearChanges() is called - guard changeManager.hasChanges else { - // No changes → cache is now empty (cleared above) + let tableRows = tableRowsProvider() + var insertedRowIndices = Set() + for (index, row) in tableRows.rows.enumerated() where row.id.isInserted { + insertedRowIndices.insert(index) + } + + if !changeManager.hasChanges && insertedRowIndices.isEmpty { return } for rowChange in changeManager.rowChanges { let rowIndex = rowChange.rowIndex let isDeleted = rowChange.type == .delete - let isInserted = rowChange.type == .insert + let isInserted = insertedRowIndices.contains(rowIndex) || rowChange.type == .insert let modifiedColumns: Set = rowChange.type == .update ? Set(rowChange.cellChanges.map { $0.columnIndex }) : [] @@ -347,6 +351,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData modifiedColumns: modifiedColumns ) } + + for rowIndex in insertedRowIndices where rowVisualStateCache[rowIndex] == nil { + rowVisualStateCache[rowIndex] = RowVisualState( + isDeleted: false, + isInserted: true, + modifiedColumns: [] + ) + } } func visualState(for row: Int) -> RowVisualState { @@ -361,6 +373,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - NSTableViewDataSource func numberOfRows(in tableView: NSTableView) -> Int { - cachedRowCount + tableRowsProvider().count } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index aa8f3effa..0df3cc099 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -57,6 +57,7 @@ struct DataGridIdentity: Equatable { /// High-performance table view using AppKit NSTableView struct DataGridView: NSViewRepresentable { let rowProvider: InMemoryRowProvider + var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } var changeManager: AnyChangeManager var schemaVersion: Int = 0 var metadataVersion: Int = 0 @@ -178,6 +179,7 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView + context.coordinator.tableRowsProvider = tableRowsProvider context.coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns @@ -247,6 +249,7 @@ struct DataGridView: NSViewRepresentable { if currentIdentity == coordinator.lastIdentity { // Only refresh delegate reference — it may have changed between body evals coordinator.delegate = delegate + coordinator.tableRowsProvider = tableRowsProvider delegate?.dataGridAttach(tableViewCoordinator: coordinator) return } @@ -303,6 +306,7 @@ struct DataGridView: NSViewRepresentable { coordinator.changeManager = changeManager coordinator.isEditable = isEditable + coordinator.tableRowsProvider = tableRowsProvider coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns @@ -726,6 +730,16 @@ struct DataGridView: NSViewRepresentable { // MARK: - Preview +private let previewTableRowsForDataGrid = TableRows.from( + queryRows: [ + ["1", "John", "john@example.com"], + ["2", "Jane", nil], + ["3", "Bob", "bob@example.com"], + ], + columns: ["id", "name", "email"], + columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: 3) +) + #Preview { DataGridView( rowProvider: InMemoryRowProvider( @@ -736,6 +750,7 @@ struct DataGridView: NSViewRepresentable { ], columns: ["id", "name", "email"] ), + tableRowsProvider: { previewTableRowsForDataGrid }, changeManager: AnyChangeManager(DataChangeManager()), isEditable: true, selectedRowIndices: .constant([]), diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index f776041f1..92dea262d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -11,24 +11,25 @@ extension TableViewCoordinator { guard let column = tableColumn else { return nil } let columnId = column.identifier.rawValue + let tableRows = tableRowsProvider() if columnId == "__rowNumber__" { return cellFactory.makeRowNumberCell( tableView: tableView, row: row, - cachedRowCount: cachedRowCount, + cachedRowCount: tableRows.count, visualState: visualState(for: row) ) } guard let columnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return nil } - guard row >= 0 && row < cachedRowCount, + guard row >= 0 && row < tableRows.count, columnIndex >= 0 && columnIndex < cachedColumnCount else { return nil } - let rawValue = rowProvider.value(atRow: row, column: columnIndex) + let rawValue = tableRows.value(at: row, column: columnIndex) let displayValue = rowProvider.displayValue(atRow: row, column: columnIndex) let state = visualState(for: row) From 387dc8eb9bfecf9d7113ad0ce6898892db85ce68 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 21:45:16 +0700 Subject: [PATCH 07/31] refactor(datagrid): route cell edits through TableRows.edit and Delta --- CHANGELOG.md | 2 +- .../Main/Child/MainEditorContentView.swift | 5 +++ ...MainContentCoordinator+RowOperations.swift | 7 ++- .../Views/Results/DataGridCoordinator.swift | 3 ++ TablePro/Views/Results/DataGridView.swift | 6 +++ .../Extensions/DataGridView+CellCommit.swift | 45 +++++++++++++++---- .../Extensions/DataGridView+Editing.swift | 10 ++++- 7 files changed, 63 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b93d2a7f..9bc71ccdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. RowBuffer still backs sorting and the display cache pending later phases (Phase C.1 / C.2 of the DataGrid refactor). +- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. RowBuffer still backs sorting and the display cache pending later phases (Phase C.2 of the DataGrid refactor). - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 1b959ac7f..8304d6533 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -559,6 +559,11 @@ struct MainEditorContentView: View { tableRowsProvider: { [coordinator] in coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() }, + tableRowsMutator: { [coordinator] mutate in + coordinator.tableRowsStore.updateTableRows(for: tabId) { rows in + mutate(&rows) + } + }, changeManager: currentChangeManager, schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index d1a2ad00d..213ee25b9 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -192,10 +192,9 @@ extension MainContentCoordinator { func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { guard let index = tabManager.selectedTabIndex else { return } let tabId = tabManager.tabs[index].id - let buffer = rowDataStore.buffer(for: tabId) - guard rowIndex < buffer.rows.count else { return } - - buffer.rows[rowIndex][columnIndex] = value + tableRowsStore.updateTableRows(for: tabId) { rows in + rows.edit(row: rowIndex, column: columnIndex, value: value) + } tabManager.tabs[index].hasUserInteraction = true } } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 311245454..912615978 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -17,6 +17,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData { var rowProvider: InMemoryRowProvider var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } + var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } var changeManager: AnyChangeManager var isEditable: Bool weak var delegate: (any DataGridViewDelegate)? @@ -60,6 +61,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData weak var tableView: NSTableView? let cellFactory = DataGridCellFactory() + let tableRowsController = TableRowsController() var overlayEditor: CellOverlayEditor? // Settings observer for real-time updates @@ -206,6 +208,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } tableView.reloadData() } + tableRowsController.detach() // Release delegate delegate = nil activeFKPreviewPopover?.close() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0df3cc099..43d04cea7 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -58,6 +58,7 @@ struct DataGridIdentity: Equatable { struct DataGridView: NSViewRepresentable { let rowProvider: InMemoryRowProvider var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } + var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } var changeManager: AnyChangeManager var schemaVersion: Int = 0 var metadataVersion: Int = 0 @@ -179,7 +180,9 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView + context.coordinator.tableRowsController.attach(tableView) context.coordinator.tableRowsProvider = tableRowsProvider + context.coordinator.tableRowsMutator = tableRowsMutator context.coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns @@ -250,6 +253,7 @@ struct DataGridView: NSViewRepresentable { // Only refresh delegate reference — it may have changed between body evals coordinator.delegate = delegate coordinator.tableRowsProvider = tableRowsProvider + coordinator.tableRowsMutator = tableRowsMutator delegate?.dataGridAttach(tableViewCoordinator: coordinator) return } @@ -307,6 +311,7 @@ struct DataGridView: NSViewRepresentable { coordinator.changeManager = changeManager coordinator.isEditable = isEditable coordinator.tableRowsProvider = tableRowsProvider + coordinator.tableRowsMutator = tableRowsMutator coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns @@ -713,6 +718,7 @@ struct DataGridView: NSViewRepresentable { NotificationCenter.default.removeObserver(observer) coordinator.themeObserver = nil } + coordinator.tableRowsController.detach() coordinator.rowProvider = InMemoryRowProvider(rows: [], columns: []) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index b833d61f3..740d31c61 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -10,26 +10,55 @@ extension TableViewCoordinator { guard let tableView else { return } guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } - let oldValue = rowProvider.value(atRow: row, column: columnIndex) + let tableRows = tableRowsProvider() + let usesTableRows = row >= 0 && row < tableRows.rows.count + let oldValue: String? = usesTableRows + ? tableRows.value(at: row, column: columnIndex) + : rowProvider.value(atRow: row, column: columnIndex) guard oldValue != newValue else { return } let columnName = rowProvider.columns[columnIndex] + let originalRow: [String?] = usesTableRows + ? tableRows.rows[row].values + : (rowProvider.rowValues(at: row) ?? []) changeManager.recordCellChange( rowIndex: row, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, newValue: newValue, - originalRow: rowProvider.rowValues(at: row) ?? [] + originalRow: originalRow ) - rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) + var delta: Delta = .none + tableRowsMutator { tableRows in + delta = tableRows.edit(row: row, column: columnIndex, value: newValue) + } delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) + rowProvider.invalidateDisplayCache() - let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) - tableView.reloadData( - forRowIndexes: IndexSet(integer: row), - columnIndexes: IndexSet(integer: tableColumnIndex) - ) + if usesTableRows, case .cellChanged = delta { + tableRowsController.apply(translatedColumnDelta(delta)) + } else { + let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) + tableView.reloadData( + forRowIndexes: IndexSet(integer: row), + columnIndexes: IndexSet(integer: tableColumnIndex) + ) + } + } + + private func translatedColumnDelta(_ delta: Delta) -> Delta { + switch delta { + case .cellChanged(let row, let column): + return .cellChanged(row: row, column: DataGridView.tableColumnIndex(for: column)) + case .cellsChanged(let positions): + let translated = Set(positions.map { + CellPosition(row: $0.row, column: DataGridView.tableColumnIndex(for: $0.column)) + }) + return .cellsChanged(translated) + default: + return delta + } } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index e2631a84f..763c13a1d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -142,14 +142,20 @@ extension TableViewCoordinator { if isEscapeCancelling { isEscapeCancelling = false - let originalValue = rowProvider.value(atRow: row, column: columnIndex) + let cancelTableRows = tableRowsProvider() + let originalValue: String? = row >= 0 && row < cancelTableRows.rows.count + ? cancelTableRows.value(at: row, column: columnIndex) + : rowProvider.value(atRow: row, column: columnIndex) textField.stringValue = originalValue ?? "" (control as? CellTextField)?.restoreTruncatedDisplay() return true } let rawInput = textField.stringValue - let oldValue = rowProvider.value(atRow: row, column: columnIndex) + let tableRows = tableRowsProvider() + let oldValue: String? = row >= 0 && row < tableRows.rows.count + ? tableRows.value(at: row, column: columnIndex) + : rowProvider.value(atRow: row, column: columnIndex) let newValue: String? = rawInput.isEmpty && oldValue == nil ? nil : rawInput commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) From b4c1643269dd3772061ffc2ebbb00ac113b895a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 21:59:15 +0700 Subject: [PATCH 08/31] refactor(datagrid): RowOperationsManager mutates TableRows and returns Delta --- CHANGELOG.md | 2 +- .../Services/Query/RowOperationsManager.swift | 283 ++++++++---------- ...MainContentCoordinator+RowOperations.swift | 177 ++++++----- .../Views/Results/DataGridCoordinator.swift | 37 +++ .../Extensions/DataGridView+Columns.swift | 25 +- TablePro/Views/Results/RowDeltaApplying.swift | 1 + .../RowOperationsManagerCopyTests.swift | 49 +-- .../Services/RowOperationsManagerTests.swift | 243 ++++++++------- 8 files changed, 437 insertions(+), 380 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc71ccdd..02e9cf9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. RowBuffer still backs sorting and the display cache pending later phases (Phase C.2 of the DataGrid refactor). +- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. Row operations (add, duplicate, delete, paste, undo) mutate TableRows and apply the returned Delta through TableViewCoordinator.applyDelta. RowBuffer still backs sorting and the display cache pending later phases (Phase C.2 of the DataGrid refactor). - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index de8cac6f1..2dca7ff3a 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -1,46 +1,56 @@ -// -// RowOperationsManager.swift -// TablePro -// -// Service responsible for row operations: add, delete, duplicate, undo/redo. -// Extracted from MainContentView for better separation of concerns. -// - import AppKit import Foundation import os -/// Manager for row operations in the data grid @MainActor final class RowOperationsManager { private static let logger = Logger(subsystem: "com.TablePro", category: "RowOperationsManager") - /// Maximum number of rows that can be copied to clipboard to prevent OOM private static let maxClipboardRows = 50_000 - // MARK: - Dependencies + struct AddNewRowResult { + let rowIndex: Int + let values: [String?] + let delta: Delta + } - private let changeManager: DataChangeManager + struct DeleteRowsResult { + let nextRowToSelect: Int + let physicallyRemovedIndices: [Int] + let delta: Delta + } + + struct PastedRowInfo { + let rowIndex: Int + let values: [String?] + } - // MARK: - Initialization + struct PasteRowsResult { + let pastedRows: [PastedRowInfo] + let delta: Delta + } + + struct UndoApplicationResult { + let adjustedSelection: Set? + let delta: Delta + } + + struct UndoInsertRowResult { + let adjustedSelection: Set + let delta: Delta + } + + private let changeManager: DataChangeManager init(changeManager: DataChangeManager) { self.changeManager = changeManager } - // MARK: - Add Row - - /// Add a new row to a table tab - /// - Parameters: - /// - columns: Column names - /// - columnDefaults: Column default values - /// - resultRows: Current rows (will be mutated) - /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed func addNewRow( columns: [String], columnDefaults: [String: String?], - resultRows: inout [[String?]] - ) -> (rowIndex: Int, values: [String?])? { + tableRows: inout TableRows + ) -> AddNewRowResult? { var newRowValues: [String?] = [] for column in columns { if let defaultValue = columnDefaults[column], defaultValue != nil { @@ -50,58 +60,43 @@ final class RowOperationsManager { } } - let newRowIndex = resultRows.count - resultRows.append(newRowValues) + let newRowIndex = tableRows.count + let delta = tableRows.appendInsertedRow(values: newRowValues) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newRowValues) - return (newRowIndex, newRowValues) + return AddNewRowResult(rowIndex: newRowIndex, values: newRowValues, delta: delta) } - // MARK: - Duplicate Row - - /// Duplicate a row with new primary key - /// - Parameters: - /// - sourceRowIndex: Index of row to duplicate - /// - columns: Column names - /// - resultRows: Current rows (will be mutated) - /// - Returns: Tuple of (newRowIndex, newRowValues) or nil if failed func duplicateRow( sourceRowIndex: Int, columns: [String], - resultRows: inout [[String?]] - ) -> (rowIndex: Int, values: [String?])? { - guard sourceRowIndex < resultRows.count else { return nil } + tableRows: inout TableRows + ) -> AddNewRowResult? { + guard sourceRowIndex >= 0, sourceRowIndex < tableRows.count else { return nil } - var newValues = resultRows[sourceRowIndex] + var newValues = tableRows.rows[sourceRowIndex].values for pkColumn in changeManager.primaryKeyColumns { - if let pkIndex = columns.firstIndex(of: pkColumn) { + if let pkIndex = columns.firstIndex(of: pkColumn), pkIndex < newValues.count { newValues[pkIndex] = "__DEFAULT__" } } - let newRowIndex = resultRows.count - resultRows.append(newValues) + let newRowIndex = tableRows.count + let delta = tableRows.appendInsertedRow(values: newValues) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: newValues) - return (newRowIndex, newValues) - } - - // MARK: - Delete Rows - - struct DeleteRowsResult { - let nextRowToSelect: Int - let physicallyRemovedIndices: [Int] + return AddNewRowResult(rowIndex: newRowIndex, values: newValues, delta: delta) } func deleteSelectedRows( selectedIndices: Set, - resultRows: inout [[String?]] + tableRows: inout TableRows ) -> DeleteRowsResult { guard !selectedIndices.isEmpty else { - return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: []) + return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: [], delta: .none) } var insertedRowsToDelete: [Int] = [] @@ -114,19 +109,17 @@ final class RowOperationsManager { if changeManager.isRowInserted(rowIndex) { insertedRowsToDelete.append(rowIndex) } else if !changeManager.isRowDeleted(rowIndex) { - if rowIndex < resultRows.count { - existingRowsToDelete.append((rowIndex: rowIndex, originalRow: resultRows[rowIndex])) + if rowIndex < tableRows.count { + existingRowsToDelete.append((rowIndex: rowIndex, originalRow: tableRows.rows[rowIndex].values)) } } } let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) + var delta: Delta = .none if !sortedInsertedRows.isEmpty { - for rowIndex in sortedInsertedRows { - guard rowIndex < resultRows.count else { continue } - resultRows.remove(at: rowIndex) - } + delta = tableRows.remove(at: IndexSet(sortedInsertedRows)) changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) } @@ -134,7 +127,7 @@ final class RowOperationsManager { changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) } - let totalRows = resultRows.count + let totalRows = tableRows.count let rowsDeleted = sortedInsertedRows.count let adjustedMaxRow = maxSelectedRow - rowsDeleted let adjustedMinRow = minSelectedRow - sortedInsertedRows.count(where: { $0 < minSelectedRow }) @@ -152,97 +145,91 @@ final class RowOperationsManager { return DeleteRowsResult( nextRowToSelect: nextRow, - physicallyRemovedIndices: sortedInsertedRows + physicallyRemovedIndices: sortedInsertedRows, + delta: delta ) } - // MARK: - Undo/Redo - - /// Undo the last change - /// - Parameter resultRows: Current rows (will be mutated) - /// - Returns: Updated selection indices - func undoLastChange(resultRows: inout [[String?]]) -> Set? { + func undoLastChange(tableRows: inout TableRows) -> UndoApplicationResult? { guard let result = changeManager.undoLastChange() else { return nil } - return applyUndoResult(result, resultRows: &resultRows) + return applyUndoResult(result, tableRows: &tableRows) } - /// Redo the last undone change - /// - Parameters: - /// - resultRows: Current rows (will be mutated) - /// - columns: Column names for new row creation - /// - Returns: Updated selection indices - func redoLastChange(resultRows: inout [[String?]], columns: [String]) -> Set? { + func redoLastChange(tableRows: inout TableRows) -> UndoApplicationResult? { guard let result = changeManager.redoLastChange() else { return nil } - return applyUndoResult(result, resultRows: &resultRows) + return applyUndoResult(result, tableRows: &tableRows) } - func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? { + func applyUndoResult(_ result: UndoResult, tableRows: inout TableRows) -> UndoApplicationResult { switch result.action { case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _): - if rowIndex < resultRows.count { - resultRows[rowIndex][columnIndex] = previousValue - } + let delta = tableRows.edit(row: rowIndex, column: columnIndex, value: previousValue) + return UndoApplicationResult(adjustedSelection: nil, delta: delta) case .rowInsertion(let rowIndex): if result.needsRowRemoval { - if rowIndex < resultRows.count { - resultRows.remove(at: rowIndex) - return Set() + guard rowIndex >= 0, rowIndex < tableRows.count else { + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } + let delta = tableRows.remove(at: IndexSet(integer: rowIndex)) + return UndoApplicationResult(adjustedSelection: Set(), delta: delta) } else if result.needsRowRestore { - let values = result.restoreRow ?? [String?](repeating: nil, count: resultRows.first?.count ?? 0) - if rowIndex <= resultRows.count { - resultRows.insert(values, at: rowIndex) + let columnCount = tableRows.columns.count + let values = result.restoreRow ?? [String?](repeating: nil, count: columnCount) + guard rowIndex >= 0, rowIndex == tableRows.count else { + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } + let delta = tableRows.appendInsertedRow(values: values) + return UndoApplicationResult(adjustedSelection: nil, delta: delta) } + return UndoApplicationResult(adjustedSelection: nil, delta: .none) case .rowDeletion: - break + return UndoApplicationResult(adjustedSelection: nil, delta: .none) case .batchRowDeletion: - break + return UndoApplicationResult(adjustedSelection: nil, delta: .none) case .batchRowInsertion(let rowIndices, let rowValues): if result.needsRowRemoval { - for rowIndex in rowIndices.sorted(by: >) { - guard rowIndex < resultRows.count else { continue } - resultRows.remove(at: rowIndex) + let validIndices = IndexSet(rowIndices.filter { $0 >= 0 && $0 < tableRows.count }) + guard !validIndices.isEmpty else { + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } + let delta = tableRows.remove(at: validIndices) + return UndoApplicationResult(adjustedSelection: nil, delta: delta) } else if result.needsRowRestore { - for (index, rowIndex) in rowIndices.enumerated().reversed() { + var insertedIndices = IndexSet() + for (index, rowIndex) in rowIndices.enumerated() { guard index < rowValues.count else { continue } - guard rowIndex <= resultRows.count else { continue } - resultRows.insert(rowValues[index], at: rowIndex) + guard rowIndex == tableRows.count else { continue } + _ = tableRows.appendInsertedRow(values: rowValues[index]) + insertedIndices.insert(rowIndex) } + guard !insertedIndices.isEmpty else { + return UndoApplicationResult(adjustedSelection: nil, delta: .none) + } + return UndoApplicationResult(adjustedSelection: nil, delta: .rowsInserted(insertedIndices)) } + return UndoApplicationResult(adjustedSelection: nil, delta: .none) } - - return nil } - // MARK: - Undo Insert Row - - /// Remove a row that was inserted (called by undo context menu) - /// - Parameters: - /// - rowIndex: Index of the inserted row - /// - resultRows: Current rows (will be mutated) - /// - selectedIndices: Current selection (will be adjusted) - /// - Returns: Adjusted selection indices func undoInsertRow( at rowIndex: Int, - resultRows: inout [[String?]], + tableRows: inout TableRows, selectedIndices: Set - ) -> Set { - guard rowIndex >= 0 && rowIndex < resultRows.count else { return selectedIndices } + ) -> UndoInsertRowResult { + guard rowIndex >= 0 && rowIndex < tableRows.count else { + return UndoInsertRowResult(adjustedSelection: selectedIndices, delta: .none) + } - // Remove the row from resultRows - resultRows.remove(at: rowIndex) + let delta = tableRows.remove(at: IndexSet(integer: rowIndex)) - // Adjust selection indices var adjustedSelection = Set() for idx in selectedIndices { if idx == rowIndex { - continue // Skip the removed row + continue } else if idx > rowIndex { adjustedSelection.insert(idx - 1) } else { @@ -250,21 +237,12 @@ final class RowOperationsManager { } } - return adjustedSelection + return UndoInsertRowResult(adjustedSelection: adjustedSelection, delta: delta) } - // MARK: - Copy Rows - - /// Copy selected rows to clipboard as tab-separated values - /// - Parameters: - /// - selectedIndices: Indices of rows to copy - /// - resultRows: Current rows - /// - columns: Column names (used when includeHeaders is true) - /// - includeHeaders: Whether to prepend column headers as the first TSV line func copySelectedRowsToClipboard( selectedIndices: Set, - resultRows: [[String?]], - columns: [String] = [], + tableRows: TableRows, includeHeaders: Bool = false ) { guard !selectedIndices.isEmpty else { return } @@ -281,22 +259,22 @@ final class RowOperationsManager { let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices - let columnCount = resultRows.first?.count ?? 1 + let columnCount = tableRows.rows.first?.values.count ?? 1 let estimatedRowLength = columnCount * 12 var result = "" result.reserveCapacity(indicesToCopy.count * estimatedRowLength) - if includeHeaders, !columns.isEmpty { - for (colIdx, col) in columns.enumerated() { + if includeHeaders, !tableRows.columns.isEmpty { + for (colIdx, col) in tableRows.columns.enumerated() { if colIdx > 0 { result.append("\t") } result.append(col) } } for rowIndex in indicesToCopy { - guard rowIndex < resultRows.count else { continue } + guard rowIndex < tableRows.count else { continue } if !result.isEmpty { result.append("\n") } - for (colIdx, value) in resultRows[rowIndex].enumerated() { + for (colIdx, value) in tableRows.rows[rowIndex].values.enumerated() { if colIdx > 0 { result.append("\t") } result.append(value ?? "NULL") } @@ -309,57 +287,37 @@ final class RowOperationsManager { ClipboardService.shared.writeText(result) } - // MARK: - Paste Rows - - /// Paste rows from clipboard (TSV format) and insert into table - /// - Parameters: - /// - columns: Column names for the table - /// - primaryKeyColumns: Primary key column names (will be set to __DEFAULT__) - /// - resultRows: Current rows (will be mutated) - /// - clipboard: Clipboard provider (injectable for testing) - /// - parser: Row data parser (injectable for testing) - /// - Returns: Array of (rowIndex, values) for pasted rows, or empty array on failure - @MainActor func pasteRowsFromClipboard( columns: [String], primaryKeyColumns: [String], - resultRows: inout [[String?]], + tableRows: inout TableRows, clipboard: ClipboardProvider? = nil, parser: RowDataParser? = nil - ) -> [(rowIndex: Int, values: [String?])] { - // Read from clipboard + ) -> PasteRowsResult { let clipboardProvider = clipboard ?? ClipboardService.shared guard let clipboardText = clipboardProvider.readText() else { - return [] + return PasteRowsResult(pastedRows: [], delta: .none) } - // Create schema let schema = TableSchema( columns: columns, primaryKeyColumns: primaryKeyColumns ) - // Parse clipboard text (auto-detect CSV vs TSV) let rowParser = parser ?? Self.detectParser(for: clipboardText) let parseResult = rowParser.parse(clipboardText, schema: schema) switch parseResult { case .success(let parsedRows): - return insertParsedRows(parsedRows, into: &resultRows) + return insertParsedRows(parsedRows, into: &tableRows) case .failure(let error): - // Log error (in production, this could show a user-facing alert) Self.logger.warning("Paste failed: \(error.localizedDescription)") - return [] + return PasteRowsResult(pastedRows: [], delta: .none) } } - // MARK: - Parser Detection - - /// Auto-detect whether clipboard text is CSV or TSV - /// Heuristic: if tabs appear in most lines, use TSV; otherwise CSV static func detectParser(for text: String) -> RowDataParser { - // Single-pass scan: count non-empty lines containing tabs vs commas var tabLines = 0 var commaLines = 0 var nonEmptyLines = 0 @@ -383,7 +341,6 @@ final class RowOperationsManager { if char == "," { lineHasComma = true } } } - // Handle last line (no trailing newline) if !lineIsEmpty { nonEmptyLines += 1 if lineHasTab { tabLines += 1 } @@ -395,7 +352,6 @@ final class RowOperationsManager { let tabCount = tabLines let commaCount = commaLines - // If majority of lines have tabs, use TSV; otherwise CSV if tabCount > commaCount { return TSVRowParser() } else if commaCount > 0 { @@ -404,30 +360,25 @@ final class RowOperationsManager { return TSVRowParser() } - // MARK: - Private Helpers - - /// Insert parsed rows into the table - /// - Parameters: - /// - parsedRows: Array of parsed rows from clipboard - /// - resultRows: Current rows (will be mutated) - /// - Returns: Array of (rowIndex, values) for inserted rows private func insertParsedRows( _ parsedRows: [ParsedRow], - into resultRows: inout [[String?]] - ) -> [(rowIndex: Int, values: [String?])] { - var pastedRowInfo: [(Int, [String?])] = [] + into tableRows: inout TableRows + ) -> PasteRowsResult { + var pastedRowInfo: [PastedRowInfo] = [] + var insertedIndices = IndexSet() for parsedRow in parsedRows { let rowValues = parsedRow.values - - resultRows.append(rowValues) - let newRowIndex = resultRows.count - 1 + let newRowIndex = tableRows.count + _ = tableRows.appendInsertedRow(values: rowValues) + insertedIndices.insert(newRowIndex) changeManager.recordRowInsertion(rowIndex: newRowIndex, values: rowValues) - pastedRowInfo.append((newRowIndex, rowValues)) + pastedRowInfo.append(PastedRowInfo(rowIndex: newRowIndex, values: rowValues)) } - return pastedRowInfo + let delta: Delta = insertedIndices.isEmpty ? .none : .rowsInserted(insertedIndices) + return PasteRowsResult(pastedRows: pastedRowInfo, delta: delta) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 213ee25b9..132480693 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -1,15 +1,6 @@ -// -// MainContentCoordinator+RowOperations.swift -// TablePro -// -// Row manipulation operations for MainContentCoordinator -// - import Foundation extension MainContentCoordinator { - // MARK: - Row Operations - func addNewRow(editingCell: inout CellPosition?) { guard !safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, @@ -18,18 +9,26 @@ extension MainContentCoordinator { let tab = tabManager.tabs[tabIndex] guard tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } - let buffer = rowDataStore.buffer(for: tab.id) - guard let result = rowOperationsManager.addNewRow( - columns: buffer.columns, - columnDefaults: buffer.columnDefaults, - resultRows: &buffer.rows - ) else { return } + let tabId = tab.id + let columnDefaults = tableRowsStore.tableRows(for: tabId).columnDefaults + let columns = tableRowsStore.tableRows(for: tabId).columns + + var addResult: RowOperationsManager.AddNewRowResult? + tableRowsStore.updateTableRows(for: tabId) { rows in + addResult = rowOperationsManager.addNewRow( + columns: columns, + columnDefaults: columnDefaults, + tableRows: &rows + ) + } + + guard let result = addResult else { return } selectionState.indices = [result.rowIndex] editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) } func deleteSelectedRows(indices: Set) { @@ -40,26 +39,33 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tabId = tabManager.tabs[tabIndex].id - let buffer = rowDataStore.buffer(for: tabId) - let result = rowOperationsManager.deleteSelectedRows( - selectedIndices: indices, - resultRows: &buffer.rows + + var deleteResult = RowOperationsManager.DeleteRowsResult( + nextRowToSelect: -1, + physicallyRemovedIndices: [], + delta: .none ) + tableRowsStore.updateTableRows(for: tabId) { rows in + deleteResult = rowOperationsManager.deleteSelectedRows( + selectedIndices: indices, + tableRows: &rows + ) + } - if result.nextRowToSelect >= 0 - && result.nextRowToSelect < buffer.rows.count { - selectionState.indices = [result.nextRowToSelect] + let totalRows = tableRowsStore.tableRows(for: tabId).count + if deleteResult.nextRowToSelect >= 0 && deleteResult.nextRowToSelect < totalRows { + selectionState.indices = [deleteResult.nextRowToSelect] } else { selectionState.indices.removeAll() } tabManager.tabs[tabIndex].hasUserInteraction = true - if !result.physicallyRemovedIndices.isEmpty { + if !deleteResult.physicallyRemovedIndices.isEmpty { querySortCache.removeValue(forKey: tabId) - dataTabDelegate?.dataGridDidRemoveRows( - at: IndexSet(result.physicallyRemovedIndices) - ) + dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta) + } else { + dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() } } @@ -70,20 +76,27 @@ 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 index < buffer.rows.count else { return } - guard let result = rowOperationsManager.duplicateRow( - sourceRowIndex: index, - columns: buffer.columns, - resultRows: &buffer.rows - ) else { return } + let tabId = tab.id + let columns = tableRowsStore.tableRows(for: tabId).columns + guard index >= 0, index < tableRowsStore.tableRows(for: tabId).count else { return } + + var dupResult: RowOperationsManager.AddNewRowResult? + tableRowsStore.updateTableRows(for: tabId) { rows in + dupResult = rowOperationsManager.duplicateRow( + sourceRowIndex: index, + columns: columns, + tableRows: &rows + ) + } + + guard let result = dupResult else { return } selectionState.indices = [result.rowIndex] editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) } func undoInsertRow(at rowIndex: Int) { @@ -91,14 +104,22 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tabId = tabManager.tabs[tabIndex].id - let buffer = rowDataStore.buffer(for: tabId) - selectionState.indices = rowOperationsManager.undoInsertRow( - at: rowIndex, - resultRows: &buffer.rows, - selectedIndices: selectionState.indices + + var undoResult = RowOperationsManager.UndoInsertRowResult( + adjustedSelection: selectionState.indices, + delta: .none ) + tableRowsStore.updateTableRows(for: tabId) { rows in + undoResult = rowOperationsManager.undoInsertRow( + at: rowIndex, + tableRows: &rows, + selectedIndices: selectionState.indices + ) + } + + selectionState.indices = undoResult.adjustedSelection querySortCache.removeValue(forKey: tabId) - dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(integer: rowIndex)) + dataTabDelegate?.tableViewCoordinator?.applyDelta(undoResult.delta) } func handleUndoResult(_ result: UndoResult) { @@ -106,16 +127,21 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) - if let adjustedSelection = rowOperationsManager.applyUndoResult( - result, resultRows: &buffer.rows - ) { + let tabId = tab.id + + var application = RowOperationsManager.UndoApplicationResult(adjustedSelection: nil, delta: .none) + tableRowsStore.updateTableRows(for: tabId) { rows in + application = rowOperationsManager.applyUndoResult(result, tableRows: &rows) + } + + if let adjustedSelection = application.adjustedSelection { selectionState.indices = adjustedSelection } tabManager.tabs[tabIndex].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) + querySortCache.removeValue(forKey: tabId) dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() + dataTabDelegate?.tableViewCoordinator?.applyDelta(application.delta) } func copySelectedRowsToClipboard(indices: Set) { @@ -123,10 +149,10 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: buffer.rows + tableRows: tableRows ) } @@ -135,11 +161,10 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: buffer.rows, - columns: buffer.columns, + tableRows: tableRows, includeHeaders: true ) } @@ -148,15 +173,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 tableRows = tableRowsStore.tableRows(for: tab.id) let rows = indices.sorted().compactMap { idx -> [String?]? in - guard idx < buffer.rows.count else { return nil } - return buffer.rows[idx] + guard idx >= 0, idx < tableRows.count else { return nil } + return tableRows.rows[idx].values } guard !rows.isEmpty else { return } let converter = JsonRowConverter( - columns: buffer.columns, - columnTypes: buffer.columnTypes + columns: tableRows.columns, + columnTypes: tableRows.columnTypes ) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } @@ -166,28 +191,30 @@ extension MainContentCoordinator { let index = tabManager.selectedTabIndex else { return } let tab = tabManager.tabs[index] - guard tab.tabType == .table else { return } - let buffer = rowDataStore.buffer(for: tab.id) - let pastedRows = rowOperationsManager.pasteRowsFromClipboard( - columns: buffer.columns, - primaryKeyColumns: changeManager.primaryKeyColumns, - resultRows: &buffer.rows - ) - - if !pastedRows.isEmpty { - let newIndices = Set(pastedRows.map { $0.rowIndex }) - selectionState.indices = newIndices + let tabId = tab.id + let columns = tableRowsStore.tableRows(for: tabId).columns - tabManager.tabs[index].selectedRowIndices = newIndices - tabManager.tabs[index].hasUserInteraction = true - querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(newIndices)) + var pasteResult = RowOperationsManager.PasteRowsResult(pastedRows: [], delta: .none) + tableRowsStore.updateTableRows(for: tabId) { rows in + pasteResult = rowOperationsManager.pasteRowsFromClipboard( + columns: columns, + primaryKeyColumns: changeManager.primaryKeyColumns, + tableRows: &rows + ) } - } - // MARK: - Cell Operations + guard !pasteResult.pastedRows.isEmpty else { return } + + let newIndices = Set(pasteResult.pastedRows.map { $0.rowIndex }) + selectionState.indices = newIndices + + tabManager.tabs[index].selectedRowIndices = newIndices + tabManager.tabs[index].hasUserInteraction = true + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.tableViewCoordinator?.applyDelta(pasteResult.delta) + } func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { guard let index = tabManager.selectedTabIndex else { return } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 912615978..8c9522b11 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -259,6 +259,43 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData lastIdentity = nil } + func applyDelta(_ delta: Delta) { + switch delta { + case .cellChanged(let row, let column): + guard let tableView else { return } + let tableColumn = DataGridView.tableColumnIndex(for: column) + guard row >= 0, row < tableView.numberOfRows else { return } + guard tableColumn >= 0, tableColumn < tableView.numberOfColumns else { return } + tableView.reloadData( + forRowIndexes: IndexSet(integer: row), + columnIndexes: IndexSet(integer: tableColumn) + ) + case .cellsChanged(let positions): + guard !positions.isEmpty, let tableView else { return } + var rowSet = IndexSet() + var colSet = IndexSet() + for position in positions { + if position.row >= 0, position.row < tableView.numberOfRows { + rowSet.insert(position.row) + } + let tableColumn = DataGridView.tableColumnIndex(for: position.column) + if tableColumn >= 0, tableColumn < tableView.numberOfColumns { + colSet.insert(tableColumn) + } + } + guard !rowSet.isEmpty, !colSet.isEmpty else { return } + tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) + case .rowsInserted(let indices): + guard !indices.isEmpty else { return } + applyInsertedRows(indices) + case .rowsRemoved(let indices): + guard !indices.isEmpty else { return } + applyRemovedRows(indices) + case .columnsReplaced, .fullReplace: + applyFullReplace() + } + } + func invalidateCachesForUndoRedo() { rowProvider.invalidateDisplayCache() rebuildVisualStateCache() diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 92dea262d..ade8a6583 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -30,7 +30,12 @@ extension TableViewCoordinator { } let rawValue = tableRows.value(at: row, column: columnIndex) - let displayValue = rowProvider.displayValue(atRow: row, column: columnIndex) + let displayValue = resolveDisplayValue( + row: row, + columnIndex: columnIndex, + rawValue: rawValue, + tableRows: tableRows + ) let state = visualState(for: row) let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) @@ -84,4 +89,22 @@ extension TableViewCoordinator { rowView.rowIndex = row return rowView } + + private func resolveDisplayValue( + row: Int, + columnIndex: Int, + rawValue: String?, + tableRows: TableRows + ) -> String? { + if row < rowProvider.totalRowCount { + return rowProvider.displayValue(atRow: row, column: columnIndex) + } + let columnType = columnIndex < tableRows.columnTypes.count + ? tableRows.columnTypes[columnIndex] + : nil + let displayFormat = columnIndex < rowProvider.columnDisplayFormats.count + ? rowProvider.columnDisplayFormats[columnIndex] + : nil + return CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: displayFormat) + } } diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/RowDeltaApplying.swift index affc103e3..bfc773f50 100644 --- a/TablePro/Views/Results/RowDeltaApplying.swift +++ b/TablePro/Views/Results/RowDeltaApplying.swift @@ -5,6 +5,7 @@ protocol RowDeltaApplying: AnyObject { func applyInsertedRows(_ indices: IndexSet) func applyRemovedRows(_ indices: IndexSet) func applyFullReplace() + func applyDelta(_ delta: Delta) func invalidateCachesForUndoRedo() } diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index df4b27d80..f3aa7ef33 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -1,11 +1,3 @@ -// -// RowOperationsManagerCopyTests.swift -// TableProTests -// -// Regression tests for RowOperationsManager copy optimization (P2-6). -// Validates TSV formatting, NULL handling, and large-row correctness. -// - import Foundation @testable import TablePro import Testing @@ -30,13 +22,13 @@ private final class MockClipboardProvider: ClipboardProvider { @MainActor @Suite("RowOperationsManager Copy") struct RowOperationsManagerCopyTests { - // MARK: - Helpers + private static let defaultColumns = ["id", "name", "email"] private func makeManager() -> (RowOperationsManager, DataChangeManager) { let changeManager = DataChangeManager() changeManager.configureForTable( tableName: "users", - columns: ["id", "name", "email"], + columns: Self.defaultColumns, primaryKeyColumns: ["id"], databaseType: .mysql ) @@ -44,26 +36,30 @@ struct RowOperationsManagerCopyTests { return (manager, changeManager) } + private func makeTableRows(rows: [[String?]], columns: [String]? = nil) -> TableRows { + let cols = columns ?? Self.defaultColumns + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: cols.count) + return TableRows.from(queryRows: rows, columns: cols, columnTypes: columnTypes) + } + private func copyAndCapture( manager: RowOperationsManager, indices: Set, rows: [[String?]], - columns: [String] = [], + columns: [String]? = nil, includeHeaders: Bool = false ) -> String? { let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + let tableRows = makeTableRows(rows: rows, columns: columns ?? Self.defaultColumns) manager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: rows, - columns: columns, + tableRows: tableRows, includeHeaders: includeHeaders ) return clipboard.lastWrittenText } - // MARK: - Single Row TSV - @Test("Single row copy produces tab-separated values") func singleRowTSV() { let (manager, _) = makeManager() @@ -74,8 +70,6 @@ struct RowOperationsManagerCopyTests { #expect(result == "1\tAlice\talice@test.com") } - // MARK: - Multiple Rows - @Test("Multiple rows separated by newlines in TSV format") func multipleRowsTSV() { let (manager, _) = makeManager() @@ -89,8 +83,6 @@ struct RowOperationsManagerCopyTests { #expect(result == "1\tAlice\ta@test.com\n2\tBob\tb@test.com") } - // MARK: - NULL Handling - @Test("NULL values rendered as literal NULL string") func nullValuesRenderedAsNullString() { let (manager, _) = makeManager() @@ -117,25 +109,22 @@ struct RowOperationsManagerCopyTests { #expect(lines?[1] == "NULL\tBob\tNULL") } - // MARK: - Empty Selection - @Test("Empty selection produces no clipboard write") func emptySelectionNoWrite() { let (manager, _) = makeManager() let rows = TestFixtures.makeRows(count: 3) let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + let tableRows = makeTableRows(rows: rows) manager.copySelectedRowsToClipboard( selectedIndices: [], - resultRows: rows + tableRows: tableRows ) #expect(clipboard.lastWrittenText == nil) } - // MARK: - Large Row Count - @Test("Large row count produces correct first and last rows") func largeRowCount() { let (manager, _) = makeManager() @@ -156,8 +145,6 @@ struct RowOperationsManagerCopyTests { #expect(lines.last == "\(count - 1)\tname_\(count - 1)\temail_\(count - 1)") } - // MARK: - Row Ordering - @Test("Copied rows are in sorted index order regardless of selection order") func rowsInSortedOrder() { let (manager, _) = makeManager() @@ -167,13 +154,11 @@ struct RowOperationsManagerCopyTests { ["C"], ] - let result = copyAndCapture(manager: manager, indices: [2, 0], rows: rows) + let result = copyAndCapture(manager: manager, indices: [2, 0], rows: rows, columns: ["letter"]) #expect(result == "A\nC") } - // MARK: - Include Headers - @Test("Copy with headers prepends column names as first TSV line") func copyWithHeaders() { let (manager, _) = makeManager() @@ -193,20 +178,16 @@ struct RowOperationsManagerCopyTests { #expect(lines[1] == "1\tAlice\ta@test.com") } - // MARK: - Out-of-Bounds Index - @Test("Out-of-bounds indices are skipped gracefully") func outOfBoundsIndicesSkipped() { let (manager, _) = makeManager() let rows: [[String?]] = [["1", "Alice"]] - let result = copyAndCapture(manager: manager, indices: [0, 5, 10], rows: rows) + let result = copyAndCapture(manager: manager, indices: [0, 5, 10], rows: rows, columns: ["id", "name"]) #expect(result == "1\tAlice") } - // MARK: - All NULL Row - @Test("Row with all NULL values produces tab-separated NULL strings") func allNullRow() { let (manager, _) = makeManager() diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index af362c956..ce6f969d0 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -1,10 +1,3 @@ -// -// RowOperationsManagerTests.swift -// TableProTests -// -// Tests for RowOperationsManager row operations: add, duplicate, delete, undo/redo. -// - import Foundation @testable import TablePro import Testing @@ -12,13 +5,17 @@ import Testing @MainActor @Suite("Row Operations Manager") struct RowOperationsManagerTests { - // MARK: - Test Helpers + private static let testColumns = ["id", "name", "email"] + private static let testColumnTypes: [ColumnType] = Array( + repeating: .text(rawType: nil), + count: 3 + ) private func makeManager() -> (RowOperationsManager, DataChangeManager) { let changeManager = DataChangeManager() changeManager.configureForTable( tableName: "users", - columns: ["id", "name", "email"], + columns: Self.testColumns, primaryKeyColumns: ["id"], databaseType: .mysql ) @@ -26,52 +23,87 @@ struct RowOperationsManagerTests { return (manager, changeManager) } - // MARK: - addNewRow Tests + private func makeTableRows(rowCount: Int) -> TableRows { + TableRows.from( + queryRows: TestFixtures.makeRows(count: rowCount, columns: Self.testColumns), + columns: Self.testColumns, + columnTypes: Self.testColumnTypes + ) + } + + private func emptyTableRows() -> TableRows { + TableRows.from( + queryRows: [], + columns: Self.testColumns, + columnTypes: Self.testColumnTypes + ) + } - @Test("addNewRow appends row to resultRows") + @Test("addNewRow appends row to tableRows") func addNewRowAppendsRow() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) - let originalCount = rows.count + var tableRows = makeTableRows(rowCount: 3) + let originalCount = tableRows.count _ = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) - #expect(rows.count == originalCount + 1) + #expect(tableRows.count == originalCount + 1) } - @Test("addNewRow returns correct row index") + @Test("addNewRow returns correct row index and inserted delta") func addNewRowReturnsCorrectIndex() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) #expect(result?.rowIndex == 5) + if case .rowsInserted(let indices) = result?.delta { + #expect(indices == IndexSet(integer: 5)) + } else { + Issue.record("Expected .rowsInserted delta") + } + } + + @Test("addNewRow assigns inserted RowID to new row") + func addNewRowAssignsInsertedRowID() { + let (manager, _) = makeManager() + var tableRows = makeTableRows(rowCount: 2) + + let result = manager.addNewRow( + columns: Self.testColumns, + columnDefaults: [:], + tableRows: &tableRows + ) + + #expect(result != nil) + let newIndex = result!.rowIndex + #expect(tableRows.rows[newIndex].id.isInserted) } @Test("addNewRow uses DEFAULT marker for columns with defaults") func addNewRowUsesDefaultMarker() { let (manager, _) = makeManager() - var rows: [[String?]] = [] + var tableRows = emptyTableRows() let defaults: [String: String?] = [ "id": "auto_increment", "name": nil, - "email": "user@example.com" + "email": "user@example.com", ] let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: defaults, - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) @@ -82,15 +114,15 @@ struct RowOperationsManagerTests { @Test("addNewRow uses nil for columns without defaults") func addNewRowUsesNilForNoDefaults() { let (manager, _) = makeManager() - var rows: [[String?]] = [] + var tableRows = emptyTableRows() let defaults: [String: String?] = [ - "id": "auto_increment" + "id": "auto_increment", ] let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: defaults, - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) @@ -101,12 +133,12 @@ struct RowOperationsManagerTests { @Test("addNewRow records insertion in change manager") func addNewRowRecordsInsertion() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) @@ -117,13 +149,13 @@ struct RowOperationsManagerTests { @Test("addNewRow increments change manager reload version") func addNewRowIncrementsReloadVersion() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) let versionBefore = changeManager.reloadVersion _ = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(changeManager.reloadVersion > versionBefore) @@ -132,77 +164,77 @@ struct RowOperationsManagerTests { @Test("multiple addNewRow calls append sequential rows") func multipleAddNewRowAppendsSequentially() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) - let r1 = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - let r2 = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - let r3 = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) + let r1 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + let r2 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + let r3 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) - #expect(rows.count == 5) + #expect(tableRows.count == 5) #expect(r1?.rowIndex == 2) #expect(r2?.rowIndex == 3) #expect(r3?.rowIndex == 4) } - // MARK: - duplicateRow Tests - @Test("duplicateRow copies source row values") func duplicateRowCopiesValues() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) - let sourceValues = rows[1] + var tableRows = makeTableRows(rowCount: 3) + let sourceValues = tableRows.rows[1].values let result = manager.duplicateRow( sourceRowIndex: 1, - columns: ["id", "name", "email"], - resultRows: &rows + columns: Self.testColumns, + tableRows: &tableRows ) #expect(result != nil) - // Non-PK columns should match source #expect(result?.values[1] == sourceValues[1]) #expect(result?.values[2] == sourceValues[2]) } - @Test("duplicateRow sets primary key to DEFAULT") + @Test("duplicateRow sets primary key to DEFAULT and returns inserted delta") func duplicateRowSetsPkToDefault() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) let result = manager.duplicateRow( sourceRowIndex: 0, - columns: ["id", "name", "email"], - resultRows: &rows + columns: Self.testColumns, + tableRows: &tableRows ) #expect(result != nil) #expect(result?.values[0] == "__DEFAULT__") + if case .rowsInserted(let indices) = result?.delta { + #expect(indices == IndexSet(integer: 3)) + } else { + Issue.record("Expected .rowsInserted delta") + } } @Test("duplicateRow returns nil for invalid source index") func duplicateRowReturnsNilForInvalidIndex() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) let result = manager.duplicateRow( sourceRowIndex: 10, - columns: ["id", "name", "email"], - resultRows: &rows + columns: Self.testColumns, + tableRows: &tableRows ) #expect(result == nil) } - // MARK: - deleteSelectedRows Tests - @Test("deleteSelectedRows marks existing rows as deleted") func deleteSelectedRowsMarksExistingAsDeleted() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) _ = manager.deleteSelectedRows( selectedIndices: [1, 3], - resultRows: &rows + tableRows: &tableRows ) #expect(changeManager.hasChanges) @@ -210,115 +242,121 @@ struct RowOperationsManagerTests { #expect(changeManager.isRowDeleted(3)) } - @Test("deleteSelectedRows removes inserted rows from resultRows") + @Test("deleteSelectedRows removes inserted rows from tableRows and reports delta") func deleteSelectedRowsRemovesInsertedRows() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) - // Insert a new row first - let result = manager.addNewRow( - columns: ["id", "name", "email"], + let addResult = manager.addNewRow( + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) - #expect(rows.count == 4) + #expect(tableRows.count == 4) - // Delete the inserted row - _ = manager.deleteSelectedRows( - selectedIndices: [result!.rowIndex], - resultRows: &rows + let result = manager.deleteSelectedRows( + selectedIndices: [addResult!.rowIndex], + tableRows: &tableRows ) - #expect(rows.count == 3) + #expect(tableRows.count == 3) + if case .rowsRemoved(let indices) = result.delta { + #expect(indices == IndexSet(integer: addResult!.rowIndex)) + } else { + Issue.record("Expected .rowsRemoved delta") + } } @Test("deleteSelectedRows returns correct next selection") func deleteSelectedRowsReturnsNextSelection() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - #expect(rows.count == 6) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + #expect(tableRows.count == 6) let result = manager.deleteSelectedRows( selectedIndices: [5], - resultRows: &rows + tableRows: &tableRows ) #expect(result.nextRowToSelect >= 0) - #expect(result.nextRowToSelect < rows.count) + #expect(result.nextRowToSelect < tableRows.count) } - @Test("deleteSelectedRows returns empty physicallyRemovedIndices for empty selection") + @Test("deleteSelectedRows returns empty result for empty selection") func deleteSelectedRowsEmptySelection() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) - let result = manager.deleteSelectedRows(selectedIndices: [], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [], tableRows: &tableRows) #expect(result.physicallyRemovedIndices.isEmpty) #expect(result.nextRowToSelect == -1) - #expect(rows.count == 3) + #expect(result.delta == .none) + #expect(tableRows.count == 3) } @Test("deleteSelectedRows: deleting only existing rows leaves physicallyRemovedIndices empty") func deleteSelectedRowsExistingOnly() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 5) + var tableRows = makeTableRows(rowCount: 5) - let result = manager.deleteSelectedRows(selectedIndices: [1, 3], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [1, 3], tableRows: &tableRows) #expect(result.physicallyRemovedIndices.isEmpty) - #expect(rows.count == 5) + #expect(result.delta == .none) + #expect(tableRows.count == 5) } @Test("deleteSelectedRows: deleting only inserted rows reports each in physicallyRemovedIndices") func deleteSelectedRowsInsertedOnly() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - #expect(rows.count == 5) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + #expect(tableRows.count == 5) - let result = manager.deleteSelectedRows(selectedIndices: [2, 3, 4], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [2, 3, 4], tableRows: &tableRows) #expect(result.physicallyRemovedIndices == [4, 3, 2]) - #expect(rows.count == 2) + #expect(tableRows.count == 2) + if case .rowsRemoved(let indices) = result.delta { + #expect(indices == IndexSet([2, 3, 4])) + } else { + Issue.record("Expected .rowsRemoved delta") + } } @Test("deleteSelectedRows: mixed inserted and existing rows reports only inserted indices") func deleteSelectedRowsMixed() { let (manager, _) = makeManager() - var rows = TestFixtures.makeRows(count: 3) + var tableRows = makeTableRows(rowCount: 3) - _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) - #expect(rows.count == 4) + _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + #expect(tableRows.count == 4) - let result = manager.deleteSelectedRows(selectedIndices: [0, 3], resultRows: &rows) + let result = manager.deleteSelectedRows(selectedIndices: [0, 3], tableRows: &tableRows) #expect(result.physicallyRemovedIndices == [3]) - #expect(rows.count == 3) + #expect(tableRows.count == 3) } - // MARK: - Integration Tests - @Test("addNewRow then edit cell preserves insertion state") func addNewRowThenEditPreservesInsertion() { let (manager, changeManager) = makeManager() - var rows = TestFixtures.makeRows(count: 2) + var tableRows = makeTableRows(rowCount: 2) - // Add a new row let result = manager.addNewRow( - columns: ["id", "name", "email"], + columns: Self.testColumns, columnDefaults: [:], - resultRows: &rows + tableRows: &tableRows ) #expect(result != nil) let newIndex = result!.rowIndex - // Edit a cell in the new row changeManager.recordCellChange( rowIndex: newIndex, columnIndex: 1, @@ -327,10 +365,9 @@ struct RowOperationsManagerTests { newValue: "Alice" ) - // Both the insertion and the cell edit should be tracked #expect(changeManager.hasChanges) #expect(changeManager.isRowInserted(newIndex)) - // The row should still exist in resultRows - #expect(rows.count == 3) + #expect(tableRows.count == 3) + #expect(tableRows.rows[newIndex].id.isInserted) } } From 45c5181537f206a2f7377bcbb83031b090b3e005 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 22:06:57 +0700 Subject: [PATCH 09/31] refactor(datagrid): undo replay returns Delta and supports non-tail insert --- .../ChangeTracking/DataChangeManager.swift | 46 +++++++-- .../Services/Query/RowOperationsManager.swift | 17 ++-- TablePro/Models/Query/TableRows.swift | 9 ++ .../Models/Query/TableRowsTests.swift | 98 +++++++++++++++++++ 4 files changed, 150 insertions(+), 20 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 1ade78419..d1ca992bc 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -17,6 +17,21 @@ struct UndoResult { let needsRowRemoval: Bool let needsRowRestore: Bool let restoreRow: [String?]? + let delta: Delta + + init( + action: UndoAction, + needsRowRemoval: Bool, + needsRowRestore: Bool, + restoreRow: [String?]?, + delta: Delta = .none + ) { + self.action = action + self.needsRowRemoval = needsRowRemoval + self.needsRowRestore = needsRowRestore + self.restoreRow = restoreRow + self.delta = delta + } } /// Manager for tracking and applying data changes @@ -222,7 +237,6 @@ final class DataChangeManager: ChangeManaging { } hasChanges = !pending.isEmpty - reloadVersion += 1 if let result = lastUndoResult { onUndoApplied?(result) @@ -259,7 +273,10 @@ final class DataChangeManager: ChangeManaging { originalDBValue: newValue, newValue: previousValue, originalRow: originalRow ) } - lastUndoResult = UndoResult(action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil) + lastUndoResult = UndoResult( + action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil, + delta: .cellChanged(row: rowIndex, column: columnIndex) + ) } private func applyRowInsertionUndo(rowIndex: Int, action: UndoAction) { @@ -274,12 +291,14 @@ final class DataChangeManager: ChangeManaging { if pending.isRowInserted(rowIndex) { _ = pending.undoRowInsertion(rowIndex: rowIndex) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .rowsRemoved(IndexSet(integer: rowIndex)) ) } else { pending.reinsertRow(rowIndex: rowIndex, columns: columns, savedValues: savedValues) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues, + delta: .rowsInserted(IndexSet(integer: rowIndex)) ) } } @@ -292,12 +311,14 @@ final class DataChangeManager: ChangeManaging { if pending.isRowDeleted(rowIndex) { _ = pending.undoRowDeletion(rowIndex: rowIndex) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow, + delta: .fullReplace ) } else { pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .fullReplace ) } } @@ -315,14 +336,16 @@ final class DataChangeManager: ChangeManaging { _ = pending.undoRowDeletion(rowIndex: rowIndex) } lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil, + delta: .fullReplace ) } else { for (rowIndex, originalRow) in rows { pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow) } lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .fullReplace ) } } @@ -335,15 +358,18 @@ final class DataChangeManager: ChangeManaging { } let firstInserted = rowIndices.first.map { pending.isRowInserted($0) } ?? false + let indices = IndexSet(rowIndices) if firstInserted { _ = pending.undoBatchRowInsertion(rowIndices: rowIndices, columnCount: columns.count) lastUndoResult = UndoResult( - action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil + action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil, + delta: .rowsRemoved(indices) ) } else { pending.reinsertBatch(rowIndices: rowIndices, rowValues: rowValues, columns: columns) lastUndoResult = UndoResult( - action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil + action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil, + delta: .rowsInserted(indices) ) } } diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 2dca7ff3a..4e8620f4b 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -176,19 +176,16 @@ final class RowOperationsManager { } else if result.needsRowRestore { let columnCount = tableRows.columns.count let values = result.restoreRow ?? [String?](repeating: nil, count: columnCount) - guard rowIndex >= 0, rowIndex == tableRows.count else { - return UndoApplicationResult(adjustedSelection: nil, delta: .none) - } - let delta = tableRows.appendInsertedRow(values: values) + let delta = tableRows.insertInsertedRow(at: rowIndex, values: values) return UndoApplicationResult(adjustedSelection: nil, delta: delta) } return UndoApplicationResult(adjustedSelection: nil, delta: .none) case .rowDeletion: - return UndoApplicationResult(adjustedSelection: nil, delta: .none) + return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) case .batchRowDeletion: - return UndoApplicationResult(adjustedSelection: nil, delta: .none) + return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) case .batchRowInsertion(let rowIndices, let rowValues): if result.needsRowRemoval { @@ -200,10 +197,10 @@ final class RowOperationsManager { return UndoApplicationResult(adjustedSelection: nil, delta: delta) } else if result.needsRowRestore { var insertedIndices = IndexSet() - for (index, rowIndex) in rowIndices.enumerated() { - guard index < rowValues.count else { continue } - guard rowIndex == tableRows.count else { continue } - _ = tableRows.appendInsertedRow(values: rowValues[index]) + let pairs = zip(rowIndices, rowValues).sorted { $0.0 < $1.0 } + for (rowIndex, values) in pairs { + guard rowIndex >= 0, rowIndex <= tableRows.count else { continue } + _ = tableRows.insertInsertedRow(at: rowIndex, values: values) insertedIndices.insert(rowIndex) } guard !insertedIndices.isEmpty else { diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index e16647f02..0ad431512 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -72,6 +72,15 @@ struct TableRows: Sendable { return .rowsInserted(IndexSet(integer: rows.count - 1)) } + @discardableResult + mutating func insertInsertedRow(at index: Int, values: [String?]) -> Delta { + guard index >= 0, index <= rows.count else { return .none } + let normalized = Self.normalize(values: values, toCount: columns.count) + let row = Row(id: .inserted(UUID()), values: normalized) + rows.insert(row, at: index) + return .rowsInserted(IndexSet(integer: index)) + } + @discardableResult mutating func appendPage(_ pageRows: [[String?]], startingAt offset: Int) -> Delta { guard !pageRows.isEmpty else { return .none } diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index 453752c85..433e1e23b 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -207,6 +207,104 @@ struct TableRowsInsertTests { #expect(table.rows[0].values == ["only-one", nil, nil]) #expect(table.rows[1].values == ["a", "b", "c"]) } + + @Test("insertInsertedRow at the head shifts existing rows down") + func insertInsertedRowAtHead() { + var table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: 0, values: ["z"]) + #expect(delta == .rowsInserted(IndexSet(integer: 0))) + #expect(table.count == 3) + #expect(table.rows[0].values == ["z"]) + #expect(table.rows[0].id.isInserted) + #expect(table.rows[1].values == ["a"]) + #expect(table.rows[2].values == ["b"]) + } + + @Test("insertInsertedRow in the middle preserves surrounding rows") + func insertInsertedRowInMiddle() { + var table = TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: 1, values: ["z"]) + #expect(delta == .rowsInserted(IndexSet(integer: 1))) + #expect(table.count == 4) + #expect(table.rows[0].values == ["a"]) + #expect(table.rows[1].values == ["z"]) + #expect(table.rows[1].id.isInserted) + #expect(table.rows[2].values == ["b"]) + #expect(table.rows[3].values == ["c"]) + } + + @Test("insertInsertedRow at the tail (index == count) appends") + func insertInsertedRowAtTail() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: table.count, values: ["z"]) + #expect(delta == .rowsInserted(IndexSet(integer: 1))) + #expect(table.count == 2) + #expect(table.rows[1].values == ["z"]) + #expect(table.rows[1].id.isInserted) + } + + @Test("insertInsertedRow pads short values and truncates long values") + func insertInsertedRowPadsAndTruncates() { + var table = TableRows.from( + queryRows: [], + columns: ["c1", "c2", "c3"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil)] + ) + _ = table.insertInsertedRow(at: 0, values: ["only-one"]) + _ = table.insertInsertedRow(at: 1, values: ["a", "b", "c", "d"]) + #expect(table.rows[0].values == ["only-one", nil, nil]) + #expect(table.rows[1].values == ["a", "b", "c"]) + } + + @Test("insertInsertedRow with negative index returns Delta.none and does not mutate") + func insertInsertedRowNegativeIndexIsNoOp() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: -1, values: ["z"]) + #expect(delta == .none) + #expect(table.count == 1) + #expect(table.rows[0].values == ["a"]) + } + + @Test("insertInsertedRow past the end returns Delta.none and does not mutate") + func insertInsertedRowPastEndIsNoOp() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let delta = table.insertInsertedRow(at: 2, values: ["z"]) + #expect(delta == .none) + #expect(table.count == 1) + #expect(table.rows[0].values == ["a"]) + } + + @Test("Two insertInsertedRow calls produce different RowID UUIDs") + func insertInsertedRowProducesDistinctUUIDs() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.insertInsertedRow(at: 0, values: ["x"]) + _ = table.insertInsertedRow(at: 0, values: ["y"]) + #expect(table.rows[0].id != table.rows[1].id) + } } @Suite("TableRows - appendPage") From 0b7ea8351456dd6a36066f7fb50ca02da9d603a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 22:20:41 +0700 Subject: [PATCH 10/31] refactor(datagrid): sort moves to controller keyed by Row.id --- CHANGELOG.md | 2 +- TablePro/Models/Query/TableRows.swift | 12 +++ .../Main/Child/MainEditorContentView.swift | 56 +++++------ .../Views/Main/MainContentCoordinator.swift | 40 ++++---- .../Views/Results/DataGridCoordinator.swift | 56 ++++++++++- TablePro/Views/Results/DataGridView.swift | 4 + .../Extensions/DataGridView+CellCommit.swift | 49 +++++----- .../Extensions/DataGridView+Columns.swift | 13 ++- .../Extensions/DataGridView+Editing.swift | 28 ++++-- .../Models/Query/TableRowsTests.swift | 97 +++++++++++++++++++ 10 files changed, 265 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e9cf9c1..705eeeaef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. Row operations (add, duplicate, delete, paste, undo) mutate TableRows and apply the returned Delta through TableViewCoordinator.applyDelta. RowBuffer still backs sorting and the display cache pending later phases (Phase C.2 of the DataGrid refactor). +- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. Row operations (add, duplicate, delete, paste, undo) mutate TableRows and apply the returned Delta through TableViewCoordinator.applyDelta. Sort state moved from InMemoryRowProvider's positional sortIndices to a TableViewCoordinator.sortedIDs permutation keyed by Row.id, so cell edits under sort hit the correct storage row and inserted rows survive at the end of the sorted view without re-sorting. RowBuffer still backs the display cache pending later phases (Phase C.2 of the DataGrid refactor). - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 0ad431512..81d1842aa 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -39,6 +39,18 @@ struct TableRows: Sendable { return rows[row][column] } + func index(of id: RowID) -> Int? { + for (index, row) in rows.enumerated() where row.id == id { + return index + } + return nil + } + + func row(withID id: RowID) -> Row? { + guard let index = index(of: id) else { return nil } + return rows[index] + } + @discardableResult mutating func edit(row: Int, column: Int, value: String?) -> Delta { guard row >= 0, row < rows.count else { return .none } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 8304d6533..f9f9fff35 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -10,9 +10,11 @@ import AppKit import CodeEditSourceEditor import SwiftUI -/// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation +/// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation. +/// Stores a permutation of `RowID` so the grid keeps the same display order even after +/// inserts and deletes mutate the underlying TableRows storage. private struct SortedRowsCache { - let sortedIndices: [Int] + let sortedIDs: [RowID] let columnIndex: Int let direction: SortDirection let schemaVersion: Int @@ -578,6 +580,7 @@ struct MainEditorContentView: View { showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers, hiddenColumns: columnVisibilityManager.hiddenColumns ), + sortedIDs: sortedIDsForTab(tab), delegate: dataTabDelegate, selectedRowIndices: Binding( get: { selectionState.indices }, @@ -633,7 +636,6 @@ struct MainEditorContentView: View { if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { provider = InMemoryRowProvider( rowBuffer: rs.rowBuffer, - sortIndices: sortIndicesForTab(tab), columns: rs.resultColumns, columnDefaults: rs.columnDefaults, columnTypes: rs.columnTypes, @@ -645,7 +647,6 @@ struct MainEditorContentView: View { let buffer = coordinator.rowDataStore.buffer(for: tab.id) provider = InMemoryRowProvider( rowBuffer: buffer, - sortIndices: sortIndicesForTab(tab), columns: buffer.columns, columnDefaults: buffer.columnDefaults, columnTypes: buffer.columnTypes, @@ -715,73 +716,66 @@ struct MainEditorContentView: View { } } - /// Returns sort index permutation for a tab, or nil if no sorting is needed. + /// Returns the display order as a permutation of `RowID`, or nil when no sort applies. /// For table tabs, sorting is handled server-side via SQL ORDER BY. - private func sortIndicesForTab(_ tab: QueryTab) -> [Int]? { - // Resolve data source: active ResultSet or tab-level fallback + private func sortedIDsForTab(_ tab: QueryTab) -> [RowID]? { let rowBuffer: RowBuffer - let rows: [[String?]] let colTypes: [ColumnType] if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { rowBuffer = rs.rowBuffer - rows = rs.resultRows colTypes = rs.columnTypes } else { let buffer = coordinator.rowDataStore.buffer(for: tab.id) rowBuffer = buffer - rows = buffer.rows colTypes = buffer.columnTypes } guard !rowBuffer.isEvicted else { return nil } - // Table tabs: no client-side sorting if tab.tabType == .table { return nil } - // Query tabs: apply client-side sorting guard tab.sortState.isSorting else { return nil } - // Check coordinator's async sort cache (for large datasets sorted on background thread) + guard let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id), + !tableRows.rows.isEmpty else { + return nil + } + if let cached = coordinator.querySortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, cached.schemaVersion == tab.schemaVersion { - return cached.sortedIndices + return cached.sortedIDs } - // For datasets sorted async, return nil (unsorted) until cache is ready - if rows.count > 1_000 { + if tableRows.rows.count > 1_000 { return nil } - // Small dataset: sort synchronously with view-level cache if let cached = sortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, cached.schemaVersion == tab.schemaVersion { - return cached.sortedIndices + return cached.sortedIDs } let sortColumns = tab.sortState.columns - let indices = Array(rows.indices) - let sortedIndices = indices.sorted { idx1, idx2 in - let row1 = rows[idx1] - let row2 = rows[idx2] + let storageRows = tableRows.rows + let sortedIndices = Array(storageRows.indices).sorted { idx1, idx2 in + let row1 = storageRows[idx1].values + let row2 = storageRows[idx2].values for sortCol in sortColumns { - let val1 = - sortCol.columnIndex < row1.count + let val1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex] ?? "") : "" - let val2 = - sortCol.columnIndex < row2.count + let val2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex] ?? "") : "" - let colType = - sortCol.columnIndex < colTypes.count + let colType = sortCol.columnIndex < colTypes.count ? colTypes[sortCol.columnIndex] : nil let result = RowSortComparator.compare(val1, val2, columnType: colType) if result == .orderedSame { continue } @@ -791,16 +785,16 @@ struct MainEditorContentView: View { } return false } + let sortedIDs = sortedIndices.map { storageRows[$0].id } - // Cache the result sortCache[tab.id] = SortedRowsCache( - sortedIndices: sortedIndices, + sortedIDs: sortedIDs, columnIndex: tab.sortState.columnIndex ?? -1, direction: tab.sortState.direction, schemaVersion: tab.schemaVersion ) - return sortedIndices + return sortedIDs } private func sortStateBinding(for tab: QueryTab) -> Binding { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 83fa7b10a..ffd20f8e3 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -21,9 +21,11 @@ enum DiscardAction { case filter } -/// Cache entry for async-sorted query tab rows (stores index permutation, not row copies) +/// Cache entry for async-sorted query tab rows. Stores a permutation of `RowID` so the +/// sort survives mutations: inserted rows append to the end of the sorted view, and +/// removed rows are dropped from the permutation without re-sorting. struct QuerySortCacheEntry { - let sortedIndices: [Int] + let sortedIDs: [RowID] let columnIndex: Int let direction: SortDirection let schemaVersion: Int @@ -1357,13 +1359,14 @@ final class MainContentCoordinator { tabManager.tabs[tabIndex].sortState = currentSort tabManager.tabs[tabIndex].hasUserInteraction = true tabManager.tabs[tabIndex].pagination.reset() - let rows = buffer.rows let tabId = tab.id let schemaVersion = tab.schemaVersion let sortColumns = currentSort.columns let colTypes = buffer.columnTypes + let storageRows = tableRowsStore.existingTableRows(for: tabId)?.rows ?? [] + let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, $0.values) } - if rows.count > 1_000 { + if storageRows.count > 1_000 { // Sort on background thread to avoid UI freeze activeSortTasks[tabId]?.cancel() activeSortTasks.removeValue(forKey: tabId) @@ -1373,8 +1376,8 @@ final class MainContentCoordinator { let sortStartTime = Date() let task = Task.detached { [weak self] in - let sortedIndices = Self.multiColumnSortIndices( - rows: rows, + let sortedIDs = Self.multiColumnSortedIDs( + rows: snapshotRows, sortColumns: sortColumns, columnTypes: colTypes ) @@ -1388,7 +1391,7 @@ final class MainContentCoordinator { return } self.querySortCache[tabId] = QuerySortCacheEntry( - sortedIndices: sortedIndices, + sortedIDs: sortedIDs, columnIndex: sortColumns.first?.columnIndex ?? 0, direction: sortColumns.first?.direction ?? .ascending, schemaVersion: schemaVersion @@ -1430,13 +1433,12 @@ final class MainContentCoordinator { } } - /// Multi-column sort returning index permutation (nonisolated for background thread). - /// Returns an array of indices into the original `rows` array, sorted by the given columns. - nonisolated private static func multiColumnSortIndices( - rows: [[String?]], + /// Multi-column sort returning a permutation of `RowID` (nonisolated for background thread). + nonisolated private static func multiColumnSortedIDs( + rows: [(id: RowID, values: [String?])], sortColumns: [SortColumn], columnTypes: [ColumnType] = [] - ) -> [Int] { + ) -> [RowID] { // Fast path: single-column sort avoids intermediate key array allocation if sortColumns.count == 1 { let col = sortColumns[0] @@ -1445,18 +1447,20 @@ final class MainContentCoordinator { let colType = colIndex < columnTypes.count ? columnTypes[colIndex] : nil var indices = Array(0.. Void) -> Void = { _ in } var changeManager: AnyChangeManager var isEditable: Bool + var sortedIDs: [RowID]? weak var delegate: (any DataGridViewDelegate)? weak var activeFKPreviewPopover: NSPopover? var dropdownColumns: Set? @@ -201,6 +202,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData rowVisualStateCache.removeAll() cachedRowCount = 0 cachedColumnCount = 0 + sortedIDs = nil // Remove columns and reload to release cell views if let tableView { while let col = tableView.tableColumns.last { @@ -259,6 +261,26 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData lastIdentity = nil } + func displayRow(at displayIndex: Int) -> Row? { + let tableRows = tableRowsProvider() + if let sorted = sortedIDs { + guard displayIndex >= 0, displayIndex < sorted.count else { return nil } + return tableRows.row(withID: sorted[displayIndex]) + } + guard displayIndex >= 0, displayIndex < tableRows.count else { return nil } + return tableRows.rows[displayIndex] + } + + func tableRowsIndex(forDisplayRow displayIndex: Int) -> Int? { + if let sorted = sortedIDs { + guard displayIndex >= 0, displayIndex < sorted.count else { return nil } + return tableRowsProvider().index(of: sorted[displayIndex]) + } + let count = tableRowsProvider().count + guard displayIndex >= 0, displayIndex < count else { return nil } + return displayIndex + } + func applyDelta(_ delta: Delta) { switch delta { case .cellChanged(let row, let column): @@ -287,15 +309,37 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) case .rowsInserted(let indices): guard !indices.isEmpty else { return } + appendInsertedIDsToSortedIDs(at: indices) applyInsertedRows(indices) case .rowsRemoved(let indices): guard !indices.isEmpty else { return } + removeMissingIDsFromSortedIDs() applyRemovedRows(indices) case .columnsReplaced, .fullReplace: + sortedIDs = nil applyFullReplace() } } + private func appendInsertedIDsToSortedIDs(at indices: IndexSet) { + guard sortedIDs != nil else { return } + let tableRows = tableRowsProvider() + for index in indices where index >= 0 && index < tableRows.count { + sortedIDs?.append(tableRows.rows[index].id) + } + } + + private func removeMissingIDsFromSortedIDs() { + guard sortedIDs != nil else { return } + let tableRows = tableRowsProvider() + var survivingIDs = Set() + survivingIDs.reserveCapacity(tableRows.count) + for row in tableRows.rows { + survivingIDs.insert(row.id) + } + sortedIDs?.removeAll { !survivingIDs.contains($0) } + } + func invalidateCachesForUndoRedo() { rowProvider.invalidateDisplayCache() rebuildVisualStateCache() @@ -369,8 +413,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let tableRows = tableRowsProvider() var insertedRowIndices = Set() - for (index, row) in tableRows.rows.enumerated() where row.id.isInserted { - insertedRowIndices.insert(index) + if let sorted = sortedIDs { + for (displayIndex, id) in sorted.enumerated() where id.isInserted { + insertedRowIndices.insert(displayIndex) + } + } else { + for (index, row) in tableRows.rows.enumerated() where row.id.isInserted { + insertedRowIndices.insert(index) + } } if !changeManager.hasChanges && insertedRowIndices.isEmpty { @@ -413,6 +463,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - NSTableViewDataSource func numberOfRows(in tableView: NSTableView) -> Int { - tableRowsProvider().count + sortedIDs?.count ?? tableRowsProvider().count } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 43d04cea7..72c45133b 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -65,6 +65,7 @@ struct DataGridView: NSViewRepresentable { var paginationVersion: Int = 0 let isEditable: Bool var configuration: DataGridConfiguration = .init() + var sortedIDs: [RowID]? var delegate: (any DataGridViewDelegate)? @Binding var selectedRowIndices: Set @@ -183,6 +184,7 @@ struct DataGridView: NSViewRepresentable { context.coordinator.tableRowsController.attach(tableView) context.coordinator.tableRowsProvider = tableRowsProvider context.coordinator.tableRowsMutator = tableRowsMutator + context.coordinator.sortedIDs = sortedIDs context.coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns @@ -254,6 +256,7 @@ struct DataGridView: NSViewRepresentable { coordinator.delegate = delegate coordinator.tableRowsProvider = tableRowsProvider coordinator.tableRowsMutator = tableRowsMutator + coordinator.sortedIDs = sortedIDs delegate?.dataGridAttach(tableViewCoordinator: coordinator) return } @@ -312,6 +315,7 @@ struct DataGridView: NSViewRepresentable { coordinator.isEditable = isEditable coordinator.tableRowsProvider = tableRowsProvider coordinator.tableRowsMutator = tableRowsMutator + coordinator.sortedIDs = sortedIDs coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 740d31c61..f66ded023 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -10,17 +10,24 @@ extension TableViewCoordinator { guard let tableView else { return } guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } - let tableRows = tableRowsProvider() - let usesTableRows = row >= 0 && row < tableRows.rows.count - let oldValue: String? = usesTableRows - ? tableRows.value(at: row, column: columnIndex) - : rowProvider.value(atRow: row, column: columnIndex) + let storageRow = tableRowsIndex(forDisplayRow: row) + let displayRowValues = displayRow(at: row) + let usesTableRows = storageRow != nil && displayRowValues != nil + let oldValue: String? = { + if let displayRowValues, columnIndex < displayRowValues.values.count { + return displayRowValues.values[columnIndex] + } + return rowProvider.value(atRow: row, column: columnIndex) + }() guard oldValue != newValue else { return } let columnName = rowProvider.columns[columnIndex] - let originalRow: [String?] = usesTableRows - ? tableRows.rows[row].values - : (rowProvider.rowValues(at: row) ?? []) + let originalRow: [String?] = { + if let displayRowValues { + return displayRowValues.values + } + return rowProvider.rowValues(at: row) ?? [] + }() changeManager.recordCellChange( rowIndex: row, columnIndex: columnIndex, @@ -31,14 +38,20 @@ extension TableViewCoordinator { ) var delta: Delta = .none - tableRowsMutator { tableRows in - delta = tableRows.edit(row: row, column: columnIndex, value: newValue) + if let storageRow { + tableRowsMutator { tableRows in + delta = tableRows.edit(row: storageRow, column: columnIndex, value: newValue) + } } delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) rowProvider.invalidateDisplayCache() if usesTableRows, case .cellChanged = delta { - tableRowsController.apply(translatedColumnDelta(delta)) + let displayDelta: Delta = .cellChanged( + row: row, + column: DataGridView.tableColumnIndex(for: columnIndex) + ) + tableRowsController.apply(displayDelta) } else { let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) tableView.reloadData( @@ -47,18 +60,4 @@ extension TableViewCoordinator { ) } } - - private func translatedColumnDelta(_ delta: Delta) -> Delta { - switch delta { - case .cellChanged(let row, let column): - return .cellChanged(row: row, column: DataGridView.tableColumnIndex(for: column)) - case .cellsChanged(let positions): - let translated = Set(positions.map { - CellPosition(row: $0.row, column: DataGridView.tableColumnIndex(for: $0.column)) - }) - return .cellsChanged(translated) - default: - return delta - } - } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index ade8a6583..5aee871f2 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -12,24 +12,29 @@ extension TableViewCoordinator { let columnId = column.identifier.rawValue let tableRows = tableRowsProvider() + let displayCount = sortedIDs?.count ?? tableRows.count if columnId == "__rowNumber__" { return cellFactory.makeRowNumberCell( tableView: tableView, row: row, - cachedRowCount: tableRows.count, + cachedRowCount: displayCount, visualState: visualState(for: row) ) } guard let columnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return nil } - guard row >= 0 && row < tableRows.count, + guard row >= 0 && row < displayCount, columnIndex >= 0 && columnIndex < cachedColumnCount else { return nil } - let rawValue = tableRows.value(at: row, column: columnIndex) + guard let displayRow = displayRow(at: row), + columnIndex < displayRow.values.count else { + return nil + } + let rawValue = displayRow.values[columnIndex] let displayValue = resolveDisplayValue( row: row, columnIndex: columnIndex, @@ -96,7 +101,7 @@ extension TableViewCoordinator { rawValue: String?, tableRows: TableRows ) -> String? { - if row < rowProvider.totalRowCount { + if sortedIDs == nil, row < rowProvider.totalRowCount { return rowProvider.displayValue(atRow: row, column: columnIndex) } let columnType = columnIndex < tableRows.columnTypes.count diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 763c13a1d..1f9db3ca7 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -37,7 +37,9 @@ extension TableViewCoordinator { if dropdownColumns?.contains(columnIndex) == true { return .blocked } if typePickerColumns?.contains(columnIndex) == true { return .blocked } - if let value = rowProvider.value(atRow: row, column: columnIndex) { + if let displayRow = displayRow(at: row), + columnIndex < displayRow.values.count, + let value = displayRow.values[columnIndex] { if value.containsLineBreak { return .needsOverlayEditor(value: value) } if value.looksLikeJson { return .blocked } } @@ -122,7 +124,9 @@ extension TableViewCoordinator { // Check if next cell is also multiline → open overlay there let nextColumnIndex = nextColumn - 1 if nextColumnIndex >= 0, nextColumnIndex < rowProvider.columns.count, - let value = rowProvider.value(atRow: nextRow, column: nextColumnIndex), + let nextDisplayRow = displayRow(at: nextRow), + nextColumnIndex < nextDisplayRow.values.count, + let value = nextDisplayRow.values[nextColumnIndex], value.containsLineBreak { showOverlayEditor(tableView: tableView, row: nextRow, column: nextColumn, columnIndex: nextColumnIndex, value: value) } else { @@ -142,20 +146,24 @@ extension TableViewCoordinator { if isEscapeCancelling { isEscapeCancelling = false - let cancelTableRows = tableRowsProvider() - let originalValue: String? = row >= 0 && row < cancelTableRows.rows.count - ? cancelTableRows.value(at: row, column: columnIndex) - : rowProvider.value(atRow: row, column: columnIndex) + let originalValue: String? = { + if let displayRow = displayRow(at: row), columnIndex < displayRow.values.count { + return displayRow.values[columnIndex] + } + return rowProvider.value(atRow: row, column: columnIndex) + }() textField.stringValue = originalValue ?? "" (control as? CellTextField)?.restoreTruncatedDisplay() return true } let rawInput = textField.stringValue - let tableRows = tableRowsProvider() - let oldValue: String? = row >= 0 && row < tableRows.rows.count - ? tableRows.value(at: row, column: columnIndex) - : rowProvider.value(atRow: row, column: columnIndex) + let oldValue: String? = { + if let displayRow = displayRow(at: row), columnIndex < displayRow.values.count { + return displayRow.values[columnIndex] + } + return rowProvider.value(atRow: row, column: columnIndex) + }() let newValue: String? = rawInput.isEmpty && oldValue == nil ? nil : rawInput commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index 433e1e23b..ab2187010 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -80,6 +80,103 @@ struct TableRowsReadTests { } } +@Suite("TableRows - id lookup") +struct TableRowsIDLookupTests { + @Test("index(of:) returns the storage index for an existing RowID") + func indexOfExistingRowID() { + let table = TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.index(of: .existing(0)) == 0) + #expect(table.index(of: .existing(1)) == 1) + #expect(table.index(of: .existing(2)) == 2) + } + + @Test("index(of:) returns nil for an unknown RowID") + func indexOfUnknownRowID() { + let table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.index(of: .existing(99)) == nil) + #expect(table.index(of: .inserted(UUID())) == nil) + } + + @Test("index(of:) tracks inserted rows by their UUID after appendInsertedRow") + func indexOfInsertedRow() { + var table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["new"]) + let insertedID = table.rows[1].id + #expect(table.index(of: insertedID) == 1) + } + + @Test("index(of:) reflects shifted positions after insertInsertedRow at the head") + func indexOfShiftsAfterHeadInsert() { + var table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let originalID = table.rows[0].id + _ = table.insertInsertedRow(at: 0, values: ["z"]) + #expect(table.index(of: originalID) == 1) + } + + @Test("index(of:) reflects shifted positions after remove") + func indexOfShiftsAfterRemove() { + var table = TableRows.from( + queryRows: [["a"], ["b"], ["c"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.remove(at: IndexSet(integer: 0)) + #expect(table.index(of: .existing(0)) == nil) + #expect(table.index(of: .existing(1)) == 0) + #expect(table.index(of: .existing(2)) == 1) + } + + @Test("row(withID:) returns the matching Row for an existing ID") + func rowWithIDReturnsMatch() { + let table = TableRows.from( + queryRows: [["a"], ["b"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + let row = table.row(withID: .existing(1)) + #expect(row?.values == ["b"]) + #expect(row?.id == .existing(1)) + } + + @Test("row(withID:) returns nil for an unknown ID") + func rowWithIDReturnsNilForUnknown() { + let table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + #expect(table.row(withID: .existing(99)) == nil) + } + + @Test("row(withID:) reads back an inserted row by its UUID") + func rowWithIDReturnsInsertedRow() { + var table = TableRows.from( + queryRows: [], + columns: ["c1"], + columnTypes: [.text(rawType: nil)] + ) + _ = table.appendInsertedRow(values: ["v"]) + let insertedID = table.rows[0].id + #expect(table.row(withID: insertedID)?.values == ["v"]) + } +} + @Suite("TableRows - edit") struct TableRowsEditTests { private static func makeTable() -> TableRows { From 2d941b414d116b1c1e9ba9ae2cf0cb1a39cb212c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 22:35:11 +0700 Subject: [PATCH 11/31] refactor(datagrid): display cache moves to coordinator keyed by Row.id --- .../Main/Child/MainEditorContentView.swift | 20 ++-- .../Views/Results/DataGridCoordinator.swift | 92 ++++++++++++++++++- .../Results/DataGridView+RowActions.swift | 48 +++++----- .../Results/DataGridView+TypePicker.swift | 2 +- TablePro/Views/Results/DataGridView.swift | 7 +- .../Extensions/DataGridView+CellCommit.swift | 30 ++---- .../Extensions/DataGridView+Click.swift | 48 +++++----- .../Extensions/DataGridView+Columns.swift | 34 ++----- .../Extensions/DataGridView+Editing.swift | 28 +++--- .../Extensions/DataGridView+Popovers.swift | 58 ++++++++---- .../Extensions/DataGridView+Sort.swift | 5 +- .../Views/Results/KeyHandlingTableView.swift | 3 +- .../Views/Results/TableRowViewWithMenu.swift | 26 +++--- 13 files changed, 238 insertions(+), 163 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index f9f9fff35..ec75d2433 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -581,6 +581,7 @@ struct MainEditorContentView: View { hiddenColumns: columnVisibilityManager.hiddenColumns ), sortedIDs: sortedIDsForTab(tab), + displayFormats: displayFormats(for: tab), delegate: dataTabDelegate, selectedRowIndices: Binding( get: { selectionState.indices }, @@ -656,23 +657,21 @@ struct MainEditorContentView: View { ) } - applyDisplayFormats(to: provider, tab: tab) return provider } - private func applyDisplayFormats(to provider: InMemoryRowProvider, tab: QueryTab) { - let columns = provider.columns - let columnTypes = provider.columnTypes - guard !columns.isEmpty else { return } + private func displayFormats(for tab: QueryTab) -> [ValueDisplayFormat?] { + let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id) + let columns = tableRows?.columns ?? [] + let columnTypes = tableRows?.columnTypes ?? [] + guard !columns.isEmpty else { return [] } let settings = AppSettingsManager.shared.dataGrid let service = ValueDisplayFormatService.shared - // Auto-detect formats when the setting is enabled var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) if settings.enableSmartValueDetection { let sampleRows: [[String?]]? = { - let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id) let rows = tableRows?.rows.prefix(10).map(\.values) ?? [] return rows.isEmpty ? nil : Array(rows) }() @@ -682,7 +681,6 @@ struct MainEditorContentView: View { sampleValues: sampleRows ) - // Update service's auto-detected formats var autoMap: [String: ValueDisplayFormat] = [:] for (i, format) in detected.enumerated() where i < columns.count { if let format { @@ -694,7 +692,6 @@ struct MainEditorContentView: View { service.clearAutoDetectedFormats(connectionId: connectionId, tableName: tab.tableContext.tableName) } - // Merge with stored overrides (override > detection > nil) let connId = connectionId let tblName = tab.tableContext.tableName var merged = detected @@ -710,10 +707,7 @@ struct MainEditorContentView: View { } } - // Only set if there's at least one non-nil format - if merged.contains(where: { $0 != nil }) { - provider.updateDisplayFormats(merged) - } + return merged.contains(where: { $0 != nil }) ? merged : [] } /// Returns the display order as a permutation of `RowID`, or nil when no sort applies. diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 75bb70aaa..90177995d 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -21,6 +21,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var changeManager: AnyChangeManager var isEditable: Bool var sortedIDs: [RowID]? + private(set) var columnDisplayFormats: [ValueDisplayFormat?] = [] + private var displayCache: [RowID: [String?]] = [:] weak var delegate: (any DataGridViewDelegate)? weak var activeFKPreviewPopover: NSPopover? var dropdownColumns: Set? @@ -150,11 +152,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // When smart detection is toggled off, clear display formats so they stop being applied if prev.enableSmartValueDetection != settings.enableSmartValueDetection && !settings.enableSmartValueDetection { - self.rowProvider.updateDisplayFormats([]) + self.updateDisplayFormats([]) } if dataChanged { - self.rowProvider.invalidateDisplayCache() + self.invalidateDisplayCache() let visibleRect = tableView.visibleRect let visibleRange = tableView.rows(in: visibleRect) if visibleRange.length > 0 { @@ -200,6 +202,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData overlayEditor?.dismiss(commit: false) rowProvider = InMemoryRowProvider(rows: [], columns: []) rowVisualStateCache.removeAll() + displayCache.removeAll() + columnDisplayFormats = [] cachedRowCount = 0 cachedColumnCount = 0 sortedIDs = nil @@ -254,7 +258,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyFullReplace() { guard let tableView else { return } - rowProvider.invalidateDisplayCache() + displayCache.removeAll() rebuildVisualStateCache() updateCache() tableView.reloadData() @@ -281,6 +285,82 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return displayIndex } + func displayValue(forID id: RowID, column: Int, rawValue: String?, columnType: ColumnType?) -> String? { + if let cachedRow = displayCache[id], column >= 0, column < cachedRow.count, let cached = cachedRow[column] { + return cached + } + let format = column >= 0 && column < columnDisplayFormats.count ? columnDisplayFormats[column] : nil + let formatted = CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: format) ?? rawValue + + var rowCache = displayCache[id] ?? [] + let neededCount = max(column + 1, columnDisplayFormats.count) + if rowCache.count < neededCount { + rowCache.append(contentsOf: Array(repeating: nil, count: neededCount - rowCache.count)) + } + if column >= 0, column < rowCache.count { + rowCache[column] = formatted + } + displayCache[id] = rowCache + return formatted + } + + func invalidateDisplayCache() { + displayCache.removeAll() + } + + func updateDisplayFormats(_ formats: [ValueDisplayFormat?]) { + columnDisplayFormats = formats + displayCache.removeAll() + } + + func syncDisplayFormats(_ formats: [ValueDisplayFormat?]) { + guard formats != columnDisplayFormats else { return } + columnDisplayFormats = formats + displayCache.removeAll() + } + + func preWarmDisplayCache(upTo rowCount: Int) { + let tableRows = tableRowsProvider() + let displayCount = sortedIDs?.count ?? tableRows.count + let count = min(rowCount, displayCount) + guard count > 0 else { return } + for displayIndex in 0..() + aliveIDs.reserveCapacity(tableRows.count) + for row in tableRows.rows { + aliveIDs.insert(row.id) + } + displayCache = displayCache.filter { aliveIDs.contains($0.key) } + } + + private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) { + guard let row = displayRow(at: displayIndex) else { return } + guard var rowCache = displayCache[row.id], column >= 0, column < rowCache.count else { return } + rowCache[column] = nil + displayCache[row.id] = rowCache + } + func applyDelta(_ delta: Delta) { switch delta { case .cellChanged(let row, let column): @@ -288,6 +368,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let tableColumn = DataGridView.tableColumnIndex(for: column) guard row >= 0, row < tableView.numberOfRows else { return } guard tableColumn >= 0, tableColumn < tableView.numberOfColumns else { return } + invalidateDisplayCache(forDisplayRow: row, column: column) tableView.reloadData( forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: tableColumn) @@ -304,6 +385,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if tableColumn >= 0, tableColumn < tableView.numberOfColumns { colSet.insert(tableColumn) } + invalidateDisplayCache(forDisplayRow: position.row, column: position.column) } guard !rowSet.isEmpty, !colSet.isEmpty else { return } tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) @@ -314,9 +396,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData case .rowsRemoved(let indices): guard !indices.isEmpty else { return } removeMissingIDsFromSortedIDs() + pruneDisplayCacheToAliveIDs() applyRemovedRows(indices) case .columnsReplaced, .fullReplace: sortedIDs = nil + displayCache.removeAll() applyFullReplace() } } @@ -341,7 +425,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func invalidateCachesForUndoRedo() { - rowProvider.invalidateDisplayCache() + displayCache.removeAll() rebuildVisualStateCache() updateCache() } diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 78f836dc7..149495cfb 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -33,12 +33,13 @@ extension TableViewCoordinator { func copyRows(at indices: Set) { let sortedIndices = indices.sorted() - let columnTypes = rowProvider.columnTypes + let tableRows = tableRowsProvider() + let columnTypes = tableRows.columnTypes var tsvRows: [String] = [] var htmlRows: [[String]] = [] for index in sortedIndices { - guard let values = rowProvider.rowValues(at: index) else { continue } + guard let values = displayRow(at: index)?.values else { continue } let formatted = formatRowValues(values: values, columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) @@ -51,13 +52,14 @@ extension TableViewCoordinator { func copyRowsWithHeaders(at indices: Set) { let sortedIndices = indices.sorted() - let columnTypes = rowProvider.columnTypes - let columns = rowProvider.columns + let tableRows = tableRowsProvider() + let columnTypes = tableRows.columnTypes + let columns = tableRows.columns var tsvRows: [String] = [columns.joined(separator: "\t")] var htmlRows: [[String]] = [] for index in sortedIndices { - guard let values = rowProvider.rowValues(at: index) else { continue } + guard let values = displayRow(at: index)?.values else { continue } let formatted = formatRowValues(values: values, columnTypes: columnTypes) tsvRows.append(formatted.joined(separator: "\t")) htmlRows.append(formatted) @@ -82,15 +84,15 @@ extension TableViewCoordinator { } func copyCellValue(at rowIndex: Int, columnIndex: Int) { - guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } + guard let row = displayRow(at: rowIndex), columnIndex < row.values.count else { return } - let value = rowProvider.value(atRow: rowIndex, column: columnIndex) ?? "NULL" - let columnTypes = rowProvider.columnTypes + let value = row.values[columnIndex] ?? "NULL" + let columnTypes = tableRows.columnTypes let columnType = columnTypes.indices.contains(columnIndex) ? columnTypes[columnIndex] : nil - // Use formatted value when a display format is active - let formats = rowProvider.columnDisplayFormats - if columnIndex < formats.count, let format = formats[columnIndex], format != .raw { + if columnIndex < columnDisplayFormats.count, let format = columnDisplayFormats[columnIndex], format != .raw { let formatted = ValueDisplayFormatService.applyFormat(value, format: format) ClipboardService.shared.writeText(formatted) return @@ -102,41 +104,44 @@ extension TableViewCoordinator { func copyRowsAsInsert(at indices: Set) { guard let tableName, let databaseType else { return } + let tableRows = tableRowsProvider() let driver = resolveDriver() let converter = SQLRowToStatementConverter( tableName: tableName, - columns: rowProvider.columns, + columns: tableRows.columns, primaryKeyColumn: primaryKeyColumn, databaseType: databaseType, quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) } + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateInserts(rows: rows)) } func copyRowsAsUpdate(at indices: Set) { guard let tableName, let databaseType else { return } + let tableRows = tableRowsProvider() let driver = resolveDriver() let converter = SQLRowToStatementConverter( tableName: tableName, - columns: rowProvider.columns, + columns: tableRows.columns, primaryKeyColumn: primaryKeyColumn, databaseType: databaseType, quoteIdentifier: driver?.quoteIdentifier, escapeStringLiteral: driver?.escapeStringLiteral ) - let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) } + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } guard !rows.isEmpty else { return } ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) } func copyRowsAsJson(at indices: Set) { - let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) } + let rows = indices.sorted().compactMap { displayRow(at: $0)?.values } guard !rows.isEmpty else { return } - let columnTypes = rowProvider.columnTypes - let converter = JsonRowConverter(columns: rowProvider.columns, columnTypes: columnTypes) + let tableRows = tableRowsProvider() + let columnTypes = tableRows.columnTypes + let converter = JsonRowConverter(columns: tableRows.columns, columnTypes: columnTypes) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } @@ -162,11 +167,12 @@ extension TableViewCoordinator { let item = NSPasteboardItem() item.setString(String(row), forType: Self.rowDragType) - if let values = rowProvider.rowValues(at: row) { - let formatted = formatRowValues(values: values, columnTypes: rowProvider.columnTypes) + if let values = displayRow(at: row)?.values { + let tableRows = tableRowsProvider() + let formatted = formatRowValues(values: values, columnTypes: tableRows.columnTypes) item.setString(formatted.joined(separator: "\t"), forType: .string) item.setString( - HtmlTableEncoder.encode(rows: [formatted], headers: rowProvider.columns), + HtmlTableEncoder.encode(rows: [formatted], headers: tableRows.columns), forType: .html ) } diff --git a/TablePro/Views/Results/DataGridView+TypePicker.swift b/TablePro/Views/Results/DataGridView+TypePicker.swift index 5a915ac75..96c917b1b 100644 --- a/TablePro/Views/Results/DataGridView+TypePicker.swift +++ b/TablePro/Views/Results/DataGridView+TypePicker.swift @@ -17,7 +17,7 @@ extension TableViewCoordinator { ) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) ?? "" + let currentValue = cellValue(at: row, column: columnIndex) ?? "" let dbType = databaseType ?? .mysql let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 72c45133b..fe93dc51e 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -66,6 +66,7 @@ struct DataGridView: NSViewRepresentable { let isEditable: Bool var configuration: DataGridConfiguration = .init() var sortedIDs: [RowID]? + var displayFormats: [ValueDisplayFormat?] = [] var delegate: (any DataGridViewDelegate)? @Binding var selectedRowIndices: Set @@ -185,6 +186,7 @@ struct DataGridView: NSViewRepresentable { context.coordinator.tableRowsProvider = tableRowsProvider context.coordinator.tableRowsMutator = tableRowsMutator context.coordinator.sortedIDs = sortedIDs + context.coordinator.syncDisplayFormats(displayFormats) context.coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns @@ -252,11 +254,11 @@ struct DataGridView: NSViewRepresentable { configuration: configuration ) if currentIdentity == coordinator.lastIdentity { - // Only refresh delegate reference — it may have changed between body evals coordinator.delegate = delegate coordinator.tableRowsProvider = tableRowsProvider coordinator.tableRowsMutator = tableRowsMutator coordinator.sortedIDs = sortedIDs + coordinator.syncDisplayFormats(displayFormats) delegate?.dataGridAttach(tableViewCoordinator: coordinator) return } @@ -307,7 +309,7 @@ struct DataGridView: NSViewRepresentable { let rowH = tableView.rowHeight if rowH > 0 { let visibleRows = Int(tableView.visibleRect.height / rowH) + 5 - coordinator.rowProvider.preWarmDisplayCache(upTo: visibleRows) + coordinator.preWarmDisplayCache(upTo: visibleRows) } } @@ -316,6 +318,7 @@ struct DataGridView: NSViewRepresentable { coordinator.tableRowsProvider = tableRowsProvider coordinator.tableRowsMutator = tableRowsMutator coordinator.sortedIDs = sortedIDs + coordinator.syncDisplayFormats(displayFormats) coordinator.delegate = delegate delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index f66ded023..6100c7c95 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -8,26 +8,16 @@ import AppKit extension TableViewCoordinator { func commitCellEdit(row: Int, columnIndex: Int, newValue: String?) { guard let tableView else { return } - guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } - - let storageRow = tableRowsIndex(forDisplayRow: row) - let displayRowValues = displayRow(at: row) - let usesTableRows = storageRow != nil && displayRowValues != nil - let oldValue: String? = { - if let displayRowValues, columnIndex < displayRowValues.values.count { - return displayRowValues.values[columnIndex] - } - return rowProvider.value(atRow: row, column: columnIndex) - }() + let tableRows = tableRowsProvider() + guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } + guard let displayRowValues = displayRow(at: row) else { return } + guard columnIndex < displayRowValues.values.count else { return } + let oldValue = displayRowValues.values[columnIndex] guard oldValue != newValue else { return } - let columnName = rowProvider.columns[columnIndex] - let originalRow: [String?] = { - if let displayRowValues { - return displayRowValues.values - } - return rowProvider.rowValues(at: row) ?? [] - }() + let storageRow = tableRowsIndex(forDisplayRow: row) + let columnName = tableRows.columns[columnIndex] + let originalRow = displayRowValues.values changeManager.recordCellChange( rowIndex: row, columnIndex: columnIndex, @@ -44,9 +34,9 @@ extension TableViewCoordinator { } } delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) - rowProvider.invalidateDisplayCache() + invalidateDisplayCache() - if usesTableRows, case .cellChanged = delta { + if storageRow != nil, case .cellChanged = delta { let displayDelta: Delta = .cellChanged( row: row, column: DataGridView.tableColumnIndex(for: columnIndex) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index df15970ec..ad0a09389 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -32,43 +32,39 @@ extension TableViewCoordinator { let columnIndex = DataGridView.dataColumnIndex(for: column) guard !changeManager.isRowDeleted(row) else { return } + let tableRows = tableRowsProvider() let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] if !immutable.isEmpty, - columnIndex < rowProvider.columns.count, - immutable.contains(rowProvider.columns[columnIndex]) { + columnIndex < tableRows.columns.count, + immutable.contains(tableRows.columns[columnIndex]) { return } - // FK columns use searchable dropdown popover on double click - if columnIndex < rowProvider.columns.count { - let columnName = rowProvider.columns[columnIndex] - if let fkInfo = rowProvider.columnForeignKeys[columnName] { + if columnIndex < tableRows.columns.count { + let columnName = tableRows.columns[columnIndex] + if let fkInfo = tableRows.columnForeignKeys[columnName] { showForeignKeyPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex, fkInfo: fkInfo) return } } - // Multiline values use the overlay editor instead of inline field editor - if let value = rowProvider.value(atRow: row, column: columnIndex), - value.containsLineBreak { + let value = cellValue(at: row, column: columnIndex) + if let value, value.containsLineBreak { showOverlayEditor(tableView: sender, row: row, column: column, columnIndex: columnIndex, value: value) return } - // JSON-like text values in non-JSON/non-chevron columns - if columnIndex < rowProvider.columnTypes.count { - let ct = rowProvider.columnTypes[columnIndex] + if columnIndex < tableRows.columnTypes.count { + let ct = tableRows.columnTypes[columnIndex] if ct.isBooleanType || ct.isDateType || ct.isBlobType || ct.isEnumType || ct.isSetType { return } } - if let cellValue = rowProvider.value(atRow: row, column: columnIndex), - cellValue.looksLikeJson { + if let value, value.looksLikeJson { showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) return } - // Regular columns — start inline editing sender.editColumn(column, row: row, with: nil, select: true) } @@ -106,17 +102,18 @@ extension TableViewCoordinator { return } - guard columnIndex < rowProvider.columnTypes.count, - columnIndex < rowProvider.columns.count else { return } + let tableRows = tableRowsProvider() + guard columnIndex < tableRows.columnTypes.count, + columnIndex < tableRows.columns.count else { return } - let ct = rowProvider.columnTypes[columnIndex] - let columnName = rowProvider.columns[columnIndex] + let ct = tableRows.columnTypes[columnIndex] + let columnName = tableRows.columns[columnIndex] if ct.isBooleanType { showDropdownMenu(tableView: tableView, row: row, column: column, columnIndex: columnIndex) - } else if ct.isEnumType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { + } else if ct.isEnumType, let values = tableRows.columnEnumValues[columnName], !values.isEmpty { showEnumPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) - } else if ct.isSetType, let values = rowProvider.columnEnumValues[columnName], !values.isEmpty { + } else if ct.isSetType, let values = tableRows.columnEnumValues[columnName], !values.isEmpty { showSetPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } else if ct.isDateType { showDatePickerPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) @@ -134,13 +131,14 @@ extension TableViewCoordinator { let row = button.fkRow let columnIndex = button.fkColumnIndex + let tableRows = tableRowsProvider() guard row >= 0 && row < cachedRowCount, - columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } + columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } - let columnName = rowProvider.columns[columnIndex] - guard let fkInfo = rowProvider.columnForeignKeys[columnName] else { return } + let columnName = tableRows.columns[columnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName] else { return } - let value = rowProvider.value(atRow: row, column: columnIndex) + let value = cellValue(at: row, column: columnIndex) guard let value = value, !value.isEmpty else { return } delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 5aee871f2..b9b4b99b1 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -35,11 +35,14 @@ extension TableViewCoordinator { return nil } let rawValue = displayRow.values[columnIndex] - let displayValue = resolveDisplayValue( - row: row, - columnIndex: columnIndex, + let columnType = columnIndex < tableRows.columnTypes.count + ? tableRows.columnTypes[columnIndex] + : nil + let formattedValue = displayValue( + forID: displayRow.id, + column: columnIndex, rawValue: rawValue, - tableRows: tableRows + columnType: columnType ) let state = visualState(for: row) @@ -58,8 +61,8 @@ extension TableViewCoordinator { let isFKColumn = fkColumns.contains(columnIndex) let hasSpecialEditor: Bool = { - guard columnIndex < rowProvider.columnTypes.count else { return false } - let ct = rowProvider.columnTypes[columnIndex] + guard columnIndex < tableRows.columnTypes.count else { return false } + let ct = tableRows.columnTypes[columnIndex] return ct.isBooleanType || ct.isDateType || ct.isJsonType || ct.isBlobType }() @@ -67,7 +70,7 @@ extension TableViewCoordinator { tableView: tableView, row: row, columnIndex: columnIndex, - displayValue: displayValue, + displayValue: formattedValue, rawValue: rawValue, visualState: state, isEditable: isEditable && !state.isDeleted, @@ -95,21 +98,4 @@ extension TableViewCoordinator { return rowView } - private func resolveDisplayValue( - row: Int, - columnIndex: Int, - rawValue: String?, - tableRows: TableRows - ) -> String? { - if sortedIDs == nil, row < rowProvider.totalRowCount { - return rowProvider.displayValue(atRow: row, column: columnIndex) - } - let columnType = columnIndex < tableRows.columnTypes.count - ? tableRows.columnTypes[columnIndex] - : nil - let displayFormat = columnIndex < rowProvider.columnDisplayFormats.count - ? rowProvider.columnDisplayFormats[columnIndex] - : nil - return CellDisplayFormatter.format(rawValue, columnType: columnType, displayFormat: displayFormat) - } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 1f9db3ca7..2548ba2be 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -15,19 +15,20 @@ extension TableViewCoordinator { func inlineEditEligibility(row: Int, columnIndex: Int) -> InlineEditEligibility { guard isEditable else { return .blocked } - guard row >= 0, columnIndex >= 0, columnIndex < rowProvider.columns.count else { return .blocked } + let tableRows = tableRowsProvider() + guard row >= 0, columnIndex >= 0, columnIndex < tableRows.columns.count else { return .blocked } guard !changeManager.isRowDeleted(row) else { return .blocked } let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] - if immutable.contains(rowProvider.columns[columnIndex]) { + if immutable.contains(tableRows.columns[columnIndex]) { return .blocked } - let columnName = rowProvider.columns[columnIndex] - if rowProvider.columnForeignKeys[columnName] != nil { return .blocked } + let columnName = tableRows.columns[columnIndex] + if tableRows.columnForeignKeys[columnName] != nil { return .blocked } - if columnIndex < rowProvider.columnTypes.count { - let ct = rowProvider.columnTypes[columnIndex] + if columnIndex < tableRows.columnTypes.count { + let ct = tableRows.columnTypes[columnIndex] if ct.isBooleanType || ct.isDateType || ct.isJsonType || ct.isBlobType || ct.isEnumType || ct.isSetType { return .blocked @@ -121,9 +122,8 @@ extension TableViewCoordinator { tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - // Check if next cell is also multiline → open overlay there let nextColumnIndex = nextColumn - 1 - if nextColumnIndex >= 0, nextColumnIndex < rowProvider.columns.count, + if nextColumnIndex >= 0, let nextDisplayRow = displayRow(at: nextRow), nextColumnIndex < nextDisplayRow.values.count, let value = nextDisplayRow.values[nextColumnIndex], @@ -147,10 +147,8 @@ extension TableViewCoordinator { if isEscapeCancelling { isEscapeCancelling = false let originalValue: String? = { - if let displayRow = displayRow(at: row), columnIndex < displayRow.values.count { - return displayRow.values[columnIndex] - } - return rowProvider.value(atRow: row, column: columnIndex) + guard let displayRow = displayRow(at: row), columnIndex < displayRow.values.count else { return nil } + return displayRow.values[columnIndex] }() textField.stringValue = originalValue ?? "" (control as? CellTextField)?.restoreTruncatedDisplay() @@ -159,10 +157,8 @@ extension TableViewCoordinator { let rawInput = textField.stringValue let oldValue: String? = { - if let displayRow = displayRow(at: row), columnIndex < displayRow.values.count { - return displayRow.values[columnIndex] - } - return rowProvider.value(atRow: row, column: columnIndex) + guard let displayRow = displayRow(at: row), columnIndex < displayRow.values.count else { return nil } + return displayRow.values[columnIndex] }() let newValue: String? = rawInput.isEmpty && oldValue == nil ? nil : rawInput diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index daa89d264..2e390eda0 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -9,9 +9,18 @@ import SwiftUI // MARK: - Popover Editors extension TableViewCoordinator { + func cellValue(at row: Int, column columnIndex: Int) -> String? { + guard let displayRow = displayRow(at: row), columnIndex >= 0, columnIndex < displayRow.values.count else { + return nil + } + return displayRow.values[columnIndex] + } + func showDatePickerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) - let columnType = rowProvider.columnTypes[columnIndex] + let currentValue = cellValue(at: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columnTypes.count else { return } + let columnType = tableRows.columnTypes[columnIndex] guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -28,7 +37,7 @@ extension TableViewCoordinator { } func showForeignKeyPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int, fkInfo: ForeignKeyInfo) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } guard let databaseType, let connectionId else { return } @@ -62,10 +71,11 @@ extension TableViewCoordinator { } func showForeignKeyPreview(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - guard columnIndex >= 0, columnIndex < rowProvider.columns.count else { return } - let columnName = rowProvider.columns[columnIndex] - guard let fkInfo = rowProvider.columnForeignKeys[columnName] else { return } - let cellValue = rowProvider.value(atRow: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName] else { return } + let cellValue = cellValue(at: row, column: columnIndex) guard let databaseType, let connectionId else { return } guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -92,8 +102,10 @@ extension TableViewCoordinator { } func showJSONEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) - let columnName = rowProvider.columns[columnIndex] + let currentValue = cellValue(at: row, column: columnIndex) + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -126,7 +138,7 @@ extension TableViewCoordinator { } func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } @@ -148,11 +160,13 @@ extension TableViewCoordinator { func showEnumPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - let columnName = rowProvider.columns[columnIndex] - guard let allowedValues = rowProvider.columnEnumValues[columnName] else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let allowedValues = tableRows.columnEnumValues[columnName] else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) - let isNullable = rowProvider.columnNullable[columnName] ?? true + let currentValue = cellValue(at: row, column: columnIndex) + let isNullable = tableRows.columnNullable[columnName] ?? true var values: [String] = [] if isNullable { @@ -179,10 +193,12 @@ extension TableViewCoordinator { func showSetPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - let columnName = rowProvider.columns[columnIndex] - guard let allowedValues = rowProvider.columnEnumValues[columnName] else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let allowedValues = tableRows.columnEnumValues[columnName] else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) let currentSet: Set if let value = currentValue { @@ -213,8 +229,10 @@ extension TableViewCoordinator { func showDropdownMenu(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } - let currentValue = rowProvider.value(atRow: row, column: columnIndex) + let currentValue = cellValue(at: row, column: columnIndex) pendingDropdownRow = row pendingDropdownColumn = columnIndex pendingDropdownTableView = tableView @@ -238,8 +256,8 @@ extension TableViewCoordinator { menu.addItem(item) } - let columnName = rowProvider.columns[columnIndex] - let isNullable = rowProvider.columnNullable[columnName] ?? true + let columnName = tableRows.columns[columnIndex] + let isNullable = tableRows.columnNullable[columnName] ?? true if isNullable && customDropdownOptions?[columnIndex] == nil { menu.addItem(.separator()) let nullItem = NSMenuItem( diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 1c7af5545..c1986ccb3 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -241,13 +241,12 @@ extension TableViewCoordinator { ) } - // Update the provider's format array and refresh - var formats = rowProvider.columnDisplayFormats + var formats = columnDisplayFormats while formats.count <= info.columnIndex { formats.append(nil) } formats[info.columnIndex] = (info.format == .raw) ? nil : info.format - rowProvider.updateDisplayFormats(formats) + updateDisplayFormats(formats) guard let tableView else { return } let visibleRect = tableView.visibleRect diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index a62f4db54..c07e554ad 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -248,9 +248,8 @@ final class KeyHandlingTableView: NSTableView { return } - // Multiline values use overlay editor instead of field editor let columnIndex = DataGridView.dataColumnIndex(for: focusedColumn) - if let value = coordinator?.rowProvider.value(atRow: row, column: columnIndex), + if let value = coordinator?.cellValue(at: row, column: columnIndex), value.containsLineBreak { coordinator?.showOverlayEditor(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex, value: value) return diff --git a/TablePro/Views/Results/TableRowViewWithMenu.swift b/TablePro/Views/Results/TableRowViewWithMenu.swift index 645d25e2d..a7dd7b7e2 100644 --- a/TablePro/Views/Results/TableRowViewWithMenu.swift +++ b/TablePro/Views/Results/TableRowViewWithMenu.swift @@ -98,11 +98,11 @@ final class TableRowViewWithMenu: NSTableRowView { menu.addItem(pasteItem) } - // FK actions (only for FK columns with non-empty values) - if dataColumnIndex >= 0, dataColumnIndex < coordinator.rowProvider.columns.count { - let columnName = coordinator.rowProvider.columns[dataColumnIndex] - if let fkInfo = coordinator.rowProvider.columnForeignKeys[columnName], - let cellValue = coordinator.rowProvider.value(atRow: rowIndex, column: dataColumnIndex), + let tableRows = coordinator.tableRowsProvider() + if dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count { + let columnName = tableRows.columns[dataColumnIndex] + if let fkInfo = tableRows.columnForeignKeys[columnName], + let cellValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex), !cellValue.isEmpty { menu.addItem(NSMenuItem.separator()) @@ -139,11 +139,11 @@ final class TableRowViewWithMenu: NSTableRowView { emptyItem.target = self setValueMenu.addItem(emptyItem) - let columnName = dataColumnIndex < coordinator.rowProvider.columns.count - ? coordinator.rowProvider.columns[dataColumnIndex] + let columnName = dataColumnIndex < tableRows.columns.count + ? tableRows.columns[dataColumnIndex] : nil - let isNullable = columnName.flatMap { coordinator.rowProvider.columnNullable[$0] } ?? true + let isNullable = columnName.flatMap { tableRows.columnNullable[$0] } ?? true if isNullable { let nullItem = NSMenuItem( title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "") @@ -152,7 +152,7 @@ final class TableRowViewWithMenu: NSTableRowView { setValueMenu.addItem(nullItem) } - let hasDefault = columnName.flatMap({ coordinator.rowProvider.columnDefaults[$0] ?? nil }) != nil + let hasDefault = columnName.flatMap({ tableRows.columnDefaults[$0] ?? nil }) != nil if hasDefault { let defaultItem = NSMenuItem( title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") @@ -297,9 +297,11 @@ final class TableRowViewWithMenu: NSTableRowView { @objc private func navigateToForeignKey(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int, let coordinator else { return } - let columnName = coordinator.rowProvider.columns[columnIndex] - guard let fkInfo = coordinator.rowProvider.columnForeignKeys[columnName], - let value = coordinator.rowProvider.value(atRow: rowIndex, column: columnIndex) else { return } + let tableRows = coordinator.tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + guard let fkInfo = tableRows.columnForeignKeys[columnName], + let value = coordinator.cellValue(at: rowIndex, column: columnIndex) else { return } coordinator.delegate?.dataGridNavigateFK(value: value, fkInfo: fkInfo) } } From e190a7a2f422b37f855c5d194211ee6c4f0ef421 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:00:12 +0700 Subject: [PATCH 12/31] refactor(datagrid): replace RowBuffer with TableRows; delete legacy row stack --- CHANGELOG.md | 2 +- .../StreamingQueryExportDataSource.swift | 2 +- .../Formatting/CellDisplayFormatter.swift | 2 +- .../Core/Services/Query/RowDataStore.swift | 44 -- TablePro/Models/Query/ResultSet.swift | 34 +- TablePro/Models/Query/RowBuffer.swift | 75 --- TablePro/Models/Query/RowProvider.swift | 356 ------------- .../Main/Child/MainEditorContentView.swift | 151 +----- .../Views/Main/Child/MainStatusBarView.swift | 8 +- .../MainContentCoordinator+Discard.swift | 45 +- .../MainContentCoordinator+FKNavigation.swift | 6 +- .../MainContentCoordinator+Filtering.swift | 6 +- .../MainContentCoordinator+LoadMore.swift | 12 +- ...ainContentCoordinator+MultiStatement.swift | 11 +- .../MainContentCoordinator+Navigation.swift | 5 - .../MainContentCoordinator+QueryHelpers.swift | 61 +-- ...inContentCoordinator+QueryParameters.swift | 6 +- .../MainContentCoordinator+SaveChanges.swift | 1 - ...ainContentCoordinator+SidebarActions.swift | 4 +- .../MainContentCoordinator+SidebarSave.swift | 8 +- .../MainContentCoordinator+TabSwitch.swift | 31 +- ...inContentCoordinator+WindowLifecycle.swift | 7 +- .../Extensions/MainContentView+Bindings.swift | 10 +- .../Extensions/MainContentView+Helpers.swift | 10 +- .../Main/MainContentCommandActions.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 23 +- TablePro/Views/Main/MainContentView.swift | 2 +- .../Views/Results/DataGridCellFactory.swift | 26 +- .../Views/Results/DataGridCoordinator.swift | 42 +- .../Results/DataGridView+RowActions.swift | 5 +- TablePro/Views/Results/DataGridView.swift | 179 ++----- .../Extensions/DataGridView+CellPaste.swift | 2 +- .../Extensions/DataGridView+Sort.swift | 23 +- TablePro/Views/Results/RowProviderCache.swift | 60 --- .../Views/Structure/CreateTableView.swift | 3 +- .../Structure/StructureRowProvider.swift | 15 +- .../Views/Structure/TableStructureView.swift | 3 +- .../Services/Query/RowDataStoreTests.swift | 151 ------ .../Services/Query/TableRowsStoreTests.swift | 21 + TableProTests/Helpers/TestFixtures.swift | 5 +- .../Models/DisplayValueCacheTests.swift | 110 ---- TableProTests/Models/RowBufferTests.swift | 111 ---- TableProTests/Models/RowProviderTests.swift | 499 ------------------ TableProTests/Views/Main/EvictionTests.swift | 53 +- .../Views/Main/TabEvictionTests.swift | 248 --------- .../DataGridCellFactoryPerfTests.swift | 52 +- .../Views/Results/RowProviderCacheTests.swift | 135 ----- .../Views/Results/RowProviderSyncTests.swift | 340 ------------ 48 files changed, 333 insertions(+), 2674 deletions(-) delete mode 100644 TablePro/Core/Services/Query/RowDataStore.swift delete mode 100644 TablePro/Models/Query/RowBuffer.swift delete mode 100644 TablePro/Models/Query/RowProvider.swift delete mode 100644 TablePro/Views/Results/RowProviderCache.swift delete mode 100644 TableProTests/Core/Services/Query/RowDataStoreTests.swift delete mode 100644 TableProTests/Models/DisplayValueCacheTests.swift delete mode 100644 TableProTests/Models/RowBufferTests.swift delete mode 100644 TableProTests/Models/RowProviderTests.swift delete mode 100644 TableProTests/Views/Main/TabEvictionTests.swift delete mode 100644 TableProTests/Views/Results/RowProviderCacheTests.swift delete mode 100644 TableProTests/Views/Results/RowProviderSyncTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 705eeeaef..b57a5c802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. Row operations (add, duplicate, delete, paste, undo) mutate TableRows and apply the returned Delta through TableViewCoordinator.applyDelta. Sort state moved from InMemoryRowProvider's positional sortIndices to a TableViewCoordinator.sortedIDs permutation keyed by Row.id, so cell edits under sort hit the correct storage row and inserted rows survive at the end of the sorted view without re-sorting. RowBuffer still backs the display cache pending later phases (Phase C.2 of the DataGrid refactor). +- Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id. - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click diff --git a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift index 25d266889..90dc375f6 100644 --- a/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift +++ b/TablePro/Core/Plugins/StreamingQueryExportDataSource.swift @@ -4,7 +4,7 @@ // // Streaming export data source for query results. // Re-executes the query and streams rows directly from the database to the export plugin, -// bypassing RowBuffer. Allows exporting large result sets without loading all rows into memory. +// bypassing in-memory storage. Allows exporting large result sets without loading all rows into memory. // import Foundation diff --git a/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift b/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift index a76377fd6..2f8f05856 100644 --- a/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift +++ b/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift @@ -3,7 +3,7 @@ // TablePro // // Pure formatter that transforms raw cell values into display-ready strings. -// Used by InMemoryRowProvider's display cache to compute values once per cell. +// Used by the data grid coordinator's display cache to compute values once per cell. // import Foundation diff --git a/TablePro/Core/Services/Query/RowDataStore.swift b/TablePro/Core/Services/Query/RowDataStore.swift deleted file mode 100644 index 135519b64..000000000 --- a/TablePro/Core/Services/Query/RowDataStore.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -@MainActor -@Observable -final class RowDataStore { - @ObservationIgnored private var store: [UUID: RowBuffer] = [:] - - func buffer(for tabId: UUID) -> RowBuffer { - if let existing = store[tabId] { - return existing - } - let buffer = RowBuffer() - store[tabId] = buffer - return buffer - } - - func existingBuffer(for tabId: UUID) -> RowBuffer? { - store[tabId] - } - - func setBuffer(_ buffer: RowBuffer, for tabId: UUID) { - store[tabId] = buffer - } - - func removeBuffer(for tabId: UUID) { - store.removeValue(forKey: tabId) - } - - func evict(for tabId: UUID) { - store[tabId]?.evict() - } - - func evictAll(except activeTabId: UUID?) { - for (id, buffer) in store where id != activeTabId { - if !buffer.rows.isEmpty && !buffer.isEvicted { - buffer.evict() - } - } - } - - func tearDown() { - store.removeAll() - } -} diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index 01997ddb7..bfc22b679 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -14,7 +14,7 @@ import os final class ResultSet: Identifiable { let id: UUID var label: String - var rowBuffer: RowBuffer + var tableRows: TableRows var executionTime: TimeInterval? var rowsAffected: Int = 0 var errorMessage: String? @@ -27,37 +27,11 @@ final class ResultSet: Identifiable { var pagination = PaginationState() var columnLayout = ColumnLayoutState() - var columnTypes: [ColumnType] { - get { rowBuffer.columnTypes } - set { rowBuffer.columnTypes = newValue } - } - - var columnDefaults: [String: String?] { - get { rowBuffer.columnDefaults } - set { rowBuffer.columnDefaults = newValue } - } - - var columnForeignKeys: [String: ForeignKeyInfo] { - get { rowBuffer.columnForeignKeys } - set { rowBuffer.columnForeignKeys = newValue } - } - - var columnEnumValues: [String: [String]] { - get { rowBuffer.columnEnumValues } - set { rowBuffer.columnEnumValues = newValue } - } - - var columnNullable: [String: Bool] { - get { rowBuffer.columnNullable } - set { rowBuffer.columnNullable = newValue } - } - - var resultColumns: [String] { rowBuffer.columns } - var resultRows: [[String?]] { rowBuffer.rows } + var resultColumns: [String] { tableRows.columns } - init(id: UUID = UUID(), label: String, rowBuffer: RowBuffer = RowBuffer()) { + init(id: UUID = UUID(), label: String, tableRows: TableRows = TableRows()) { self.id = id self.label = label - self.rowBuffer = rowBuffer + self.tableRows = tableRows } } diff --git a/TablePro/Models/Query/RowBuffer.swift b/TablePro/Models/Query/RowBuffer.swift deleted file mode 100644 index 86c2b3b83..000000000 --- a/TablePro/Models/Query/RowBuffer.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// RowBuffer.swift -// TablePro -// - -import Foundation -import os -import TableProPluginKit - -/// Reference-type wrapper for large result data. -/// When QueryTab (a struct) is copied via CoW, only this 8-byte reference is copied -/// instead of duplicating potentially large result arrays. -final class RowBuffer { - var rows: [[String?]] - var columns: [String] - var columnTypes: [ColumnType] - var columnDefaults: [String: String?] - var columnForeignKeys: [String: ForeignKeyInfo] - var columnEnumValues: [String: [String]] - var columnNullable: [String: Bool] - - init( - rows: [[String?]] = [], - columns: [String] = [], - columnTypes: [ColumnType] = [], - columnDefaults: [String: String?] = [:], - columnForeignKeys: [String: ForeignKeyInfo] = [:], - columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] - ) { - self.rows = rows - self.columns = columns - self.columnTypes = columnTypes - self.columnDefaults = columnDefaults - self.columnForeignKeys = columnForeignKeys - self.columnEnumValues = columnEnumValues - self.columnNullable = columnNullable - } - - /// Create a deep copy of this buffer (used when explicit data duplication is needed) - func copy() -> RowBuffer { - RowBuffer( - rows: rows, - columns: columns, - columnTypes: columnTypes, - columnDefaults: columnDefaults, - columnForeignKeys: columnForeignKeys, - columnEnumValues: columnEnumValues, - columnNullable: columnNullable - ) - } - - /// Whether this buffer's row data has been evicted to save memory - private(set) var isEvicted: Bool = false - - /// Evict row data to free memory. Column metadata is preserved. - func evict() { - guard !isEvicted else { return } - rows = [] - isEvicted = true - } - - /// Restore row data after eviction - func restore(rows newRows: [[String?]]) { - self.rows = newRows - isEvicted = false - } - - deinit { - #if DEBUG - Logger(subsystem: "com.TablePro", category: "RowBuffer") - .debug("RowBuffer deallocated — columns: \(self.columns.count), evicted: \(self.isEvicted)") - #endif - } -} diff --git a/TablePro/Models/Query/RowProvider.swift b/TablePro/Models/Query/RowProvider.swift deleted file mode 100644 index 9bdab27db..000000000 --- a/TablePro/Models/Query/RowProvider.swift +++ /dev/null @@ -1,356 +0,0 @@ -// -// RowProvider.swift -// TablePro -// -// Protocol for virtualized row data access -// - -import Foundation -import os - -/// Protocol for virtualized data access with lazy loading support -protocol RowProvider: AnyObject { - /// Total number of rows available - var totalRowCount: Int { get } - - /// Column names - var columns: [String] { get } - - /// Column default values from schema - var columnDefaults: [String: String?] { get } - - /// Fetch rows for the given range - /// - Parameters: - /// - offset: Starting row index - /// - limit: Maximum number of rows to fetch - /// - Returns: Array of row data - func fetchRows(offset: Int, limit: Int) -> [TableRowData] - - /// Prefetch rows at specific indices for smoother scrolling - func prefetchRows(at indices: [Int]) - - /// Invalidate cached data (e.g., after refresh) - func invalidateCache() -} - -/// Represents a single row of table data -final class TableRowData { - let index: Int - var values: [String?] - - init(index: Int, values: [String?]) { - self.index = index - self.values = values - } - - /// Get value at column index - func value(at columnIndex: Int) -> String? { - guard columnIndex < values.count else { return nil } - return values[columnIndex] - } - - /// Set value at column index - func setValue(_ value: String?, at columnIndex: Int) { - guard columnIndex < values.count else { return } - values[columnIndex] = value - } -} - -// MARK: - In-Memory Row Provider - -/// Row provider that keeps all data in memory as `[[String?]]`. -/// References `RowBuffer` directly to avoid duplicating row data. -/// An optional `sortIndices` array maps display indices to source-row indices, -/// so sorted views don't need a reordered copy of the rows. -/// -/// Direct-access methods `value(atRow:column:)` and `rowValues(at:)` avoid -/// heap allocations by reading straight from the source `[String?]` array. -final class InMemoryRowProvider: RowProvider { - private weak var rowBuffer: RowBuffer? - /// Strong reference only when the provider created its own buffer (convenience init). - /// External buffers are owned by QueryTab, so we hold them weakly. - private var ownedBuffer: RowBuffer? - private static let emptyBuffer = RowBuffer() - private var safeBuffer: RowBuffer { rowBuffer ?? Self.emptyBuffer } - private var sortIndices: [Int]? - private var appendedRows: [[String?]] = [] - private(set) var columns: [String] - - /// Lazy per-cell cache for formatted display values. - /// Keyed by source row index (buffer index or offset appended index). - /// Evicted when exceeding maxDisplayCacheSize to bound memory. - private var displayCache: [Int: [String?]] = [:] - private static let maxDisplayCacheSize = 20_000 - private(set) var columnDefaults: [String: String?] - private(set) var columnTypes: [ColumnType] - private(set) var columnForeignKeys: [String: ForeignKeyInfo] - private(set) var columnEnumValues: [String: [String]] - private(set) var columnNullable: [String: Bool] - private(set) var columnDisplayFormats: [ValueDisplayFormat?] = [] - - var totalRowCount: Int { - bufferRowCount + appendedRows.count - } - - /// Number of rows coming from the buffer (respecting sort indices count when present) - private var bufferRowCount: Int { - sortIndices?.count ?? safeBuffer.rows.count - } - - init( - rowBuffer: RowBuffer, - sortIndices: [Int]? = nil, - columns: [String], - columnDefaults: [String: String?] = [:], - columnTypes: [ColumnType]? = nil, - columnForeignKeys: [String: ForeignKeyInfo] = [:], - columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] - ) { - self.rowBuffer = rowBuffer - self.sortIndices = sortIndices - self.columns = columns - self.columnDefaults = columnDefaults - self.columnTypes = columnTypes ?? Array(repeating: ColumnType.text(rawType: nil), count: columns.count) - self.columnForeignKeys = columnForeignKeys - self.columnEnumValues = columnEnumValues - self.columnNullable = columnNullable - } - - /// Convenience initializer that wraps rows in an internal RowBuffer. - /// Used by tests, previews, and callers that don't have a RowBuffer reference. - convenience init( - rows: [[String?]], - columns: [String], - columnDefaults: [String: String?] = [:], - columnTypes: [ColumnType]? = nil, - columnForeignKeys: [String: ForeignKeyInfo] = [:], - columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] - ) { - let buffer = RowBuffer(rows: rows, columns: columns) - self.init( - rowBuffer: buffer, - columns: columns, - columnDefaults: columnDefaults, - columnTypes: columnTypes, - columnForeignKeys: columnForeignKeys, - columnEnumValues: columnEnumValues, - columnNullable: columnNullable - ) - ownedBuffer = buffer - } - - func fetchRows(offset: Int, limit: Int) -> [TableRowData] { - let total = totalRowCount - let endIndex = min(offset + limit, total) - guard offset < endIndex else { return [] } - var result: [TableRowData] = [] - result.reserveCapacity(endIndex - offset) - for i in offset.. TableRowData? { - guard index >= 0 && index < totalRowCount else { return nil } - return TableRowData(index: index, values: sourceRow(at: index)) - } - - /// O(1) cell value access — no heap allocation. - func value(atRow rowIndex: Int, column columnIndex: Int) -> String? { - guard rowIndex >= 0 && rowIndex < totalRowCount else { return nil } - let src = sourceRow(at: rowIndex) - guard columnIndex >= 0 && columnIndex < src.count else { return nil } - return src[columnIndex] - } - - /// Returns the source values array for a display row. No copy until caller stores it. - func rowValues(at rowIndex: Int) -> [String?]? { - guard rowIndex >= 0 && rowIndex < totalRowCount else { return nil } - return sourceRow(at: rowIndex) - } - - // MARK: - Display Value Cache - - /// Get the formatted display value for a cell. - /// Computes on first access for the entire row, returns cached on subsequent calls. - @MainActor - func displayValue(atRow rowIndex: Int, column columnIndex: Int) -> String? { - guard rowIndex >= 0 && rowIndex < totalRowCount else { return nil } - - let cacheKey = resolveCacheKey(for: rowIndex) - - if let cachedRow = displayCache[cacheKey], columnIndex < cachedRow.count { - return cachedRow[columnIndex] - } - - let src = sourceRow(at: rowIndex) - let columnCount = columns.count - var rowCache = [String?](repeating: nil, count: columnCount) - for col in 0.. Self.maxDisplayCacheSize else { return } - let halfSize = Self.maxDisplayCacheSize / 2 - displayCache = displayCache.filter { abs($0.key - nearKey) <= halfSize } - } - - @MainActor - func preWarmDisplayCache(upTo rowCount: Int) { - let count = min(rowCount, totalRowCount) - for row in 0.. Int { - let newIndex = totalRowCount - appendedRows.append(values) - return newIndex - } - - /// Remove row at index (used when discarding new rows) - func removeRow(at index: Int) { - guard index >= 0 && index < totalRowCount else { return } - let bCount = bufferRowCount - if index >= bCount { - let appendedIdx = index - bCount - guard appendedIdx < appendedRows.count else { return } - appendedRows.remove(at: appendedIdx) - } else { - guard let buffer = rowBuffer else { return } - if let sorted = sortIndices { - let bufferIdx = sorted[index] - buffer.rows.remove(at: bufferIdx) - var newIndices = sorted - newIndices.remove(at: index) - for i in newIndices.indices where newIndices[i] > bufferIdx { - newIndices[i] -= 1 - } - sortIndices = newIndices - } else { - buffer.rows.remove(at: index) - } - } - displayCache.removeAll() - } - - /// Remove multiple rows at indices (used when discarding new rows) - /// Indices should be sorted in descending order to maintain correct removal - func removeRows(at indices: Set) { - for index in indices.sorted(by: >) { - guard index >= 0 && index < totalRowCount else { continue } - removeRow(at: index) - } - } - - // MARK: - Private - - /// Map a display index to a cache key based on the source row identity. - private func resolveCacheKey(for displayIndex: Int) -> Int { - let sourceIdx = resolveSourceIndex(displayIndex) - if let bufIdx = sourceIdx.bufferIndex { - return bufIdx - } else if let appIdx = sourceIdx.appendedIndex { - return bufferRowCount + appIdx - } - return displayIndex - } - - /// Resolve a display index to either a buffer index or an appended-row index. - private func resolveSourceIndex(_ displayIndex: Int) -> (bufferIndex: Int?, appendedIndex: Int?) { - let bCount = bufferRowCount - if displayIndex >= bCount { - return (nil, displayIndex - bCount) - } - if let sorted = sortIndices { - return (sorted[displayIndex], nil) - } - return (displayIndex, nil) - } - - /// Get the source row values for a display index. - private func sourceRow(at displayIndex: Int) -> [String?] { - let bCount = bufferRowCount - if displayIndex >= bCount { - return appendedRows[displayIndex - bCount] - } - if let sorted = sortIndices { - return safeBuffer.rows[sorted[displayIndex]] - } - return safeBuffer.rows[displayIndex] - } -} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ec75d2433..3769e1a4a 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -63,7 +63,6 @@ struct MainEditorContentView: View { @State private var sortCache: [UUID: SortedRowsCache] = [:] - @State private var providerCache = RowProviderCache() @State private var cachedChangeManager: AnyChangeManager? @State private var erDiagramViewModels: [UUID: ERDiagramViewModel] = [:] @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] @@ -121,7 +120,7 @@ struct MainEditorContentView: View { } .onChange(of: tabManager.tabStructureVersion) { _, _ in let newIds = tabManager.tabIds - guard !sortCache.isEmpty || !providerCache.isEmpty || !erDiagramViewModels.isEmpty + guard !sortCache.isEmpty || !erDiagramViewModels.isEmpty || !serverDashboardViewModels.isEmpty else { coordinator.cleanupSortCache(openTabIds: Set(newIds)) return @@ -129,55 +128,24 @@ struct MainEditorContentView: View { let openTabIds = Set(newIds) sortCache = sortCache.filter { openTabIds.contains($0.key) } coordinator.cleanupSortCache(openTabIds: openTabIds) - providerCache.retain(tabIds: openTabIds) erDiagramViewModels = erDiagramViewModels.filter { openTabIds.contains($0.key) } serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) } } .onChange(of: tabManager.selectedTabId) { _, _ in updateHasQueryText() - - guard let tab = tabManager.selectedTab, - let existing = coordinator.rowDataStore.existingBuffer(for: tab.id), - !existing.isEvicted else { return } - if providerCache.provider( - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) == nil { - cacheRowProvider(for: tab) - } } .onAppear { updateHasQueryText() cachedChangeManager = AnyChangeManager(changeManager) - if let tab = tabManager.selectedTab, - let existing = coordinator.rowDataStore.existingBuffer(for: tab.id), - !existing.isEvicted { - cacheRowProvider(for: tab) - } wireDataTabDelegateStableRefs() refreshDataTabDelegateMutableRefs() coordinator.dataTabDelegate = dataTabDelegate coordinator.onTeardown = { [self] in - providerCache.removeAll() sortCache.removeAll() cachedChangeManager = nil coordinator.dataTabDelegate = nil } } - .onChange(of: tabManager.selectedTab?.schemaVersion) { _, newVersion in - guard let tab = tabManager.selectedTab, newVersion != nil else { return } - cacheRowProvider(for: tab) - } - .onChange(of: tabManager.selectedTab?.metadataVersion) { _, _ in - guard let tab = tabManager.selectedTab else { return } - cacheRowProvider(for: tab) - } - .onChange(of: tabManager.selectedTab?.display.activeResultSetId) { _, _ in - guard let tab = tabManager.selectedTab else { return } - cacheRowProvider(for: tab) - } .onChange(of: selectionState.indices) { _, newIndices in onSelectionChange(newIndices) } @@ -440,7 +408,7 @@ struct MainEditorContentView: View { } case .json: ResultsJsonView( - tableRows: coordinator.tableRowsStore.tableRows(for: tab.id), + tableRows: resolvedTableRows(for: tab), selectedRowIndices: selectionState.indices ) case .data: @@ -448,13 +416,11 @@ struct MainEditorContentView: View { ExplainResultView(text: explainText, executionTime: tab.display.explainExecutionTime, plan: tab.display.explainPlan) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - // Result tab bar (when multiple result sets) if tab.display.resultSets.count > 1 { resultTabBar(tab: tab) Divider() } - // Inline error banner (when active result set has error) if let error = tab.display.activeResultSet?.errorMessage { InlineErrorBanner( message: error, @@ -463,8 +429,7 @@ struct MainEditorContentView: View { Divider() } - // Content: success view OR filter+grid - let resolvedBuffer = coordinator.rowDataStore.buffer(for: tab.id) + let resolvedRows = resolvedTableRows(for: tab) if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, !tab.execution.isExecuting { @@ -473,7 +438,7 @@ struct MainEditorContentView: View { executionTime: rs.executionTime, statusMessage: rs.statusMessage ) - } else if resolvedBuffer.columns.isEmpty && tab.execution.errorMessage == nil + } else if resolvedRows.columns.isEmpty && tab.execution.errorMessage == nil && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting { if tab.display.resultSets.isEmpty { @@ -486,11 +451,10 @@ struct MainEditorContentView: View { ) } } else { - // Filter panel (collapsible, above data grid) if filterStateManager.isVisible && tab.tabType == .table { FilterPanelView( filterState: filterStateManager, - columns: resolvedBuffer.columns, + columns: resolvedRows.columns, primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, onApply: onApplyFilters, @@ -499,8 +463,8 @@ struct MainEditorContentView: View { Divider() } - if tab.tabType == .query && !resolvedBuffer.columns.isEmpty - && resolvedBuffer.rows.isEmpty && tab.execution.lastExecutedAt != nil + if tab.tabType == .query && !resolvedRows.columns.isEmpty + && resolvedRows.rows.isEmpty && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting && !filterStateManager.hasAppliedFilters { emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime) @@ -557,9 +521,8 @@ struct MainEditorContentView: View { let tabId = tab.id DataGridView( - rowProvider: rowProvider(for: tab), tableRowsProvider: { [coordinator] in - coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() + resolvedTableRowsForTab(coordinator: coordinator, tabId: tabId) }, tableRowsMutator: { [coordinator] mutate in coordinator.tableRowsStore.updateTableRows(for: tabId) { rows in @@ -594,70 +557,22 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity, alignment: .top) } - private func rowProvider(for tab: QueryTab) -> InMemoryRowProvider { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - if buffer.isEvicted { - providerCache.remove(for: tab.id) - return makeRowProvider(for: tab) - } - if let cached = providerCache.provider( - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) { - return cached + private func resolvedTableRows(for tab: QueryTab) -> TableRows { + if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { + return rs.tableRows } - let provider = makeRowProvider(for: tab) - providerCache.store( - provider, - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) - return provider + return coordinator.tableRowsStore.existingTableRows(for: tab.id) ?? TableRows() } - private func cacheRowProvider(for tab: QueryTab) { - let provider = makeRowProvider(for: tab) - providerCache.store( - provider, - for: tab.id, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - sortState: tab.sortState - ) - } - - private func makeRowProvider(for tab: QueryTab) -> InMemoryRowProvider { - let provider: InMemoryRowProvider - - // Use active ResultSet data when available (multi-statement results) + @MainActor + private func resolvedTableRowsForTab(coordinator: MainContentCoordinator, tabId: UUID) -> TableRows { + guard let tab = coordinator.tabManager.tabs.first(where: { $0.id == tabId }) else { + return coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() + } if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { - provider = InMemoryRowProvider( - rowBuffer: rs.rowBuffer, - columns: rs.resultColumns, - columnDefaults: rs.columnDefaults, - columnTypes: rs.columnTypes, - columnForeignKeys: rs.columnForeignKeys, - columnEnumValues: rs.columnEnumValues, - columnNullable: rs.columnNullable - ) - } else { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - provider = InMemoryRowProvider( - rowBuffer: buffer, - columns: buffer.columns, - columnDefaults: buffer.columnDefaults, - columnTypes: buffer.columnTypes, - columnForeignKeys: buffer.columnForeignKeys, - columnEnumValues: buffer.columnEnumValues, - columnNullable: buffer.columnNullable - ) + return rs.tableRows } - - return provider + return coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() } private func displayFormats(for tab: QueryTab) -> [ValueDisplayFormat?] { @@ -713,19 +628,6 @@ struct MainEditorContentView: View { /// Returns the display order as a permutation of `RowID`, or nil when no sort applies. /// For table tabs, sorting is handled server-side via SQL ORDER BY. private func sortedIDsForTab(_ tab: QueryTab) -> [RowID]? { - let rowBuffer: RowBuffer - let colTypes: [ColumnType] - if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { - rowBuffer = rs.rowBuffer - colTypes = rs.columnTypes - } else { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - rowBuffer = buffer - colTypes = buffer.columnTypes - } - - guard !rowBuffer.isEvicted else { return nil } - if tab.tabType == .table { return nil } @@ -734,10 +636,11 @@ struct MainEditorContentView: View { return nil } - guard let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id), - !tableRows.rows.isEmpty else { + let resolvedRows = resolvedTableRows(for: tab) + guard !resolvedRows.rows.isEmpty else { return nil } + let colTypes = resolvedRows.columnTypes if let cached = coordinator.querySortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), @@ -747,7 +650,7 @@ struct MainEditorContentView: View { return cached.sortedIDs } - if tableRows.rows.count > 1_000 { + if resolvedRows.rows.count > 1_000 { return nil } @@ -760,7 +663,7 @@ struct MainEditorContentView: View { } let sortColumns = tab.sortState.columns - let storageRows = tableRows.rows + let storageRows = resolvedRows.rows let sortedIndices = Array(storageRows.indices).sorted { idx1, idx2 in let row1 = storageRows[idx1].values let row2 = storageRows[idx2].values @@ -821,12 +724,12 @@ struct MainEditorContentView: View { // MARK: - Status Bar private func statusBar(tab: QueryTab) -> some View { - let buffer = coordinator.rowDataStore.buffer(for: tab.id) + let resolvedRows = resolvedTableRows(for: tab) return MainStatusBarView( - snapshot: StatusBarSnapshot(tab: tab, buffer: buffer), + snapshot: StatusBarSnapshot(tab: tab, tableRows: resolvedRows), filterStateManager: filterStateManager, columnVisibilityManager: columnVisibilityManager, - allColumns: buffer.columns, + allColumns: resolvedRows.columns, selectedRowIndices: selectionState.indices, viewMode: resultsViewModeBinding(for: tab), onFirstPage: onFirstPage, diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 65f201bbf..803ca3fec 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -17,12 +17,12 @@ struct StatusBarSnapshot: Equatable { let pagination: PaginationState let statusMessage: String? - init(tab: QueryTab?, buffer: RowBuffer?) { + init(tab: QueryTab?, tableRows: TableRows?) { self.tabId = tab?.id self.tabType = tab?.tabType - self.hasRows = !(buffer?.rows.isEmpty ?? true) - self.hasColumns = !(buffer?.columns.isEmpty ?? true) - self.rowCount = buffer?.rows.count ?? 0 + self.hasRows = !(tableRows?.rows.isEmpty ?? true) + self.hasColumns = !(tableRows?.columns.isEmpty ?? true) + self.rowCount = tableRows?.rows.count ?? 0 self.hasTableName = tab?.tableContext.tableName != nil self.pagination = tab?.pagination ?? PaginationState() self.statusMessage = tab?.execution.statusMessage diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index 5136b79aa..e53c4fe9b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -71,24 +71,34 @@ extension MainContentCoordinator { pendingDeletes: inout Set ) { let originalValues = changeManager.getOriginalValues() + var deltas: [Delta] = [] if let index = tabManager.selectedTabIndex { let tabId = tabManager.tabs[index].id - let buffer = rowDataStore.buffer(for: tabId) - for (rowIndex, columnIndex, originalValue) in originalValues { - if rowIndex < buffer.rows.count, - columnIndex < buffer.rows[rowIndex].count { - buffer.rows[rowIndex][columnIndex] = originalValue + let insertedIDs = collectInsertedRowIDs( + tabId: tabId, + indices: changeManager.insertedRowIndices + ) + tableRowsStore.updateTableRows(for: tabId) { tableRows in + let edits = originalValues.map { (row: $0.0, column: $0.1, value: $0.2) } + if !edits.isEmpty { + let editDelta = tableRows.editMany(edits) + if editDelta != .none { + deltas.append(editDelta) + } } - } - - let insertedIndices = changeManager.insertedRowIndices.sorted(by: >) - for rowIndex in insertedIndices { - if rowIndex < buffer.rows.count { - buffer.rows.remove(at: rowIndex) + if !insertedIDs.isEmpty { + let removeDelta = tableRows.remove(rowIDs: insertedIDs) + if removeDelta != .none { + deltas.append(removeDelta) + } } } } + for delta in deltas { + dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) + } + if let tableName = tabManager.selectedTab?.tableContext.tableName { filterStateManager.saveLastFilters(for: tableName) } @@ -103,4 +113,17 @@ extension MainContentCoordinator { Task { await refreshTables() } } + + private func collectInsertedRowIDs(tabId: UUID, indices: Set) -> Set { + guard !indices.isEmpty else { return [] } + guard let tableRows = tableRowsStore.existingTableRows(for: tabId) else { return [] } + var ids = Set() + for index in indices where index >= 0 && index < tableRows.rows.count { + let id = tableRows.rows[index].id + if id.isInserted { + ids.insert(id) + } + } + return ids + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index a2a19e400..98b334f37 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -84,7 +84,7 @@ extension MainContentCoordinator { if needsQuery, let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - rowDataStore.setBuffer(RowBuffer(), for: tabId) + tableRowsStore.setTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() } @@ -100,12 +100,12 @@ extension MainContentCoordinator { // New tab — build filtered query directly, run once guard let tabIndex = tabManager.selectedTabIndex else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) let filteredQuery = queryBuilder.buildFilteredQuery( tableName: referencedTable, schemaName: fkInfo.referencedSchema, filters: [filter], - columns: buffer.columns, + columns: tableRows.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift index 945456474..13db7f2f1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift @@ -26,7 +26,7 @@ extension MainContentCoordinator { self.tabManager.tabs[capturedTabIndex].pagination.reset() let tab = self.tabManager.tabs[capturedTabIndex] - let buffer = self.rowDataStore.buffer(for: tab.id) + let buffer = self.tableRowsStore.tableRows(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildFilteredQuery( tableName: capturedTableName, @@ -64,7 +64,7 @@ extension MainContentCoordinator { guard capturedTabIndex < self.tabManager.tabs.count else { return } let tab = self.tabManager.tabs[capturedTabIndex] - let buffer = self.rowDataStore.buffer(for: tab.id) + let buffer = self.tableRowsStore.tableRows(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildBaseQuery( tableName: capturedTableName, @@ -95,7 +95,7 @@ extension MainContentCoordinator { let tableName = tabManager.tabs[tabIndex].tableContext.tableName else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) + let buffer = tableRowsStore.tableRows(for: tab.id) let hasFilters = filterStateManager.hasAppliedFilters let exclusions = columnExclusions(for: tableName) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index e42629602..e65fbc84a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -96,12 +96,12 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - let buffer = rowDataStore.buffer(for: tab.id) - let pageOffset = buffer.rows.count - buffer.rows.append(contentsOf: pagedResult.rows) + let existingRows = tableRowsStore.tableRows(for: tab.id) + let pageOffset = existingRows.rows.count tableRowsStore.updateTableRows(for: tab.id) { rows in _ = rows.appendPage(pagedResult.rows, startingAt: pageOffset) } + let newCount = pageOffset + pagedResult.rows.count tab.schemaVersion += 1 tab.pagination.loadMoreOffset = pagedResult.nextOffset tab.pagination.hasMoreRows = pagedResult.hasMore @@ -114,7 +114,7 @@ extension MainContentCoordinator { if capturedGeneration == queryGeneration { currentQueryTask = nil } - progressLog.info("[loadMore] applied totalRows=\(buffer.rows.count)") + progressLog.info("[loadMore] applied totalRows=\(newCount)") } } catch { await MainActor.run { [weak self] in @@ -140,7 +140,7 @@ extension MainContentCoordinator { tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore else { return } - let loadedCount = rowDataStore.buffer(for: tab.id).rows.count + let loadedCount = tableRowsStore.tableRows(for: tab.id).rows.count let totalEstimate = tab.pagination.totalRowCount let message: String @@ -221,8 +221,6 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - let buffer = rowDataStore.buffer(for: tab.id) - buffer.rows = result.rows tableRowsStore.updateTableRows(for: tab.id) { rows in _ = rows.replace(rows: result.rows) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index 0aad9cf24..617dc798a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -84,12 +84,12 @@ extension MainContentCoordinator { } let stmtTableName = await MainActor.run { extractTableName(from: sql) } - let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)") - rs.rowBuffer = RowBuffer( - rows: result.rows.map { row in row.map { $0.map { String($0) } } }, + let stmtRows = TableRows.from( + queryRows: result.rows.map { row in row.map { $0.map { String($0) } } }, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) + let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)", tableRows: stmtRows) rs.executionTime = result.executionTime rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage @@ -233,10 +233,6 @@ extension MainContentCoordinator { tableName = lastSelectSQL.flatMap { extractTableName(from: $0) } } - rowDataStore.setBuffer( - RowBuffer(rows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), - for: updatedTab.id - ) tableRowsStore.setTableRows( TableRows.from(queryRows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), for: updatedTab.id @@ -244,7 +240,6 @@ extension MainContentCoordinator { updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = tableName != nil && updatedTab.tableContext.isEditable } else { - rowDataStore.setBuffer(RowBuffer(), for: updatedTab.id) tableRowsStore.setTableRows(TableRows(), for: updatedTab.id) if updatedTab.tabType != .table { updatedTab.tableContext.tableName = nil diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f937e075f..15c81149a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -125,7 +125,6 @@ extension MainContentCoordinator { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - rowDataStore.setBuffer(RowBuffer(), for: tabId) tableRowsStore.setTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true @@ -208,7 +207,6 @@ 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.tableRowsStore.setTableRows(TableRows(), for: tabId) previewCoordinator.tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() @@ -281,7 +279,6 @@ extension MainContentCoordinator { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - rowDataStore.setBuffer(RowBuffer(), for: tabId) tableRowsStore.setTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data tabManager.tabs[tabIndex].pagination.reset() @@ -392,7 +389,6 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - rowDataStore.tearDown() tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil @@ -428,7 +424,6 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - rowDataStore.tearDown() tableRowsStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 451142c4b..e54e79175 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -188,20 +188,19 @@ extension MainContentCoordinator { return false } let tab = tabManager.tabs[idx] - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) guard tab.tableContext.tableName == tableName, - !buffer.columnDefaults.isEmpty, + !tableRows.columnDefaults.isEmpty, !tab.tableContext.primaryKeyColumns.isEmpty else { return false } - // Ensure every ENUM/SET column has its allowed values loaded - let enumSetColumnNames: [String] = buffer.columns.enumerated().compactMap { i, name in - guard i < buffer.columnTypes.count, - buffer.columnTypes[i].isEnumType || buffer.columnTypes[i].isSetType else { return nil } + let enumSetColumnNames: [String] = tableRows.columns.enumerated().compactMap { i, name in + guard i < tableRows.columnTypes.count, + tableRows.columnTypes[i].isEnumType || tableRows.columnTypes[i].isSetType else { return nil } return name } if !enumSetColumnNames.isEmpty, - !enumSetColumnNames.allSatisfy({ buffer.columnEnumValues[$0] != nil }) { + !enumSetColumnNames.allSatisfy({ tableRows.columnEnumValues[$0] != nil }) { return false } return true @@ -249,7 +248,10 @@ extension MainContentCoordinator { guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } var updatedTab = tabManager.tabs[idx] - let newBuffer = RowBuffer(rows: rows, columns: columns, columnTypes: columnTypes) + var columnEnumValues: [String: [String]] = [:] + var columnDefaults: [String: String?] = [:] + var columnForeignKeys: [String: ForeignKeyInfo] = [:] + var columnNullable: [String: Bool] = [:] updatedTab.schemaVersion += 1 updatedTab.execution.executionTime = executionTime updatedTab.execution.rowsAffected = rowsAffected @@ -258,20 +260,18 @@ extension MainContentCoordinator { updatedTab.execution.lastExecutedAt = Date() updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = isEditable - // Populate enum values from column types for the enum popover - for (index, colType) in newBuffer.columnTypes.enumerated() { - if case .enumType(_, let values) = colType, let vals = values, index < newBuffer.columns.count { - newBuffer.columnEnumValues[newBuffer.columns[index]] = vals + for (index, colType) in columnTypes.enumerated() { + if case .enumType(_, let values) = colType, let vals = values, index < columns.count { + columnEnumValues[columns[index]] = vals } } - // Merge FK metadata into the same update if available if let metadata { - newBuffer.columnDefaults = metadata.columnDefaults - newBuffer.columnForeignKeys = metadata.columnForeignKeys - newBuffer.columnNullable = metadata.columnNullable + columnDefaults = metadata.columnDefaults + columnForeignKeys = metadata.columnForeignKeys + columnNullable = metadata.columnNullable for (col, vals) in metadata.columnEnumValues { - newBuffer.columnEnumValues[col] = vals + columnEnumValues[col] = vals } if let approxCount = metadata.approximateRowCount, approxCount > 0 { updatedTab.pagination.totalRowCount = approxCount @@ -282,22 +282,18 @@ extension MainContentCoordinator { updatedTab.metadataVersion += 1 } - rowDataStore.setBuffer(newBuffer, for: updatedTab.id) - let newTableRows = TableRows.from( - queryRows: newBuffer.rows, - columns: newBuffer.columns, - columnTypes: newBuffer.columnTypes, - columnDefaults: newBuffer.columnDefaults, - columnForeignKeys: newBuffer.columnForeignKeys, - columnEnumValues: newBuffer.columnEnumValues, - columnNullable: newBuffer.columnNullable + queryRows: rows, + columns: columns, + columnTypes: columnTypes, + columnDefaults: columnDefaults, + columnForeignKeys: columnForeignKeys, + columnEnumValues: columnEnumValues, + columnNullable: columnNullable ) tableRowsStore.setTableRows(newTableRows, for: updatedTab.id) - // Create a ResultSet for this single-statement execution - let rs = ResultSet(label: tableName ?? "Result") - rs.rowBuffer = newBuffer + let rs = ResultSet(label: tableName ?? "Result", tableRows: newTableRows) rs.executionTime = updatedTab.execution.executionTime rs.rowsAffected = updatedTab.execution.rowsAffected rs.statusMessage = updatedTab.execution.statusMessage @@ -477,14 +473,11 @@ extension MainContentCoordinator { guard capturedGeneration == queryGeneration else { return } guard !Task.isCancelled else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - let buffer = rowDataStore.buffer(for: tabId) + let existing = tableRowsStore.tableRows(for: tabId) let hasNewValues = columnEnumValues.contains { key, value in - buffer.columnEnumValues[key] != value + existing.columnEnumValues[key] != value } if hasNewValues { - for (col, vals) in columnEnumValues { - buffer.columnEnumValues[col] = vals - } tableRowsStore.updateTableRows(for: tabId) { rows in for (col, vals) in columnEnumValues { rows.columnEnumValues[col] = vals diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift index 841ecba45..256f3680e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift @@ -331,12 +331,12 @@ extension MainContentCoordinator { } let stmtTableName = await MainActor.run { extractTableName(from: stmtSQL) } - let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)") - rs.rowBuffer = RowBuffer( - rows: result.rows.map { row in row.map { $0.map { String($0) } } }, + let stmtRows = TableRows.from( + queryRows: result.rows.map { row in row.map { $0.map { String($0) } } }, columns: result.columns.map { String($0) }, columnTypes: result.columnTypes ) + let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)", tableRows: stmtRows) rs.executionTime = result.executionTime rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 7e8b6ad99..af68e7f37 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -249,7 +249,6 @@ extension MainContentCoordinator { let firstRemovedIndex = tabManager.tabs .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 for tabId in tabIdsToRemove { - rowDataStore.removeBuffer(for: tabId) tableRowsStore.removeTableRows(for: tabId) } tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 7e65f610b..f6c390b25 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -22,7 +22,7 @@ extension MainContentCoordinator { tabManager.tabs[tabIdx].display.activeResultSetId = tabManager.tabs[tabIdx].display.resultSets.last?.id } if tabManager.tabs[tabIdx].display.resultSets.isEmpty { - rowDataStore.setBuffer(RowBuffer(), for: tabManager.tabs[tabIdx].id) + tableRowsStore.setTableRows(TableRows(), for: tabManager.tabs[tabIdx].id) tabManager.tabs[tabIdx].execution.errorMessage = nil tabManager.tabs[tabIdx].execution.rowsAffected = 0 tabManager.tabs[tabIdx].execution.executionTime = nil @@ -105,7 +105,7 @@ extension MainContentCoordinator { func openExportQueryResultsDialog() { guard let tab = tabManager.selectedTab, - !rowDataStore.buffer(for: tab.id).rows.isEmpty else { return } + !tableRowsStore.tableRows(for: tab.id).rows.isEmpty else { return } activeSheet = .exportQueryResults } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index 364bec943..998705365 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -23,10 +23,10 @@ extension MainContentCoordinator { let editedFields = editState.getEditedFields() guard !editedFields.isEmpty else { return } - let buffer = rowDataStore.buffer(for: tab.id) + let tableRows = tableRowsStore.tableRows(for: tab.id) let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex in - guard rowIndex < buffer.rows.count else { return nil } - let originalRow = buffer.rows[rowIndex] + guard rowIndex < tableRows.rows.count else { return nil } + let originalRow = tableRows.rows[rowIndex].values return RowChange( rowIndex: rowIndex, type: .update, @@ -35,7 +35,7 @@ extension MainContentCoordinator { rowIndex: rowIndex, columnIndex: field.columnIndex, columnName: field.columnName, - oldValue: originalRow[field.columnIndex], + oldValue: field.columnIndex < originalRow.count ? originalRow[field.columnIndex] : nil, newValue: field.newValue ) }, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index bc7eb833f..4e45c1b7e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -57,7 +57,7 @@ extension MainContentCoordinator { if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { let newTab = tabManager.tabs[newIndex] - let newBuffer = rowDataStore.buffer(for: newId) + let newRows = tableRowsStore.tableRows(for: newId) // Restore filter state for new tab filterStateManager.restoreFromTabState(newTab.filterState) @@ -75,9 +75,9 @@ extension MainContentCoordinator { } else { changeManager.configureForTable( tableName: newTab.tableContext.tableName ?? "", - columns: newBuffer.columns, + columns: newRows.columns, primaryKeyColumns: newTab.tableContext.primaryKeyColumns.isEmpty - ? newBuffer.columns.prefix(1).map { $0 } + ? newRows.columns.prefix(1).map { $0 } : newTab.tableContext.primaryKeyColumns, databaseType: connection.type, triggerReload: false @@ -112,7 +112,7 @@ extension MainContentCoordinator { // If the tab shows isExecuting but has no results, the previous query was // likely cancelled when the user rapidly switched away. Force-clear the stale // flag so the lazy-load check below can re-execute the query. - if newTab.execution.isExecuting && newBuffer.rows.isEmpty && newTab.execution.lastExecutedAt == nil { + if newTab.execution.isExecuting && newRows.rows.isEmpty && newTab.execution.lastExecutedAt == nil { let tabId = newId Task { [weak self] in guard let self, @@ -122,9 +122,9 @@ extension MainContentCoordinator { } } - let isEvicted = newBuffer.isEvicted + let isEvicted = tableRowsStore.isEvicted(newId) let needsLazyQuery = newTab.tabType == .table - && (newBuffer.rows.isEmpty || isEvicted) + && (newRows.rows.isEmpty || isEvicted) && (newTab.execution.lastExecutedAt == nil || isEvicted) && newTab.execution.errorMessage == nil && !newTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -154,15 +154,15 @@ extension MainContentCoordinator { private func evictInactiveTabs(excluding activeTabIds: Set) { let start = Date() - let candidates: [(tab: QueryTab, buffer: RowBuffer)] = tabManager.tabs.compactMap { tab in + let candidates: [(tab: QueryTab, rows: TableRows)] = tabManager.tabs.compactMap { tab in guard !activeTabIds.contains(tab.id), tab.execution.lastExecutedAt != nil, !tab.pendingChanges.hasChanges, - let buffer = rowDataStore.existingBuffer(for: tab.id), - !buffer.isEvicted, - !buffer.rows.isEmpty + let rows = tableRowsStore.existingTableRows(for: tab.id), + !tableRowsStore.isEvicted(tab.id), + !rows.rows.isEmpty else { return nil } - return (tab, buffer) + return (tab, rows) } let sorted = candidates.sorted { @@ -170,12 +170,12 @@ extension MainContentCoordinator { let t1 = $1.tab.execution.lastExecutedAt ?? .distantFuture if t0 != t1 { return t0 < t1 } let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.buffer.rows.count, - columnCount: $0.buffer.columns.count + rowCount: $0.rows.rows.count, + columnCount: $0.rows.columns.count ) let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.buffer.rows.count, - columnCount: $1.buffer.columns.count + rowCount: $1.rows.rows.count, + columnCount: $1.rows.columns.count ) return size0 > size1 } @@ -190,7 +190,6 @@ extension MainContentCoordinator { let toEvict = sorted.dropLast(maxInactiveLoaded) for entry in toEvict { - entry.buffer.evict() tableRowsStore.evict(for: entry.tab.id) } Self.lifecycleLogger.debug( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 70806c38e..07f0244ab 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -40,10 +40,11 @@ extension MainContentCoordinator { DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false let needsLazyLoad = tabManager.selectedTab.map { tab in - let buffer = rowDataStore.buffer(for: tab.id) + let rows = tableRowsStore.tableRows(for: tab.id) + let isEvicted = tableRowsStore.isEvicted(tab.id) return tab.tabType == .table - && (buffer.rows.isEmpty || buffer.isEvicted) - && (tab.execution.lastExecutedAt == nil || buffer.isEvicted) + && (rows.rows.isEmpty || isEvicted) + && (tab.execution.lastExecutedAt == nil || isEvicted) && tab.execution.errorMessage == nil && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 7066017a1..516776968 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -16,19 +16,19 @@ extension MainContentView { guard let tab = coordinator.tabManager.selectedTab, !coordinator.selectionState.indices.isEmpty, let firstIndex = coordinator.selectionState.indices.min() else { return nil } - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - guard firstIndex < buffer.rows.count else { return nil } + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + guard firstIndex < tableRows.rows.count else { return nil } - let row = buffer.rows[firstIndex] + let row = tableRows.rows[firstIndex].values var data: [(column: String, value: String?, type: String)] = [] let service = ValueDisplayFormatService.shared let connId = coordinator.connection.id let tblName = tab.tableContext.tableName - for (i, col) in buffer.columns.enumerated() { + for (i, col) in tableRows.columns.enumerated() { var value = i < row.count ? row[i] : nil - let type = i < buffer.columnTypes.count ? buffer.columnTypes[i].displayName : "string" + let type = i < tableRows.columnTypes.count ? tableRows.columnTypes[i].displayName : "string" // Apply display format if active if let rawValue = value { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index d2f0dbe1c..7888826dc 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -101,11 +101,11 @@ extension MainContentView { private func buildQueryResultsSummary() -> String? { guard let tab = currentTab else { return nil } - let buffer = coordinator.rowDataStore.buffer(for: tab.id) - guard !buffer.columns.isEmpty, !buffer.rows.isEmpty else { return nil } + let tableRows = coordinator.tableRowsStore.tableRows(for: tab.id) + guard !tableRows.columns.isEmpty, !tableRows.rows.isEmpty else { return nil } - let columns = buffer.columns - let rows = buffer.rows + let columns = tableRows.columns + let rows = tableRows.rows let maxRows = 10 let displayRows = Array(rows.prefix(maxRows)) @@ -114,7 +114,7 @@ extension MainContentView { for row in displayRows { let values = columns.indices.map { i in - let raw = i < row.count ? (row[i] ?? "NULL") : "NULL" + let raw = i < row.values.count ? (row.values[i] ?? "NULL") : "NULL" return (raw as NSString).length > 200 ? String(raw.prefix(200)) + "..." : raw } lines.append(values.joined(separator: " | ")) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 699b7adc4..e9400fb27 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -380,7 +380,7 @@ final class MainContentCommandActions { } else if coordinator?.tabManager.tabs.isEmpty == true { window.close() } else { - coordinator?.rowDataStore.evictAll(except: nil) + coordinator?.tableRowsStore.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 ffd20f8e3..cf35d3462 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -89,7 +89,6 @@ final class MainContentCoordinator { let filterStateManager: FilterStateManager let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState - let rowDataStore = RowDataStore() let tableRowsStore = TableRowsStore() // MARK: - Services @@ -341,9 +340,7 @@ final class MainContentCoordinator { func evictInactiveRowData() { let selectedId = tabManager.selectedTabId for tab in tabManager.tabs where tab.id != selectedId && !tab.pendingChanges.hasChanges { - guard let buffer = rowDataStore.existingBuffer(for: tab.id), - !buffer.isEvicted, !buffer.rows.isEmpty else { continue } - buffer.evict() + tableRowsStore.evict(for: tab.id) } } @@ -576,20 +573,14 @@ final class MainContentCoordinator { for task in activeSortTasks.values { task.cancel() } activeSortTasks.removeAll() - // Let the view layer release cached row providers before we drop RowBuffers. - // Called synchronously here because SwiftUI onChange handlers don't fire - // reliably on disappearing views. onTeardown?() onTeardown = nil - // Notify DataGridView coordinators to release NSTableView cell views NotificationCenter.default.post( name: Self.teardownNotification, object: connection.id ) - // Release heavy data so memory drops even if SwiftUI delays deallocation - rowDataStore.tearDown() tableRowsStore.tearDown() querySortCache.removeAll() cachedTableColumnTypes.removeAll() @@ -1313,8 +1304,8 @@ final class MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) - guard columnIndex >= 0 && columnIndex < buffer.columns.count else { return } + let tableRows = tableRowsStore.tableRows(for: tab.id) + guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } var currentSort = tab.sortState let newDirection: SortDirection = ascending ? .ascending : .descending @@ -1342,7 +1333,7 @@ final class MainContentCoordinator { // When more rows are available server-side, re-execute with ORDER BY // instead of sorting locally (we only have a partial result set) if tab.pagination.hasMoreRows { - let columnName = buffer.columns[columnIndex] + let columnName = tableRows.columns[columnIndex] let direction = currentSort.columns.first?.direction == .ascending ? "ASC" : "DESC" let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) @@ -1362,8 +1353,8 @@ final class MainContentCoordinator { let tabId = tab.id let schemaVersion = tab.schemaVersion let sortColumns = currentSort.columns - let colTypes = buffer.columnTypes - let storageRows = tableRowsStore.existingTableRows(for: tabId)?.rows ?? [] + let colTypes = tableRows.columnTypes + let storageRows = tableRows.rows let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, $0.values) } if storageRows.count > 1_000 { @@ -1416,7 +1407,7 @@ final class MainContentCoordinator { let tabId = tab.id let capturedSort = currentSort let capturedQuery = tab.content.query - let capturedColumns = buffer.columns + let capturedColumns = tableRows.columns confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in guard let self, confirmed, let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 0945c0a0e..fff167dbf 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -351,7 +351,7 @@ struct MainContentView: View { handleStructureChange() } .onChange(of: currentTab?.schemaVersion) { _, _ in - let columns = currentTab.map { coordinator.rowDataStore.buffer(for: $0.id).columns } + let columns = currentTab.map { coordinator.tableRowsStore.tableRows(for: $0.id).columns } handleColumnsChange(newColumns: columns) } .task { handleConnectionStatusChange() } diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index 8427d60a1..c3c2bcc99 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -360,32 +360,22 @@ final class DataGridCellFactory { /// Since the cell font is monospaced, we avoid per-row CoreText measurement /// and instead multiply character count by the pre-computed glyph advance width. /// This reduces the cost from O(sampleRows * CoreText) to O(sampleRows * 1). - /// - /// - Parameters: - /// - columnName: The column header name - /// - columnIndex: Index of the column - /// - rowProvider: Provider to get sample row data - /// - Returns: Optimal column width within min/max bounds func calculateOptimalColumnWidth( for columnName: String, columnIndex: Int, - rowProvider: InMemoryRowProvider + tableRows: TableRows ) -> CGFloat { - // For header: use character count * average proportional char width - // instead of CoreText measurement. ~0.6 of mono width is a good estimate - // for proportional system font. let headerCharCount = (columnName as NSString).length var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48 - let totalRows = rowProvider.totalRowCount - let columnCount = rowProvider.columns.count - // Reduce sample count for wide tables to keep total work bounded + let totalRows = tableRows.count + let columnCount = tableRows.columns.count let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount let step = max(1, totalRows / effectiveSampleCount) let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { - guard let value = rowProvider.value(atRow: i, column: columnIndex) else { continue } + guard let value = tableRows.value(at: i, column: columnIndex) else { continue } let charCount = min((value as NSString).length, Self.maxMeasureChars) let cellWidth = CGFloat(charCount) * charWidth + 16 @@ -404,19 +394,19 @@ final class DataGridCellFactory { func calculateFitToContentWidth( for columnName: String, columnIndex: Int, - rowProvider: InMemoryRowProvider + tableRows: TableRows ) -> CGFloat { let headerCharCount = (columnName as NSString).length var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48 - let totalRows = rowProvider.totalRowCount - let columnCount = rowProvider.columns.count + let totalRows = tableRows.count + let columnCount = tableRows.columns.count let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount let step = max(1, totalRows / effectiveSampleCount) let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { - guard let value = rowProvider.value(atRow: i, column: columnIndex) else { continue } + guard let value = tableRows.value(at: i, column: columnIndex) else { continue } let charCount = (value as NSString).length let cellWidth = CGFloat(charCount) * charWidth + 16 diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 90177995d..71f09071c 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -15,9 +15,9 @@ import SwiftUI final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate { - var rowProvider: InMemoryRowProvider var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } + var cachedTableRows: TableRows = TableRows() var changeManager: AnyChangeManager var isEditable: Bool var sortedIDs: [RowID]? @@ -43,14 +43,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func persistColumnLayoutToStorage() { guard tabType == .table else { return } guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return } - guard !rowProvider.columns.isEmpty else { return } + guard !cachedTableRows.columns.isEmpty else { return } var widths: [String: CGFloat] = [:] var order: [String] = [] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = DataGridView.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let name = rowProvider.columns[colIndex] + colIndex < cachedTableRows.columns.count else { continue } + let name = cachedTableRows.columns[colIndex] widths[name] = column.width order.append(name) } @@ -67,11 +67,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let tableRowsController = TableRowsController() var overlayEditor: CellOverlayEditor? - // Settings observer for real-time updates var settingsObserver: NSObjectProtocol? - // Theme observer for font/color changes var themeObserver: NSObjectProtocol? - /// Snapshot of last-seen data grid settings for change detection private var lastDataGridSettings: DataGridSettings @Binding var selectedRowIndices: Set @@ -84,14 +81,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private(set) var enumOrSetColumns: Set = [] private(set) var fkColumns: Set = [] var isSyncingSortDescriptors: Bool = false - /// Suppresses selection delegate callbacks during programmatic selection sync var isSyncingSelection = false var isRebuildingColumns: Bool = false var hasUserResizedColumns: Bool = false - /// Guards against two-frame bounce when async column layout write-back triggers updateNSView var isWritingColumnLayout: Bool = false var isEscapeCancelling = false - /// Debounced task for persisting column layout after resize/reorder var layoutPersistTask: Task? static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") @@ -105,13 +99,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } init( - rowProvider: InMemoryRowProvider, changeManager: AnyChangeManager, isEditable: Bool, selectedRowIndices: Binding>, delegate: (any DataGridViewDelegate)? ) { - self.rowProvider = rowProvider self.changeManager = changeManager self.isEditable = isEditable self._selectedRowIndices = selectedRowIndices @@ -120,10 +112,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData super.init() updateCache() - // Subscribe to theme changes for font/color updates observeThemeChanges() - // Subscribe to settings changes for real-time updates settingsObserver = NotificationCenter.default.addObserver( forName: .dataGridSettingsDidChange, object: nil, @@ -143,13 +133,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData tableView.tile() } - // Font changes are handled by .themeDidChange observer. - // Check for data format changes that need cell re-rendering. let dataChanged = prev.dateFormat != settings.dateFormat || prev.nullDisplay != settings.nullDisplay || prev.enableSmartValueDetection != settings.enableSmartValueDetection - // When smart detection is toggled off, clear display formats so they stop being applied if prev.enableSmartValueDetection != settings.enableSmartValueDetection && !settings.enableSmartValueDetection { self.updateDisplayFormats([]) @@ -200,14 +187,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData /// Called during coordinator teardown to free memory while SwiftUI holds the view. private func releaseData() { overlayEditor?.dismiss(commit: false) - rowProvider = InMemoryRowProvider(rows: [], columns: []) + cachedTableRows = TableRows() rowVisualStateCache.removeAll() displayCache.removeAll() columnDisplayFormats = [] cachedRowCount = 0 cachedColumnCount = 0 sortedIDs = nil - // Remove columns and reload to release cell views if let tableView { while let col = tableView.tableColumns.last { tableView.removeTableColumn(col) @@ -215,7 +201,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData tableView.reloadData() } tableRowsController.detach() - // Release delegate delegate = nil activeFKPreviewPopover?.close() activeFKPreviewPopover = nil @@ -236,8 +221,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func updateCache() { - cachedRowCount = rowProvider.totalRowCount - cachedColumnCount = rowProvider.columns.count + cachedRowCount = sortedIDs?.count ?? cachedTableRows.count + cachedColumnCount = cachedTableRows.columns.count } func applyInsertedRows(_ indices: IndexSet) { @@ -433,10 +418,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func rebuildColumnMetadataCache() { var enumSet = Set() var fkSet = Set() - let columns = rowProvider.columns - let types = rowProvider.columnTypes - let enumValues = rowProvider.columnEnumValues - let fkKeys = rowProvider.columnForeignKeys + let columns = cachedTableRows.columns + let types = cachedTableRows.columnTypes + let enumValues = cachedTableRows.columnEnumValues + let fkKeys = cachedTableRows.columnForeignKeys for i in 0.. RowVisualState { - // If delegate provides custom visual state, use it if let delegateState = delegate?.dataGridVisualState(forRow: row) { return delegateState } - // Otherwise use cache return rowVisualStateCache[row] ?? .empty } diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 149495cfb..7a70d8235 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -26,7 +26,10 @@ extension TableViewCoordinator { func undoInsertRow(at index: Int) { delegate?.dataGridUndoInsert(at: index) changeManager.undoRowInsertion(rowIndex: index) - rowProvider.removeRow(at: index) + tableRowsMutator { rows in + _ = rows.remove(at: IndexSet(integer: index)) + } + cachedTableRows = tableRowsProvider() updateCache() tableView?.reloadData() } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index fe93dc51e..3498e8db2 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -56,7 +56,6 @@ struct DataGridIdentity: Equatable { /// High-performance table view using AppKit NSTableView struct DataGridView: NSViewRepresentable { - let rowProvider: InMemoryRowProvider var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } var changeManager: AnyChangeManager @@ -88,7 +87,6 @@ struct DataGridView: NSViewRepresentable { tableView.style = .plain tableView.setAccessibilityLabel(String(localized: "Data grid")) tableView.setAccessibilityRole(.table) - // Use settings for alternate row backgrounds let settings = AppSettingsManager.shared.dataGrid tableView.usesAlternatingRowBackgroundColors = settings.showAlternateRows tableView.allowsMultipleSelection = true @@ -97,7 +95,6 @@ struct DataGridView: NSViewRepresentable { tableView.columnAutoresizingStyle = .noColumnAutoresizing tableView.gridStyleMask = [.solidVerticalGridLineMask] tableView.intercellSpacing = NSSize(width: 1, height: 0) - // Use settings for row height tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) tableView.delegate = context.coordinator @@ -106,7 +103,6 @@ struct DataGridView: NSViewRepresentable { tableView.action = #selector(TableViewCoordinator.handleClick(_:)) tableView.doubleAction = #selector(TableViewCoordinator.handleDoubleClick(_:)) - // Add row number column let rowNumberColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("__rowNumber__")) rowNumberColumn.title = "#" rowNumberColumn.width = 40 @@ -118,13 +114,15 @@ struct DataGridView: NSViewRepresentable { tableView.addTableColumn(rowNumberColumn) rowNumberColumn.isHidden = !configuration.showRowNumbers - // Add data columns (suppress resize notifications during setup) + let initialRows = tableRowsProvider() + context.coordinator.cachedTableRows = initialRows + context.coordinator.isRebuildingColumns = true - for (index, columnName) in rowProvider.columns.enumerated() { + for (index, columnName) in initialRows.columns.enumerated() { let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) column.title = columnName - if index < rowProvider.columnTypes.count { - let typeName = rowProvider.columnTypes[index].rawType ?? rowProvider.columnTypes[index].displayName + if index < initialRows.columnTypes.count { + let typeName = initialRows.columnTypes[index].rawType ?? initialRows.columnTypes[index].displayName column.headerToolTip = "\(columnName) (\(typeName))" } column.headerCell.setAccessibilityLabel( @@ -133,7 +131,7 @@ struct DataGridView: NSViewRepresentable { column.width = context.coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: index, - rowProvider: rowProvider + tableRows: initialRows ) column.minWidth = 30 column.resizingMask = .userResizingMask @@ -145,12 +143,11 @@ struct DataGridView: NSViewRepresentable { tableView.addTableColumn(column) } - // Apply saved column widths (from user resizing) if !columnLayout.columnWidths.isEmpty { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let baseName = rowProvider.columns[colIndex] + colIndex < initialRows.columns.count else { continue } + let baseName = initialRows.columns[colIndex] if let savedWidth = columnLayout.columnWidths[baseName] { column.width = savedWidth } @@ -158,14 +155,12 @@ struct DataGridView: NSViewRepresentable { context.coordinator.hasUserResizedColumns = true } - // Apply saved column order if let savedOrder = columnLayout.columnOrder { - DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: rowProvider.columns) + DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: initialRows.columns) } context.coordinator.isRebuildingColumns = false - // Apply column visibility - applyColumnVisibility(to: tableView) + applyColumnVisibility(to: tableView, columns: initialRows.columns) if let headerView = tableView.headerView { let headerMenu = NSMenu() @@ -173,7 +168,6 @@ struct DataGridView: NSViewRepresentable { headerView.menu = headerMenu } - // Register for row drag-and-drop if delegate supports move let hasMoveRow = delegate != nil if hasMoveRow { tableView.registerForDraggedTypes([NSPasteboard.PasteboardType("com.TablePro.rowDrag")]) @@ -210,11 +204,9 @@ struct DataGridView: NSViewRepresentable { let coordinator = context.coordinator - // Don't reload while editing (field editor or overlay) if tableView.editedRow >= 0 { return } if let editor = context.coordinator.overlayEditor, editor.isActive { return } - // Sync row number visibility before identity check (setting can change without data change) if let rowNumCol = tableView.tableColumns.first(where: { $0.identifier.rawValue == "__rowNumber__" }) { let shouldHide = !configuration.showRowNumbers if rowNumCol.isHidden != shouldHide { @@ -222,7 +214,6 @@ struct DataGridView: NSViewRepresentable { } } - // Sync row drag registration when delegate availability changes let rowDragType = NSPasteboard.PasteboardType("com.TablePro.rowDrag") let hasDragRegistered = tableView.registeredDraggedTypes.contains(rowDragType) let hasMoveRow = delegate != nil @@ -241,15 +232,18 @@ struct DataGridView: NSViewRepresentable { coordinator.observeTeardown(connectionId: connectionId) } - // Identity-based early-return BEFORE reading settings — avoids - // AppSettingsManager access on every SwiftUI re-evaluation. + let latestRows = tableRowsProvider() + coordinator.cachedTableRows = latestRows + let rowDisplayCount = sortedIDs?.count ?? latestRows.count + let columnCount = latestRows.columns.count + let currentIdentity = DataGridIdentity( reloadVersion: changeManager.reloadVersion, schemaVersion: schemaVersion, metadataVersion: metadataVersion, paginationVersion: paginationVersion, - rowCount: rowProvider.totalRowCount, - columnCount: rowProvider.columns.count, + rowCount: rowDisplayCount, + columnCount: columnCount, isEditable: isEditable, configuration: configuration ) @@ -265,7 +259,6 @@ struct DataGridView: NSViewRepresentable { let previousIdentity = coordinator.lastIdentity coordinator.lastIdentity = currentIdentity - // Update settings-based properties dynamically (after identity check) let settings = AppSettingsManager.shared.dataGrid if tableView.rowHeight != CGFloat(settings.rowHeight.rawValue) { tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) @@ -278,30 +271,10 @@ struct DataGridView: NSViewRepresentable { let metadataChanged = previousIdentity.map { $0.metadataVersion != metadataVersion } ?? false let oldRowCount = coordinator.cachedRowCount let oldColumnCount = coordinator.cachedColumnCount - let newRowCount = rowProvider.totalRowCount - let newColumnCount = rowProvider.columns.count - // Only do full reload if row/column count changed, columns changed, or result version changed - // For cell edits (versionChanged but same count), use granular reload - let structureChanged = oldRowCount != newRowCount || oldColumnCount != newColumnCount + let structureChanged = oldRowCount != rowDisplayCount || oldColumnCount != columnCount let needsFullReload = structureChanged - coordinator.rowProvider = rowProvider - - // Re-apply pending cell edits only when changes have been modified - if changeManager.reloadVersion != coordinator.lastReapplyVersion { - coordinator.lastReapplyVersion = changeManager.reloadVersion - for rowChange in changeManager.rowChanges { - for cellChange in rowChange.cellChanges { - coordinator.rowProvider.updateValue( - cellChange.newValue, - at: rowChange.rowIndex, - columnIndex: cellChange.columnIndex - ) - } - } - } - coordinator.updateCache() coordinator.rebuildColumnMetadataCache() @@ -332,37 +305,33 @@ struct DataGridView: NSViewRepresentable { coordinator.rebuildVisualStateCache() - // Capture current column layout before any rebuilds (only if not about to rebuild) - // Check if columns changed (by name or structure) let currentDataColumns = tableView.tableColumns.dropFirst() let currentColumnIds = currentDataColumns.map { $0.identifier.rawValue } - let expectedColumnIds = rowProvider.columns.indices.map { Self.columnIdentifier(for: $0).rawValue } - let columnsChanged = !rowProvider.columns.isEmpty && (currentColumnIds != expectedColumnIds) + let expectedColumnIds = latestRows.columns.indices.map { Self.columnIdentifier(for: $0).rawValue } + let columnsChanged = !latestRows.columns.isEmpty && (currentColumnIds != expectedColumnIds) - // Only recalculate column widths when transitioning from 0 rows (initial data load). - // When row count changes but columns are the same and already have widths, skip - // the expensive calculateOptimalColumnWidth calls. - let isInitialDataLoad = structureChanged && oldRowCount == 0 && !rowProvider.columns.isEmpty + let isInitialDataLoad = structureChanged && oldRowCount == 0 && !latestRows.columns.isEmpty let shouldRebuildColumns = columnsChanged || isInitialDataLoad updateColumns( tableView: tableView, coordinator: coordinator, + tableRows: latestRows, columnsChanged: columnsChanged, shouldRebuild: shouldRebuildColumns, structureChanged: structureChanged ) - // Sync column visibility - applyColumnVisibility(to: tableView) + applyColumnVisibility(to: tableView, columns: latestRows.columns) - syncSortDescriptors(tableView: tableView, coordinator: coordinator) + syncSortDescriptors(tableView: tableView, coordinator: coordinator, columns: latestRows.columns) let paginationChanged = previousIdentity.map { $0.paginationVersion != paginationVersion } ?? false reloadAndSyncSelection( tableView: tableView, coordinator: coordinator, + tableRows: latestRows, needsFullReload: needsFullReload, versionChanged: versionChanged, metadataChanged: metadataChanged, @@ -372,10 +341,10 @@ struct DataGridView: NSViewRepresentable { // MARK: - updateNSView Helpers - /// Rebuild or sync table columns based on data changes private func updateColumns( tableView: NSTableView, coordinator: TableViewCoordinator, + tableRows: TableRows, columnsChanged: Bool, shouldRebuild: Bool, structureChanged: Bool @@ -385,19 +354,18 @@ struct DataGridView: NSViewRepresentable { defer { coordinator.isRebuildingColumns = false } if columnsChanged { - // Column count changed — full rebuild (remove all, create all) let columnsToRemove = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } for column in columnsToRemove { tableView.removeTableColumn(column) } let willRestoreWidths = !columnLayout.columnWidths.isEmpty - for (index, columnName) in rowProvider.columns.enumerated() { + for (index, columnName) in tableRows.columns.enumerated() { let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) column.title = columnName - if index < rowProvider.columnTypes.count { - let typeName = rowProvider.columnTypes[index].rawType - ?? rowProvider.columnTypes[index].displayName + if index < tableRows.columnTypes.count { + let typeName = tableRows.columnTypes[index].rawType + ?? tableRows.columnTypes[index].displayName column.headerToolTip = "\(columnName) (\(typeName))" } column.headerCell.setAccessibilityLabel( @@ -409,7 +377,7 @@ struct DataGridView: NSViewRepresentable { column.width = coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: index, - rowProvider: rowProvider + tableRows: tableRows ) } column.minWidth = 30 @@ -422,23 +390,22 @@ struct DataGridView: NSViewRepresentable { tableView.addTableColumn(column) } } else { - // Same column count — lightweight in-place update (avoids remove/add overhead) let hasSavedWidths = !columnLayout.columnWidths.isEmpty for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let columnName = rowProvider.columns[colIndex] + colIndex < tableRows.columns.count else { continue } + let columnName = tableRows.columns[colIndex] column.title = columnName - if colIndex < rowProvider.columnTypes.count { - let typeName = rowProvider.columnTypes[colIndex].rawType - ?? rowProvider.columnTypes[colIndex].displayName + if colIndex < tableRows.columnTypes.count { + let typeName = tableRows.columnTypes[colIndex].rawType + ?? tableRows.columnTypes[colIndex].displayName column.headerToolTip = "\(columnName) (\(typeName))" } if !hasSavedWidths { column.width = coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: colIndex, - rowProvider: rowProvider + tableRows: tableRows ) } column.isEditable = isEditable @@ -446,12 +413,11 @@ struct DataGridView: NSViewRepresentable { } let hasSavedLayout = !columnLayout.columnWidths.isEmpty - // Restore saved column widths after rebuild (from user resize or persisted layout) if hasSavedLayout { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let baseName = rowProvider.columns[colIndex] + colIndex < tableRows.columns.count else { continue } + let baseName = tableRows.columns[colIndex] if let savedWidth = columnLayout.columnWidths[baseName] { column.width = savedWidth } @@ -459,21 +425,17 @@ struct DataGridView: NSViewRepresentable { coordinator.hasUserResizedColumns = true } - // Restore saved column order after rebuild if let savedOrder = columnLayout.columnOrder { - DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: rowProvider.columns) + DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: tableRows.columns) coordinator.hasUserResizedColumns = true } - // Persist calculated widths so subsequent tab switches reuse them - // instead of calling the expensive calculateOptimalColumnWidth. - // Skip when saved layout exists to avoid overwriting persisted values. if !coordinator.hasUserResizedColumns, !hasSavedLayout { var newWidths: [String: CGFloat] = [:] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - newWidths[rowProvider.columns[colIndex]] = column.width + colIndex < tableRows.columns.count else { continue } + newWidths[tableRows.columns[colIndex]] = column.width } if !newWidths.isEmpty && newWidths != columnLayout.columnWidths { coordinator.isWritingColumnLayout = true @@ -484,25 +446,19 @@ struct DataGridView: NSViewRepresentable { } } } else { - // Always sync column editability (e.g., view tabs reusing table columns) for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { column.isEditable = isEditable } - // Skip layout capture when an async layout write-back is pending — - // prevents the two-frame bounce where stale widths are applied - // before the async block updates them. guard !coordinator.isWritingColumnLayout else { return } - // Capture current column layout from user interactions (resize/reorder) - // Only done in the non-rebuild path to avoid feedback loops if coordinator.hasUserResizedColumns, tableView.tableColumns.count > 1 { var currentWidths: [String: CGFloat] = [:] var currentOrder: [String] = [] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let baseName = rowProvider.columns[colIndex] + colIndex < tableRows.columns.count else { continue } + let baseName = tableRows.columns[colIndex] currentWidths[baseName] = column.width currentOrder.append(baseName) } @@ -525,8 +481,7 @@ struct DataGridView: NSViewRepresentable { } } - /// Synchronize sort descriptors and indicators with the table view - private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator) { + private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) { coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } @@ -535,8 +490,7 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = [] } } else if let firstSort = sortState.columns.first, - firstSort.columnIndex >= 0 && firstSort.columnIndex < rowProvider.columns.count { - // Sync with first sort column for NSTableView's built-in sort indicators + firstSort.columnIndex >= 0 && firstSort.columnIndex < columns.count { let key = Self.columnIdentifier(for: firstSort.columnIndex).rawValue let ascending = firstSort.direction == .ascending let currentDescriptor = tableView.sortDescriptors.first @@ -545,14 +499,13 @@ struct DataGridView: NSViewRepresentable { } } - // Update column header titles for multi-sort indicators - Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: rowProvider.columns) + Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: columns) } - /// Reload table data as needed and synchronize selection and editing state private func reloadAndSyncSelection( tableView: NSTableView, coordinator: TableViewCoordinator, + tableRows: TableRows, needsFullReload: Bool, versionChanged: Bool, metadataChanged: Bool = false, @@ -561,15 +514,13 @@ struct DataGridView: NSViewRepresentable { if needsFullReload { tableView.reloadData() } else if metadataChanged { - // FK metadata arrived (Phase 2) — reload only FK columns to show arrow buttons. - // Use display-order indices from tableView.tableColumns (respects user column reordering). let fkColumnIndices = IndexSet( tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in guard tableColumn.identifier.rawValue != "__rowNumber__", let modelIndex = Self.dataColumnIndex(from: tableColumn.identifier), - modelIndex < rowProvider.columns.count else { return nil } - let columnName = rowProvider.columns[modelIndex] - return rowProvider.columnForeignKeys[columnName] != nil ? displayIndex : nil + modelIndex < tableRows.columns.count else { return nil } + let columnName = tableRows.columns[modelIndex] + return tableRows.columnForeignKeys[columnName] != nil ? displayIndex : nil } ) if !fkColumnIndices.isEmpty { @@ -596,12 +547,10 @@ struct DataGridView: NSViewRepresentable { coordinator.lastReloadVersion = changeManager.reloadVersion - // Scroll to first row when page changes if paginationChanged && tableView.numberOfRows > 0 { tableView.scrollRowToVisible(0) } - // Sync selection let currentSelection = tableView.selectedRowIndexes let targetSelection = IndexSet(selectedRowIndices) if currentSelection != targetSelection { @@ -610,7 +559,6 @@ struct DataGridView: NSViewRepresentable { coordinator.isSyncingSelection = false } - // Handle editingCell if let cell = editingCell { let tableColumn = DataGridView.tableColumnIndex(for: cell.column) if cell.row < tableView.numberOfRows && tableColumn < tableView.numberOfColumns { @@ -631,12 +579,11 @@ struct DataGridView: NSViewRepresentable { // MARK: - Column Visibility - /// Apply hidden column state to the table view - private func applyColumnVisibility(to tableView: NSTableView) { + private func applyColumnVisibility(to tableView: NSTableView, columns: [String]) { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < rowProvider.columns.count else { continue } - let columnName = rowProvider.columns[colIndex] + colIndex < columns.count else { continue } + let columnName = columns[colIndex] let shouldHide = configuration.hiddenColumns.contains(columnName) if column.isHidden != shouldHide { column.isHidden = shouldHide @@ -665,12 +612,10 @@ struct DataGridView: NSViewRepresentable { } private static func applyColumnOrder(_ order: [String], to tableView: NSTableView, columns: [String]) { - // Only apply if saved order is a permutation of current columns guard Set(order) == Set(columns) else { return } let dataColumns = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } - // Build name→column map for O(1) lookup var columnMap: [String: NSTableColumn] = [:] for col in dataColumns { if let idx = dataColumnIndex(from: col.identifier), idx < columns.count { @@ -690,7 +635,6 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - /// Update column header titles to show multi-sort priority indicators (e.g., "name 1▲", "age 2▼") private static func updateSortIndicators(tableView: NSTableView, sortState: SortState, columns: [String]) { for column in tableView.tableColumns { guard let colIndex = dataColumnIndex(from: column.identifier), @@ -704,11 +648,9 @@ struct DataGridView: NSViewRepresentable { let indicator = " \(sortIndex + 1)\(sortCol.direction.indicator)" column.title = "\(baseName)\(indicator)" } else { - // Single sort: NSTableView shows its own indicator, keep base name column.title = baseName } } else { - // Not sorted: restore base name column.title = baseName } } @@ -726,12 +668,11 @@ struct DataGridView: NSViewRepresentable { coordinator.themeObserver = nil } coordinator.tableRowsController.detach() - coordinator.rowProvider = InMemoryRowProvider(rows: [], columns: []) + coordinator.cachedTableRows = TableRows() } func makeCoordinator() -> TableViewCoordinator { TableViewCoordinator( - rowProvider: rowProvider, changeManager: changeManager, isEditable: isEditable, selectedRowIndices: $selectedRowIndices, @@ -755,14 +696,6 @@ private let previewTableRowsForDataGrid = TableRows.from( #Preview { DataGridView( - rowProvider: InMemoryRowProvider( - rows: [ - ["1", "John", "john@example.com"], - ["2", "Jane", nil], - ["3", "Bob", "bob@example.com"], - ], - columns: ["id", "name", "email"] - ), tableRowsProvider: { previewTableRowsForDataGrid }, changeManager: AnyChangeManager(DataChangeManager()), isEditable: true, diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift index 9ad521b8a..edcc86102 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift @@ -16,7 +16,7 @@ extension TableViewCoordinator { guard !grid.isEmpty, grid[0].count > 1 || grid.count > 1 else { return false } let maxRow = min(anchorRow + grid.count, cachedRowCount) - let maxCol = min(anchorColumn + (grid.first?.count ?? 0), rowProvider.columns.count) + let maxCol = min(anchorColumn + (grid.first?.count ?? 0), cachedTableRows.columns.count) guard anchorRow < maxRow, anchorColumn < maxCol else { return false } let undoManager = tableView?.window?.undoManager diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index c1986ccb3..f70f163e6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -15,7 +15,7 @@ extension TableViewCoordinator { guard let sortDescriptor = tableView.sortDescriptors.first, let key = sortDescriptor.key, let columnIndex = DataGridView.dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), - columnIndex >= 0 && columnIndex < rowProvider.columns.count else { + columnIndex >= 0 && columnIndex < cachedTableRows.columns.count else { return } @@ -34,10 +34,11 @@ extension TableViewCoordinator { return column.width } + let tableRows = tableRowsProvider() let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, + for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, columnIndex: dataColumnIndex, - rowProvider: rowProvider + tableRows: tableRows ) hasUserResizedColumns = true return width @@ -64,8 +65,8 @@ extension TableViewCoordinator { // Derive base column name from stable identifier (avoids sort indicator in title) let baseName: String = { if let idx = DataGridView.dataColumnIndex(from: column.identifier), - idx < rowProvider.columns.count { - return rowProvider.columns[idx] + idx < cachedTableRows.columns.count { + return cachedTableRows.columns[idx] } return column.title }() @@ -104,7 +105,7 @@ extension TableViewCoordinator { // "Display As" submenu for value display format overrides if let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) { - let columnType = dataColumnIndex < rowProvider.columnTypes.count ? rowProvider.columnTypes[dataColumnIndex] : nil + let columnType = dataColumnIndex < cachedTableRows.columnTypes.count ? cachedTableRows.columnTypes[dataColumnIndex] : nil let applicableFormats = ValueDisplayFormat.applicableFormats(for: columnType) if applicableFormats.count > 1 { let displaySubmenu = NSMenu() @@ -201,10 +202,11 @@ extension TableViewCoordinator { let column = tableView.tableColumns[columnIndex] guard let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return } + let tableRows = tableRowsProvider() let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, + for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, columnIndex: dataColumnIndex, - rowProvider: rowProvider + tableRows: tableRows ) column.width = width hasUserResizedColumns = true @@ -213,14 +215,15 @@ extension TableViewCoordinator { @objc func sizeAllColumnsToFit(_ sender: NSMenuItem) { guard let tableView else { return } + let tableRows = tableRowsProvider() for column in tableView.tableColumns { guard column.identifier.rawValue != "__rowNumber__", let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { continue } let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, + for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, columnIndex: dataColumnIndex, - rowProvider: rowProvider + tableRows: tableRows ) column.width = width } diff --git a/TablePro/Views/Results/RowProviderCache.swift b/TablePro/Views/Results/RowProviderCache.swift deleted file mode 100644 index f97becb93..000000000 --- a/TablePro/Views/Results/RowProviderCache.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -@MainActor -final class RowProviderCache { - private struct Entry { - let provider: InMemoryRowProvider - let schemaVersion: Int - let metadataVersion: Int - let sortState: SortState - } - - private var entries: [UUID: Entry] = [:] - - func provider( - for tabId: UUID, - schemaVersion: Int, - metadataVersion: Int, - sortState: SortState - ) -> InMemoryRowProvider? { - guard let entry = entries[tabId], - entry.schemaVersion == schemaVersion, - entry.metadataVersion == metadataVersion, - entry.sortState == sortState - else { - return nil - } - return entry.provider - } - - func store( - _ provider: InMemoryRowProvider, - for tabId: UUID, - schemaVersion: Int, - metadataVersion: Int, - sortState: SortState - ) { - entries[tabId] = Entry( - provider: provider, - schemaVersion: schemaVersion, - metadataVersion: metadataVersion, - sortState: sortState - ) - } - - func remove(for tabId: UUID) { - entries.removeValue(forKey: tabId) - } - - func retain(tabIds: Set) { - entries = entries.filter { tabIds.contains($0.key) } - } - - func removeAll() { - entries.removeAll() - } - - var isEmpty: Bool { - entries.isEmpty - } -} diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index bd4c840d7..07af05175 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -235,8 +235,9 @@ struct CreateTableView: View { additionalFields: [.primaryKey] ) + let tableRows = provider.asTableRows() return DataGridView( - rowProvider: provider.asInMemoryProvider(), + tableRowsProvider: { tableRows }, changeManager: wrappedChangeManager, isEditable: true, configuration: DataGridConfiguration( diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index 7692afb89..c351563e5 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -2,8 +2,7 @@ // StructureRowProvider.swift // TablePro // -// Adapts structure entities (columns/indexes/FKs) to InMemoryRowProvider interface -// Converts entity-based data to row-based format for DataGridView +// Adapts structure entities (columns/indexes/FKs) to TableRows for DataGridView // import Foundation @@ -149,7 +148,7 @@ final class StructureRowProvider { return canonicalFieldOrder.filter { fields.contains($0) } } - // MARK: - InMemoryRowProvider-compatible methods + // MARK: - Row Access func row(at index: Int) -> [String?]? { guard index >= 0, index < cachedRows.count else { return nil } @@ -258,13 +257,13 @@ final class StructureRowProvider { } } -// MARK: - Helper to create InMemoryRowProvider +// MARK: - Helper to create TableRows extension StructureRowProvider { - /// Creates an InMemoryRowProvider from structure data - func asInMemoryProvider() -> InMemoryRowProvider { - InMemoryRowProvider( - rows: rows, + /// Creates a TableRows snapshot from structure data + func asTableRows() -> TableRows { + TableRows.from( + queryRows: rows, columns: columns, columnTypes: columnTypes ) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 5bcd35fd4..03e9a0d7b 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -268,8 +268,9 @@ struct TableStructureView: View { let customOptions = provider.customDropdownOptions let allDropdownColumns = provider.dropdownColumns.union(Set(customOptions.keys)) + let tableRows = provider.asTableRows() return DataGridView( - rowProvider: provider.asInMemoryProvider(), + tableRowsProvider: { tableRows }, changeManager: wrappedChangeManager, schemaVersion: displayVersion, isEditable: canEdit, diff --git a/TableProTests/Core/Services/Query/RowDataStoreTests.swift b/TableProTests/Core/Services/Query/RowDataStoreTests.swift deleted file mode 100644 index 3a7883426..000000000 --- a/TableProTests/Core/Services/Query/RowDataStoreTests.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// RowDataStoreTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("RowDataStore") -@MainActor -struct RowDataStoreTests { - - @Test("buffer(for:) creates an empty RowBuffer on first access and returns the same instance after") - func bufferCreatesAndReturnsSameInstance() { - let store = RowDataStore() - let tabId = UUID() - - let first = store.buffer(for: tabId) - #expect(first.rows.isEmpty) - #expect(first.columns.isEmpty) - #expect(first.isEvicted == false) - - let second = store.buffer(for: tabId) - #expect(ObjectIdentifier(first) == ObjectIdentifier(second)) - } - - @Test("setBuffer(_:for:) replaces the buffer for a tab id") - func setBufferReplacesEntry() { - let store = RowDataStore() - let tabId = UUID() - - let original = store.buffer(for: tabId) - let replacement = RowBuffer(rows: [["a"]], columns: ["c"]) - store.setBuffer(replacement, for: tabId) - - let resolved = store.buffer(for: tabId) - #expect(ObjectIdentifier(resolved) == ObjectIdentifier(replacement)) - #expect(ObjectIdentifier(resolved) != ObjectIdentifier(original)) - } - - @Test("existingBuffer(for:) returns nil before storage and the stored buffer afterwards") - func existingBufferReflectsState() { - let store = RowDataStore() - let tabId = UUID() - - #expect(store.existingBuffer(for: tabId) == nil) - - let buffer = RowBuffer(rows: [["x"]], columns: ["c"]) - store.setBuffer(buffer, for: tabId) - - let resolved = store.existingBuffer(for: tabId) - #expect(resolved != nil) - #expect(resolved.map(ObjectIdentifier.init) == ObjectIdentifier(buffer)) - } - - @Test("removeBuffer(for:) deletes the entry") - func removeBufferDeletes() { - let store = RowDataStore() - let tabId = UUID() - - store.setBuffer(RowBuffer(rows: [["x"]], columns: ["c"]), for: tabId) - #expect(store.existingBuffer(for: tabId) != nil) - - store.removeBuffer(for: tabId) - #expect(store.existingBuffer(for: tabId) == nil) - } - - @Test("evict(for:) calls evict on the stored buffer") - func evictMarksBuffer() { - let store = RowDataStore() - let tabId = UUID() - let buffer = RowBuffer(rows: [["a"], ["b"]], columns: ["c"]) - store.setBuffer(buffer, for: tabId) - - #expect(buffer.isEvicted == false) - store.evict(for: tabId) - - #expect(buffer.isEvicted == true) - #expect(buffer.rows.isEmpty) - } - - @Test("evict(for:) is a no-op for unknown tab ids") - func evictUnknownTabIsNoOp() { - let store = RowDataStore() - store.evict(for: UUID()) - } - - @Test("evictAll(except:) evicts every other tab and spares the active one") - func evictAllSparesActive() { - let store = RowDataStore() - let activeId = UUID() - let otherId1 = UUID() - let otherId2 = UUID() - - let activeBuffer = RowBuffer(rows: [["a"]], columns: ["c"]) - let otherBuffer1 = RowBuffer(rows: [["b"]], columns: ["c"]) - let otherBuffer2 = RowBuffer(rows: [["d"]], columns: ["c"]) - - store.setBuffer(activeBuffer, for: activeId) - store.setBuffer(otherBuffer1, for: otherId1) - store.setBuffer(otherBuffer2, for: otherId2) - - store.evictAll(except: activeId) - - #expect(activeBuffer.isEvicted == false) - #expect(activeBuffer.rows.count == 1) - #expect(otherBuffer1.isEvicted == true) - #expect(otherBuffer1.rows.isEmpty) - #expect(otherBuffer2.isEvicted == true) - #expect(otherBuffer2.rows.isEmpty) - } - - @Test("evictAll(except: nil) evicts every loaded tab") - func evictAllNoActiveEvictsAll() { - let store = RowDataStore() - let buffer1 = RowBuffer(rows: [["a"]], columns: ["c"]) - let buffer2 = RowBuffer(rows: [["b"]], columns: ["c"]) - store.setBuffer(buffer1, for: UUID()) - store.setBuffer(buffer2, for: UUID()) - - store.evictAll(except: nil) - - #expect(buffer1.isEvicted == true) - #expect(buffer2.isEvicted == true) - } - - @Test("evictAll(except:) skips empty buffers") - func evictAllSkipsEmpty() { - let store = RowDataStore() - let emptyBuffer = RowBuffer() - store.setBuffer(emptyBuffer, for: UUID()) - - store.evictAll(except: nil) - #expect(emptyBuffer.isEvicted == false) - } - - @Test("tearDown() clears the store") - func tearDownClearsAll() { - let store = RowDataStore() - let tabId1 = UUID() - let tabId2 = UUID() - store.setBuffer(RowBuffer(rows: [["a"]], columns: ["c"]), for: tabId1) - store.setBuffer(RowBuffer(rows: [["b"]], columns: ["c"]), for: tabId2) - - store.tearDown() - - #expect(store.existingBuffer(for: tabId1) == nil) - #expect(store.existingBuffer(for: tabId2) == nil) - } -} diff --git a/TableProTests/Core/Services/Query/TableRowsStoreTests.swift b/TableProTests/Core/Services/Query/TableRowsStoreTests.swift index 2cced79cf..c2be639d4 100644 --- a/TableProTests/Core/Services/Query/TableRowsStoreTests.swift +++ b/TableProTests/Core/Services/Query/TableRowsStoreTests.swift @@ -190,6 +190,27 @@ struct TableRowsStoreTests { #expect(resolved?.value(at: 0, column: 0) == "z") } + @Test("closing one tab removes only its TableRows entry, leaving siblings intact") + func closingTabRemovesOnlyThatEntry() { + let store = TableRowsStore() + let tabId1 = UUID() + let tabId2 = UUID() + + store.setTableRows( + TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId1 + ) + store.setTableRows( + TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), + for: tabId2 + ) + + store.removeTableRows(for: tabId1) + + #expect(store.existingTableRows(for: tabId1) == nil) + #expect(store.existingTableRows(for: tabId2)?.rows.count == 1) + } + @Test("tearDown() clears the store") func tearDownClearsAll() { let store = TableRowsStore() diff --git a/TableProTests/Helpers/TestFixtures.swift b/TableProTests/Helpers/TestFixtures.swift index 1601d87e3..0038c99ab 100644 --- a/TableProTests/Helpers/TestFixtures.swift +++ b/TableProTests/Helpers/TestFixtures.swift @@ -209,9 +209,10 @@ enum TestFixtures { } } - static func makeInMemoryRowProvider(rowCount: Int = 3, columns: [String] = ["id", "name", "email"]) -> InMemoryRowProvider { + static func makeTableRows(rowCount: Int = 3, columns: [String] = ["id", "name", "email"]) -> TableRows { let rows = makeRows(count: rowCount, columns: columns) - return InMemoryRowProvider(rows: rows, columns: columns) + let columnTypes = Array(repeating: ColumnType.text(rawType: nil), count: columns.count) + return TableRows.from(queryRows: rows, columns: columns, columnTypes: columnTypes) } static func makeForeignKeyInfo( diff --git a/TableProTests/Models/DisplayValueCacheTests.swift b/TableProTests/Models/DisplayValueCacheTests.swift deleted file mode 100644 index 3cd61ce3d..000000000 --- a/TableProTests/Models/DisplayValueCacheTests.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// DisplayValueCacheTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("Display Value Cache") -@MainActor -struct DisplayValueCacheTests { - private func makeProvider(rows: [[String?]], columns: [String], columnTypes: [ColumnType]? = nil) -> InMemoryRowProvider { - InMemoryRowProvider( - rows: rows, - columns: columns, - columnTypes: columnTypes - ) - } - - @Test("first access computes and returns display value") - func firstAccessComputes() { - let provider = makeProvider( - rows: [["hello", "world"]], - columns: ["a", "b"] - ) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "hello") - } - - @Test("second access returns cached value without recomputation") - func secondAccessCached() { - let provider = makeProvider( - rows: [["value1", "value2"]], - columns: ["a", "b"] - ) - let first = provider.displayValue(atRow: 0, column: 0) - let second = provider.displayValue(atRow: 0, column: 0) - #expect(first == second) - #expect(first == "value1") - } - - @Test("updateValue invalidates cache for that row") - func updateInvalidates() { - let provider = makeProvider( - rows: [["old", "keep"]], - columns: ["a", "b"] - ) - _ = provider.displayValue(atRow: 0, column: 0) - provider.updateValue("new", at: 0, columnIndex: 0) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "new") - } - - @Test("invalidateDisplayCache clears all cached values") - func invalidateAll() { - let provider = makeProvider( - rows: [["a1", "a2"], ["b1", "b2"]], - columns: ["x", "y"] - ) - _ = provider.displayValue(atRow: 0, column: 0) - _ = provider.displayValue(atRow: 1, column: 0) - provider.invalidateDisplayCache() - // After invalidation, re-access should still work (recomputes) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "a1") - } - - @Test("nil raw value returns nil display value") - func nilRawValue() { - let provider = makeProvider( - rows: [[nil, "ok"]], - columns: ["a", "b"] - ) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == nil) - } - - @Test("out-of-bounds row returns nil") - func outOfBoundsRow() { - let provider = makeProvider( - rows: [["a"]], - columns: ["x"] - ) - let result = provider.displayValue(atRow: 5, column: 0) - #expect(result == nil) - } - - @Test("linebreaks in values are sanitized in display cache") - func linebreaksSanitized() { - let provider = makeProvider( - rows: [["line1\nline2"]], - columns: ["a"] - ) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "line1 line2") - } - - @Test("updateRows clears display cache") - func updateRowsClearsCache() { - let provider = makeProvider( - rows: [["old"]], - columns: ["a"] - ) - _ = provider.displayValue(atRow: 0, column: 0) - provider.updateRows([["new"]]) - let result = provider.displayValue(atRow: 0, column: 0) - #expect(result == "new") - } -} diff --git a/TableProTests/Models/RowBufferTests.swift b/TableProTests/Models/RowBufferTests.swift deleted file mode 100644 index 5ad8bb7a0..000000000 --- a/TableProTests/Models/RowBufferTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -import Testing -@testable import TablePro - -@Suite("RowBuffer") -struct RowBufferTests { - // MARK: - Initialization - - @Test("Init with default values creates empty buffer") - func initDefaults() { - let buffer = RowBuffer() - #expect(buffer.rows.isEmpty) - #expect(buffer.columns.isEmpty) - #expect(buffer.columnTypes.isEmpty) - #expect(buffer.isEvicted == false) - } - - @Test("Init with data preserves all fields") - func initWithData() { - let rows = TestFixtures.makeRows(count: 5) - let buffer = RowBuffer( - rows: rows, - columns: ["id", "name", "email"], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "VARCHAR"), .text(rawType: "VARCHAR")] - ) - #expect(buffer.rows.count == 5) - #expect(buffer.columns == ["id", "name", "email"]) - #expect(buffer.columnTypes.count == 3) - #expect(buffer.isEvicted == false) - } - - // MARK: - Eviction - - @Test("evict() clears rows and sets isEvicted") - func evictClearsRows() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 10), columns: ["a"]) - buffer.evict() - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - } - - @Test("evict() preserves column metadata") - func evictPreservesMetadata() { - let fk = TestFixtures.makeForeignKeyInfo() - let buffer = RowBuffer( - rows: TestFixtures.makeRows(count: 3), - columns: ["id", "user_id"], - columnTypes: [.integer(rawType: "INT"), .integer(rawType: "INT")], - columnDefaults: ["id": nil], - columnForeignKeys: ["user_id": fk], - columnEnumValues: ["status": ["a", "b"]], - columnNullable: ["id": false] - ) - buffer.evict() - #expect(buffer.columns == ["id", "user_id"]) - #expect(buffer.columnTypes.count == 2) - #expect(buffer.columnForeignKeys["user_id"]?.name == "fk_user") - #expect(buffer.columnEnumValues["status"] == ["a", "b"]) - #expect(buffer.columnNullable["id"] == false) - } - - @Test("Double evict is no-op") - func doubleEvictNoOp() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - buffer.evict() - buffer.evict() - #expect(buffer.isEvicted == true) - #expect(buffer.rows.isEmpty) - } - - // MARK: - Restore - - @Test("restore() repopulates rows and clears isEvicted") - func restoreRepopulates() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - buffer.evict() - #expect(buffer.isEvicted == true) - - let newRows = TestFixtures.makeRows(count: 5) - buffer.restore(rows: newRows) - #expect(buffer.rows.count == 5) - #expect(buffer.isEvicted == false) - } - - @Test("restore() with empty rows clears eviction flag") - func restoreEmptyRows() { - let buffer = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - buffer.evict() - buffer.restore(rows: []) - #expect(buffer.isEvicted == false) - #expect(buffer.rows.isEmpty) - } - - // MARK: - Copy - - @Test("copy() creates independent buffer") - func copyCreatesIndependent() { - let original = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a", "b"]) - let copied = original.copy() - copied.rows.removeAll() - #expect(original.rows.count == 3) - #expect(copied.rows.isEmpty) - } - - @Test("copy() preserves eviction state as false") - func copyPreservesNonEvictedState() { - let original = RowBuffer(rows: TestFixtures.makeRows(count: 3), columns: ["a"]) - let copied = original.copy() - #expect(copied.isEvicted == false) - } -} diff --git a/TableProTests/Models/RowProviderTests.swift b/TableProTests/Models/RowProviderTests.swift deleted file mode 100644 index be48cb62e..000000000 --- a/TableProTests/Models/RowProviderTests.swift +++ /dev/null @@ -1,499 +0,0 @@ -// -// RowProviderTests.swift -// TableProTests -// -// Tests for TableRowData and InMemoryRowProvider -// - -import Foundation -import Testing -@testable import TablePro - -// MARK: - TableRowData Tests - -@Suite("TableRowData") -struct TableRowDataTests { - @Test("Stores index and values") - func storesIndexAndValues() { - let row = TableRowData(index: 5, values: ["a", "b", "c"]) - #expect(row.index == 5) - #expect(row.values == ["a", "b", "c"]) - } - - @Test("value(at:) returns value at valid index") - func valueAtValid() { - let row = TableRowData(index: 0, values: ["hello", "world"]) - #expect(row.value(at: 0) == "hello") - #expect(row.value(at: 1) == "world") - } - - @Test("value(at:) returns value at last index") - func valueAtLast() { - let row = TableRowData(index: 0, values: ["a", "b", "c"]) - #expect(row.value(at: 2) == "c") - } - - @Test("value(at:) returns nil for out-of-bounds index") - func valueAtOutOfBounds() { - let row = TableRowData(index: 0, values: ["a"]) - #expect(row.value(at: 1) == nil) - #expect(row.value(at: 100) == nil) - } - - @Test("value(at:) returns nil for nil entry") - func valueAtNilEntry() { - let row = TableRowData(index: 0, values: [nil, "b"]) - #expect(row.value(at: 0) == nil) - } - - @Test("setValue at valid index updates value") - func setValueValid() { - let row = TableRowData(index: 0, values: ["old", "keep"]) - row.setValue("new", at: 0) - #expect(row.value(at: 0) == "new") - #expect(row.value(at: 1) == "keep") - } - - @Test("setValue to nil clears value") - func setValueNil() { - let row = TableRowData(index: 0, values: ["hello"]) - row.setValue(nil, at: 0) - #expect(row.value(at: 0) == nil) - } - - @Test("setValue out-of-bounds is no-op") - func setValueOutOfBounds() { - let row = TableRowData(index: 0, values: ["a"]) - row.setValue("b", at: 5) - #expect(row.values == ["a"]) - } - - @Test("Empty values array") - func emptyValues() { - let row = TableRowData(index: 0, values: []) - #expect(row.values.isEmpty) - #expect(row.value(at: 0) == nil) - } - - @Test("Index is immutable after setValue") - func indexImmutable() { - let row = TableRowData(index: 42, values: ["x"]) - row.setValue("y", at: 0) - #expect(row.index == 42) - } - - @Test("Values array is mutable") - func valuesMutable() { - let row = TableRowData(index: 0, values: ["a", "b"]) - row.values[0] = "z" - #expect(row.values[0] == "z") - } - - @Test("Reference semantics - two refs see same mutation") - func referenceSemantics() { - let row = TableRowData(index: 0, values: ["a"]) - let ref = row - ref.setValue("b", at: 0) - #expect(row.value(at: 0) == "b") - } -} - -// MARK: - InMemoryRowProvider Tests - -@Suite("InMemoryRowProvider") -struct InMemoryRowProviderTests { - // MARK: - Init - - @Test("Init stores rows and columns") - func initStoresRowsAndColumns() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.totalRowCount == 3) - #expect(provider.columns == ["id", "name", "email"]) - } - - @Test("Init with empty rows") - func initEmptyRows() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - #expect(provider.totalRowCount == 0) - #expect(provider.columns == ["a"]) - } - - @Test("Init with column defaults") - func initColumnDefaults() { - let provider = InMemoryRowProvider( - rows: [], columns: ["id", "status"], - columnDefaults: ["status": "active"] - ) - #expect(provider.columnDefaults["status"] as? String == "active") - } - - @Test("Init with explicit column types") - func initExplicitTypes() { - let types: [ColumnType] = [.integer(rawType: "INT"), .text(rawType: "VARCHAR")] - let provider = InMemoryRowProvider(rows: [], columns: ["id", "name"], columnTypes: types) - #expect(provider.columnTypes == types) - } - - @Test("Init with nil types defaults to text") - func initNilTypesDefault() { - let provider = InMemoryRowProvider(rows: [], columns: ["a", "b"]) - #expect(provider.columnTypes.count == 2) - #expect(provider.columnTypes[0] == .text(rawType: nil)) - #expect(provider.columnTypes[1] == .text(rawType: nil)) - } - - // MARK: - Metadata - - @Test("Foreign key access") - func foreignKeyAccess() { - let fk = TestFixtures.makeForeignKeyInfo() - let provider = InMemoryRowProvider(rows: [], columns: ["user_id"], columnForeignKeys: ["user_id": fk]) - #expect(provider.columnForeignKeys["user_id"]?.name == "fk_user") - } - - @Test("Enum values access") - func enumValuesAccess() { - let provider = InMemoryRowProvider( - rows: [], columns: ["status"], - columnEnumValues: ["status": ["active", "inactive"]] - ) - #expect(provider.columnEnumValues["status"] == ["active", "inactive"]) - } - - @Test("Nullable info access") - func nullableInfoAccess() { - let provider = InMemoryRowProvider(rows: [], columns: ["name"], columnNullable: ["name": true]) - #expect(provider.columnNullable["name"] == true) - } - - @Test("Empty metadata defaults") - func emptyMetadataDefaults() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - #expect(provider.columnForeignKeys.isEmpty) - #expect(provider.columnEnumValues.isEmpty) - #expect(provider.columnNullable.isEmpty) - #expect(provider.columnDefaults.isEmpty) - } - - @Test("totalRowCount matches source rows") - func totalRowCountMatches() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 7) - #expect(provider.totalRowCount == 7) - } - - // MARK: - row(at:) - - @Test("row(at:) returns data for valid index") - func rowAtValid() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let row = provider.row(at: 0) - #expect(row != nil) - #expect(row?.index == 0) - #expect(row?.value(at: 0) == "id_0") - } - - @Test("row(at:) returns data for last index") - func rowAtLast() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let row = provider.row(at: 4) - #expect(row != nil) - #expect(row?.index == 4) - } - - @Test("row(at:) returns nil for negative index") - func rowAtNegative() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.row(at: -1) == nil) - } - - @Test("row(at:) returns nil for out-of-bounds index") - func rowAtOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.row(at: 3) == nil) - #expect(provider.row(at: 100) == nil) - } - - // MARK: - fetchRows - - @Test("fetchRows returns full range") - func fetchRowsFullRange() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let rows = provider.fetchRows(offset: 0, limit: 5) - #expect(rows.count == 5) - #expect(rows[0].index == 0) - #expect(rows[4].index == 4) - } - - @Test("fetchRows returns partial range") - func fetchRowsPartialRange() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10) - let rows = provider.fetchRows(offset: 2, limit: 3) - #expect(rows.count == 3) - #expect(rows[0].index == 2) - #expect(rows[2].index == 4) - } - - @Test("fetchRows with zero limit returns empty") - func fetchRowsZeroLimit() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let rows = provider.fetchRows(offset: 0, limit: 0) - #expect(rows.isEmpty) - } - - @Test("fetchRows offset beyond count returns empty") - func fetchRowsOffsetBeyond() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let rows = provider.fetchRows(offset: 10, limit: 5) - #expect(rows.isEmpty) - } - - @Test("fetchRows limit exceeds available returns available") - func fetchRowsLimitExceeds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let rows = provider.fetchRows(offset: 0, limit: 100) - #expect(rows.count == 3) - } - - @Test("fetchRows from middle of data") - func fetchRowsFromMiddle() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10) - let rows = provider.fetchRows(offset: 5, limit: 3) - #expect(rows.count == 3) - #expect(rows[0].index == 5) - } - - @Test("fetchRows preserves data order") - func fetchRowsPreservesOrder() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let rows = provider.fetchRows(offset: 0, limit: 5) - for (i, row) in rows.enumerated() { - #expect(row.index == i) - #expect(row.value(at: 0) == "id_\(i)") - } - } - - @Test("fetchRows on empty provider returns empty") - func fetchRowsEmpty() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - let rows = provider.fetchRows(offset: 0, limit: 10) - #expect(rows.isEmpty) - } - - // MARK: - updateValue - - @Test("updateValue changes value") - func updateValueChanges() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue("updated", at: 1, columnIndex: 0) - #expect(provider.value(atRow: 1, column: 0) == "updated") - } - - @Test("updateValue sets value to nil") - func updateValueNil() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue(nil, at: 0, columnIndex: 1) - #expect(provider.value(atRow: 0, column: 1) == nil) - } - - @Test("updateValue out-of-bounds row is no-op") - func updateValueOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue("x", at: 10, columnIndex: 0) - #expect(provider.totalRowCount == 3) - } - - @Test("updateValue reflects in direct access") - func updateValueReflectsInDirectAccess() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: 0, column: 0) == "id_0") - provider.updateValue("changed", at: 0, columnIndex: 0) - #expect(provider.value(atRow: 0, column: 0) == "changed") - } - - // MARK: - appendRow - - @Test("appendRow increases count") - func appendRowCount() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 2) - let _ = provider.appendRow(values: ["new1", "new2", "new3"]) - #expect(provider.totalRowCount == 3) - } - - @Test("appendRow returns correct index") - func appendRowIndex() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - let index = provider.appendRow(values: ["a", "b", "c"]) - #expect(index == 5) - } - - @Test("Appended row is accessible via value(atRow:column:)") - func appendRowAccessible() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 1) - let index = provider.appendRow(values: ["x", "y", "z"]) - #expect(provider.value(atRow: index, column: 0) == "x") - #expect(provider.value(atRow: index, column: 2) == "z") - } - - @Test("Multiple appends work correctly") - func multipleAppends() { - let provider = InMemoryRowProvider(rows: [], columns: ["a"]) - let i1 = provider.appendRow(values: ["first"]) - let i2 = provider.appendRow(values: ["second"]) - let i3 = provider.appendRow(values: ["third"]) - #expect(i1 == 0) - #expect(i2 == 1) - #expect(i3 == 2) - #expect(provider.totalRowCount == 3) - } - - @Test("Append to empty provider") - func appendToEmpty() { - let provider = InMemoryRowProvider(rows: [], columns: ["col"]) - let index = provider.appendRow(values: ["val"]) - #expect(index == 0) - #expect(provider.totalRowCount == 1) - #expect(provider.value(atRow: 0, column: 0) == "val") - } - - // MARK: - removeRow - - @Test("removeRow decreases count") - func removeRowCount() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRow(at: 1) - #expect(provider.totalRowCount == 2) - } - - @Test("removeRow out-of-bounds is no-op") - func removeRowOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRow(at: 10) - #expect(provider.totalRowCount == 3) - } - - @Test("removeRow negative index is no-op") - func removeRowNegative() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRow(at: -1) - #expect(provider.totalRowCount == 3) - } - - // MARK: - removeRows - - @Test("removeRows removes multiple rows") - func removeRowsMultiple() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - provider.removeRows(at: [1, 3]) - #expect(provider.totalRowCount == 3) - } - - @Test("removeRows with empty set is no-op") - func removeRowsEmpty() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRows(at: []) - #expect(provider.totalRowCount == 3) - } - - @Test("removeRows skips invalid indices") - func removeRowsSkipsInvalid() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRows(at: [0, 10, 20]) - #expect(provider.totalRowCount == 2) - } - - @Test("removeRows can remove all") - func removeRowsAll() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.removeRows(at: [0, 1, 2]) - #expect(provider.totalRowCount == 0) - } - - // MARK: - invalidateCache - - @Test("invalidateCache preserves data") - func invalidateCachePreservesData() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.invalidateCache() - #expect(provider.value(atRow: 0, column: 0) == "id_0") - #expect(provider.totalRowCount == 3) - } - - // MARK: - updateRows - - @Test("updateRows replaces all data") - func updateRowsReplaces() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let newRows: [[String?]] = [["new_a", "new_b", "new_c"]] - provider.updateRows(newRows) - #expect(provider.totalRowCount == 1) - #expect(provider.value(atRow: 0, column: 0) == "new_a") - } - - @Test("updateRows with empty array sets count to 0") - func updateRowsEmpty() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 5) - provider.updateRows([]) - #expect(provider.totalRowCount == 0) - } - - // MARK: - Direct Access Methods - - @Test("value(atRow:column:) returns correct value") - func valueAtRowColumn() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: 0, column: 0) == "id_0") - #expect(provider.value(atRow: 1, column: 1) == "name_1") - #expect(provider.value(atRow: 2, column: 2) == "email_2") - } - - @Test("value(atRow:column:) returns nil for out-of-bounds row") - func valueAtRowOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: -1, column: 0) == nil) - #expect(provider.value(atRow: 3, column: 0) == nil) - } - - @Test("value(atRow:column:) returns nil for out-of-bounds column") - func valueAtColumnOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.value(atRow: 0, column: -1) == nil) - #expect(provider.value(atRow: 0, column: 100) == nil) - } - - @Test("rowValues(at:) returns correct array") - func rowValuesAt() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - let values = provider.rowValues(at: 1) - #expect(values == ["id_1", "name_1", "email_1"]) - } - - @Test("rowValues(at:) returns nil for out-of-bounds") - func rowValuesAtOutOfBounds() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - #expect(provider.rowValues(at: -1) == nil) - #expect(provider.rowValues(at: 3) == nil) - } - - @Test("value(atRow:column:) reflects updateValue") - func valueReflectsUpdate() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 3) - provider.updateValue("changed", at: 1, columnIndex: 0) - #expect(provider.value(atRow: 1, column: 0) == "changed") - } - - @Test("rowValues(at:) reflects appendRow") - func rowValuesReflectsAppend() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 2) - let index = provider.appendRow(values: ["a", "b", "c"]) - let values = provider.rowValues(at: index) - #expect(values == ["a", "b", "c"]) - } - - @Test("Large row count direct access works") - func largeRowCountDirectAccess() { - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10000) - #expect(provider.value(atRow: 0, column: 0) == "id_0") - #expect(provider.value(atRow: 9999, column: 0) == "id_9999") - #expect(provider.totalRowCount == 10000) - } -} diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 4da3408fb..d413c9c40 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -38,9 +38,10 @@ struct EvictionTests { guard let index = tabManager.selectedTabIndex else { return } let rows = TestFixtures.makeRows(count: 10) let tabId = tabManager.tabs[index].id - let buffer = coordinator.rowDataStore.buffer(for: tabId) - buffer.rows = rows - buffer.columns = ["id", "name", "email"] + let columns = ["id", "name", "email"] + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) + let tableRows = TableRows.from(queryRows: rows, columns: columns, columnTypes: columnTypes) + coordinator.tableRowsStore.setTableRows(tableRows, for: tabId) tabManager.tabs[index].execution.lastExecutedAt = Date() } @@ -49,15 +50,14 @@ struct EvictionTests { let (coordinator, tabManager) = makeCoordinator() addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") let tabId = tabManager.tabs[0].id - let buffer = coordinator.rowDataStore.buffer(for: tabId) - #expect(buffer.rows.count == 10) - #expect(buffer.isEvicted == false) + #expect(coordinator.tableRowsStore.tableRows(for: tabId).rows.count == 10) + #expect(coordinator.tableRowsStore.isEvicted(tabId) == false) coordinator.evictInactiveRowData() - #expect(buffer.isEvicted == true) - #expect(buffer.rows.isEmpty) + #expect(coordinator.tableRowsStore.isEvicted(tabId) == true) + #expect(coordinator.tableRowsStore.tableRows(for: tabId).rows.isEmpty) } @Test("evictInactiveRowData skips tabs with pending changes") @@ -69,33 +69,9 @@ struct EvictionTests { coordinator.evictInactiveRowData() - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - #expect(buffer.isEvicted == false) - #expect(buffer.rows.count == 10) - } - - @Test("evictInactiveRowData skips already evicted tabs") - func skipsAlreadyEvicted() { - let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - buffer.evict() - #expect(buffer.isEvicted == true) - - coordinator.evictInactiveRowData() - #expect(buffer.isEvicted == true) - } - - @Test("evictInactiveRowData skips tabs with empty results") - func skipsEmptyResults() { - let (coordinator, tabManager) = makeCoordinator() - tabManager.addTableTab(tableName: "empty_table") - - coordinator.evictInactiveRowData() - - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - #expect(buffer.isEvicted == false) + let tabId = tabManager.tabs[0].id + #expect(coordinator.tableRowsStore.isEvicted(tabId) == false) + #expect(coordinator.tableRowsStore.tableRows(for: tabId).rows.count == 10) } @Test("evictInactiveRowData preserves column metadata after eviction") @@ -105,9 +81,10 @@ struct EvictionTests { coordinator.evictInactiveRowData() - let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) - #expect(buffer.columns == ["id", "name", "email"]) - #expect(buffer.isEvicted == true) + let tabId = tabManager.tabs[0].id + let rows = coordinator.tableRowsStore.tableRows(for: tabId) + #expect(rows.columns == ["id", "name", "email"]) + #expect(coordinator.tableRowsStore.isEvicted(tabId) == true) } @Test("evictInactiveRowData with no tabs is no-op") diff --git a/TableProTests/Views/Main/TabEvictionTests.swift b/TableProTests/Views/Main/TabEvictionTests.swift deleted file mode 100644 index 9ddb7fbb3..000000000 --- a/TableProTests/Views/Main/TabEvictionTests.swift +++ /dev/null @@ -1,248 +0,0 @@ -// -// TabEvictionTests.swift -// TableProTests -// -// Tests for tab data eviction logic: RowBuffer eviction/restore behavior -// and the candidate filtering + budget logic used by evictInactiveTabs. -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("Tab Eviction") -@MainActor -struct TabEvictionTests { - - // MARK: - Helpers - - private func makeTestRows(count: Int) -> [[String?]] { - (0.. TestTab { - var tab = QueryTab(id: id, title: "Test", query: "SELECT 1", tabType: tabType) - tab.execution.lastExecutedAt = lastExecutedAt - - let buffer: RowBuffer - if rowCount > 0 { - buffer = RowBuffer( - rows: makeTestRows(count: rowCount), - columns: ["col1"], - columnTypes: [.text(rawType: "VARCHAR")] - ) - } else { - buffer = RowBuffer() - } - store.setBuffer(buffer, for: tab.id) - - if isEvicted { - buffer.evict() - } - - if hasUnsavedChanges { - tab.pendingChanges.deletedRowIndices = [0] - } - - return TestTab(tab: tab, buffer: buffer) - } - - // MARK: - RowBuffer Eviction - - @Test("RowBuffer evict clears rows and sets isEvicted flag") - func rowBufferEvictClearsRows() { - let buffer = RowBuffer( - rows: makeTestRows(count: 5), - columns: ["id", "name"], - columnTypes: [.integer(rawType: "INT"), .text(rawType: "VARCHAR")] - ) - - #expect(buffer.rows.count == 5) - #expect(buffer.isEvicted == false) - - buffer.evict() - - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - #expect(buffer.columns == ["id", "name"]) - #expect(buffer.columnTypes.count == 2) - } - - @Test("RowBuffer evict is idempotent") - func rowBufferEvictIdempotent() { - let buffer = RowBuffer( - rows: makeTestRows(count: 3), - columns: ["col1"], - columnTypes: [.text(rawType: nil)] - ) - - buffer.evict() - buffer.evict() - - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - } - - @Test("RowBuffer restore repopulates rows and clears evicted flag") - func rowBufferRestoreAfterEviction() { - let buffer = RowBuffer( - rows: makeTestRows(count: 5), - columns: ["col1"], - columnTypes: [.text(rawType: nil)] - ) - - buffer.evict() - #expect(buffer.rows.isEmpty) - #expect(buffer.isEvicted == true) - - let newRows = makeTestRows(count: 3) - buffer.restore(rows: newRows) - - #expect(buffer.isEvicted == false) - #expect(buffer.rows.count == 3) - } - - // MARK: - Eviction Candidate Filtering - - @Test("Tabs with pending changes are excluded from eviction candidates") - func tabsWithPendingChangesExcluded() { - let store = RowDataStore() - let entry = makeTestTab( - store: store, - rowCount: 10, - lastExecutedAt: Date(), - hasUnsavedChanges: true - ) - - let isCandidate = !entry.buffer.isEvicted - && !entry.buffer.rows.isEmpty - && entry.tab.execution.lastExecutedAt != nil - && !entry.tab.pendingChanges.hasChanges - - #expect(isCandidate == false) - } - - @Test("Eviction candidate filter excludes active, evicted, empty, and unsaved tabs") - func evictionCandidateFiltering() { - let store = RowDataStore() - let activeId = UUID() - let entryA = makeTestTab(store: store, id: activeId, rowCount: 10, lastExecutedAt: Date()) - let entryB = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date(), isEvicted: true) - let entryC = makeTestTab(store: store, rowCount: 0, lastExecutedAt: Date()) - let entryD = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date(), hasUnsavedChanges: true) - let entryE = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date()) - - let activeTabIds: Set = [activeId] - let allEntries = [entryA, entryB, entryC, entryD, entryE] - - let candidates = allEntries.filter { - !activeTabIds.contains($0.tab.id) - && !$0.buffer.isEvicted - && !$0.buffer.rows.isEmpty - && $0.tab.execution.lastExecutedAt != nil - && !$0.tab.pendingChanges.hasChanges - } - - #expect(candidates.count == 1) - #expect(candidates.first?.tab.id == entryE.tab.id) - } - - // MARK: - Budget-Based Eviction - - @Test("Eviction keeps the 2 most recently executed inactive tabs") - func evictionKeepsTwoMostRecent() { - let store = RowDataStore() - let now = Date() - let entries = (0..<5).map { i in - makeTestTab( - store: store, - rowCount: 10, - lastExecutedAt: now.addingTimeInterval(Double(i) * 60) - ) - } - - let activeTabIds: Set = [] - let candidates = entries.filter { - !activeTabIds.contains($0.tab.id) - && !$0.buffer.isEvicted - && !$0.buffer.rows.isEmpty - && $0.tab.execution.lastExecutedAt != nil - && !$0.tab.pendingChanges.hasChanges - } - - let sorted = candidates.sorted { - ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) - } - - let maxInactiveLoaded = 2 - let toEvict = Array(sorted.dropLast(maxInactiveLoaded)) - - #expect(toEvict.count == 3) - - for entry in toEvict { - entry.buffer.evict() - } - - let evictedIds = Set(toEvict.map(\.tab.id)) - - // The 2 newest (index 3 and 4) should NOT be evicted - #expect(!evictedIds.contains(entries[3].tab.id)) - #expect(!evictedIds.contains(entries[4].tab.id)) - - // The 3 oldest (index 0, 1, 2) should be evicted - #expect(entries[0].buffer.isEvicted == true) - #expect(entries[1].buffer.isEvicted == true) - #expect(entries[2].buffer.isEvicted == true) - #expect(entries[3].buffer.isEvicted == false) - #expect(entries[4].buffer.isEvicted == false) - } - - @Test("No tabs evicted when candidates are within budget") - func noEvictionWithinBudget() { - let store = RowDataStore() - let now = Date() - let entries = (0..<2).map { i in - makeTestTab( - store: store, - rowCount: 10, - lastExecutedAt: now.addingTimeInterval(Double(i) * 60) - ) - } - - let activeTabIds: Set = [] - let candidates = entries.filter { - !activeTabIds.contains($0.tab.id) - && !$0.buffer.isEvicted - && !$0.buffer.rows.isEmpty - && $0.tab.execution.lastExecutedAt != nil - && !$0.tab.pendingChanges.hasChanges - } - - let sorted = candidates.sorted { - ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) - } - - let maxInactiveLoaded = 2 - let shouldEvict = sorted.count > maxInactiveLoaded - - #expect(shouldEvict == false) - - for entry in entries { - #expect(entry.buffer.isEvicted == false) - #expect(entry.buffer.rows.count == 10) - } - } -} diff --git a/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift index 83c525d9d..e405c044f 100644 --- a/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift +++ b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift @@ -2,31 +2,24 @@ // DataGridCellFactoryPerfTests.swift // TableProTests // -// Regression tests for DataGrid performance optimizations: -// - P2-4: VoiceOver caching (verified via build — static cache replaces per-cell system calls) -// - P1-5: Column width optimization -// - P2-7: Change reapplication version tracking -// import Foundation @testable import TablePro import Testing -// MARK: - Column Width Optimization (P1-5) - @Suite("Column Width Optimization") @MainActor struct ColumnWidthOptimizationTests { @Test("Column width is within min/max bounds") func columnWidthWithinBounds() { let factory = DataGridCellFactory() - let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 10) + let tableRows = TestFixtures.makeTableRows(rowCount: 10) - for (index, column) in provider.columns.enumerated() { + for (index, column) in tableRows.columns.enumerated() { let width = factory.calculateOptimalColumnWidth( for: column, columnIndex: index, - rowProvider: provider + tableRows: tableRows ) #expect(width >= 60, "Width should be at least 60 (min)") #expect(width <= 800, "Width should be at most 800 (max)") @@ -36,12 +29,16 @@ struct ColumnWidthOptimizationTests { @Test("Header-only column returns reasonable width") func headerOnlyColumnWidth() { let factory = DataGridCellFactory() - let provider = InMemoryRowProvider(rows: [], columns: ["username"]) + let tableRows = TableRows.from( + queryRows: [], + columns: ["username"], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "username", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width >= 60) #expect(width <= 800) @@ -50,12 +47,16 @@ struct ColumnWidthOptimizationTests { @Test("Empty header with no rows returns minimum width") func emptyHeaderNoRowsReturnsMinWidth() { let factory = DataGridCellFactory() - let provider = InMemoryRowProvider(rows: [], columns: [""]) + let tableRows = TableRows.from( + queryRows: [], + columns: [""], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width >= 60, "Should return at least minimum width") } @@ -65,12 +66,16 @@ struct ColumnWidthOptimizationTests { let factory = DataGridCellFactory() let longValue = String(repeating: "X", count: 5_000) let rows: [[String?]] = [[longValue]] - let provider = InMemoryRowProvider(rows: rows, columns: ["data"]) + let tableRows = TableRows.from( + queryRows: rows, + columns: ["data"], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "data", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width <= 800, "Width should be capped at max (800)") } @@ -80,16 +85,17 @@ struct ColumnWidthOptimizationTests { let factory = DataGridCellFactory() let columnCount = 60 let columns = (0..= 60) #expect(width <= 800) @@ -116,20 +122,22 @@ struct ColumnWidthOptimizationTests { ["hello"], [nil], ] - let provider = InMemoryRowProvider(rows: rows, columns: ["name"]) + let tableRows = TableRows.from( + queryRows: rows, + columns: ["name"], + columnTypes: [.text(rawType: nil)] + ) let width = factory.calculateOptimalColumnWidth( for: "name", columnIndex: 0, - rowProvider: provider + tableRows: tableRows ) #expect(width >= 60) #expect(width <= 800) } } -// MARK: - Change Reapplication Version Tracking (P2-7) - @Suite("Change Reapplication Version Tracking") struct ChangeReapplyVersionTests { @Test("Version tracking skips redundant work") diff --git a/TableProTests/Views/Results/RowProviderCacheTests.swift b/TableProTests/Views/Results/RowProviderCacheTests.swift deleted file mode 100644 index 8f57eb913..000000000 --- a/TableProTests/Views/Results/RowProviderCacheTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// RowProviderCacheTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("RowProviderCache") -@MainActor -struct RowProviderCacheTests { - - private func makeProvider(rows: [[String?]] = [["a"]]) -> InMemoryRowProvider { - InMemoryRowProvider(rows: rows, columns: ["c"]) - } - - private func makeSortState(columnIndex: Int = 0, direction: SortDirection = .ascending) -> SortState { - var state = SortState() - state.columns = [SortColumn(columnIndex: columnIndex, direction: direction)] - return state - } - - @Test("provider(for:) returns nil when the tab id is unknown") - func providerUnknownReturnsNil() { - let cache = RowProviderCache() - let resolved = cache.provider( - for: UUID(), - schemaVersion: 1, - metadataVersion: 1, - sortState: SortState() - ) - #expect(resolved == nil) - } - - @Test("After store(...), the same key returns the stored provider") - func storeRoundTrips() { - let cache = RowProviderCache() - let tabId = UUID() - let provider = makeProvider() - - cache.store(provider, for: tabId, schemaVersion: 2, metadataVersion: 3, sortState: SortState()) - - let resolved = cache.provider(for: tabId, schemaVersion: 2, metadataVersion: 3, sortState: SortState()) - #expect(resolved != nil) - #expect(resolved.map(ObjectIdentifier.init) == ObjectIdentifier(provider)) - } - - @Test("Different schemaVersion invalidates the cache hit") - func schemaVersionMismatchReturnsNil() { - let cache = RowProviderCache() - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - let resolved = cache.provider(for: tabId, schemaVersion: 2, metadataVersion: 1, sortState: SortState()) - #expect(resolved == nil) - } - - @Test("Different metadataVersion invalidates the cache hit") - func metadataVersionMismatchReturnsNil() { - let cache = RowProviderCache() - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 99, sortState: SortState()) - #expect(resolved == nil) - } - - @Test("Different sortState invalidates the cache hit") - func sortStateMismatchReturnsNil() { - let cache = RowProviderCache() - let tabId = UUID() - let storedSort = makeSortState(columnIndex: 0, direction: .ascending) - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: storedSort) - - let differentSort = makeSortState(columnIndex: 1, direction: .descending) - let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: differentSort) - #expect(resolved == nil) - } - - @Test("remove(for:) removes the entry") - func removeRemoves() { - let cache = RowProviderCache() - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - cache.remove(for: tabId) - - let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - #expect(resolved == nil) - #expect(cache.isEmpty) - } - - @Test("retain(tabIds:) keeps only the listed tabs") - func retainKeepsListedOnly() { - let cache = RowProviderCache() - let keepId = UUID() - let dropId1 = UUID() - let dropId2 = UUID() - - cache.store(makeProvider(), for: keepId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - cache.store(makeProvider(), for: dropId1, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - cache.store(makeProvider(), for: dropId2, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - cache.retain(tabIds: [keepId]) - - #expect(cache.provider(for: keepId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) != nil) - #expect(cache.provider(for: dropId1, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) == nil) - #expect(cache.provider(for: dropId2, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) == nil) - } - - @Test("removeAll() clears the cache") - func removeAllClears() { - let cache = RowProviderCache() - cache.store(makeProvider(), for: UUID(), schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - cache.store(makeProvider(), for: UUID(), schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - - cache.removeAll() - - #expect(cache.isEmpty) - } - - @Test("isEmpty reflects state across mutations") - func isEmptyReflectsState() { - let cache = RowProviderCache() - #expect(cache.isEmpty) - - let tabId = UUID() - cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) - #expect(!cache.isEmpty) - - cache.remove(for: tabId) - #expect(cache.isEmpty) - } -} diff --git a/TableProTests/Views/Results/RowProviderSyncTests.swift b/TableProTests/Views/Results/RowProviderSyncTests.swift deleted file mode 100644 index a7b8fe608..000000000 --- a/TableProTests/Views/Results/RowProviderSyncTests.swift +++ /dev/null @@ -1,340 +0,0 @@ -// -// RowProviderSyncTests.swift -// TableProTests -// -// Tests for the regression fix: re-applying pending cell edits from -// DataChangeManager to a fresh (stale/cached) InMemoryRowProvider. -// Simulates the scenario in DataGridView.updateNSView where SwiftUI -// provides a cached rowProvider that doesn't reflect in-flight edits. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("RowProvider Sync After Replacement") -@MainActor -struct RowProviderSyncTests { - // MARK: - Helpers - - private func makeScenario( - rowCount: Int = 3, - columns: [String] = ["id", "name", "email"] - ) -> (manager: DataChangeManager, provider: InMemoryRowProvider) { - let rows = TestFixtures.makeRows(count: rowCount, columns: columns) - let provider = InMemoryRowProvider(rows: rows, columns: columns) - let manager = DataChangeManager() - manager.configureForTable(tableName: "test", columns: columns, primaryKeyColumns: ["id"]) - return (manager, provider) - } - - /// Simulates the re-apply logic from DataGridView.updateNSView - private func reapplyChanges(from manager: DataChangeManager, to provider: InMemoryRowProvider) { - for change in manager.changes { - for cellChange in change.cellChanges { - provider.updateValue( - cellChange.newValue, - at: change.rowIndex, - columnIndex: cellChange.columnIndex - ) - } - } - } - - // MARK: - Tests - - @Test("Single cell edit syncs to new provider") - func singleCellEditSyncsToNewProvider() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - // Edit row 1, col 1: "name_1" → "new" - manager.recordCellChange( - rowIndex: 1, - columnIndex: 1, - columnName: "name", - oldValue: "name_1", - newValue: "new", - originalRow: originalRow - ) - providerA.updateValue("new", at: 1, columnIndex: 1) - - // Simulate SwiftUI providing a stale cached provider - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 1, column: 1) == "new") - } - - @Test("Multiple cell edits on same row sync correctly") - func multipleCellEditsSameRowSync() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "updated_name", - originalRow: originalRow - ) - manager.recordCellChange( - rowIndex: 0, - columnIndex: 2, - columnName: "email", - oldValue: "email_0", - newValue: "updated_email", - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "updated_name") - #expect(providerB.value(atRow: 0, column: 2) == "updated_email") - } - - @Test("Multiple cell edits on different rows sync correctly") - func multipleCellEditsDifferentRowsSync() { - let (manager, providerA) = makeScenario() - let originalRow0 = providerA.rowValues(at: 0)! - let originalRow2 = providerA.rowValues(at: 2)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "new_name_0", - originalRow: originalRow0 - ) - manager.recordCellChange( - rowIndex: 2, - columnIndex: 2, - columnName: "email", - oldValue: "email_2", - newValue: "new_email_2", - originalRow: originalRow2 - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "new_name_0") - #expect(providerB.value(atRow: 2, column: 2) == "new_email_2") - } - - @Test("Edit then undo leaves provider unchanged") - func editThenUndoLeavesProviderUnchanged() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - manager.recordCellChange( - rowIndex: 1, - columnIndex: 1, - columnName: "name", - oldValue: "name_1", - newValue: "edited", - originalRow: originalRow - ) - - _ = manager.undoLastChange() - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - } - - @Test("Edit, undo, redo syncs correctly") - func editUndoRedoSyncsCorrectly() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - manager.recordCellChange( - rowIndex: 1, - columnIndex: 1, - columnName: "name", - oldValue: "name_1", - newValue: "new", - originalRow: originalRow - ) - - _ = manager.undoLastChange() - _ = manager.redoLastChange() - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 1, column: 1) == "new") - } - - @Test("Inserted row cell edit syncs to new provider") - func insertedRowCellEditSyncs() { - let columns = ["id", "name", "email"] - let (manager, providerA) = makeScenario(columns: columns) - - // Insert a new row at index 3 - manager.recordRowInsertion(rowIndex: 3, values: ["", "", ""]) - _ = providerA.appendRow(values: ["", "", ""]) - - // Edit cell on the inserted row - manager.recordCellChange( - rowIndex: 3, - columnIndex: 1, - columnName: "name", - oldValue: "", - newValue: "inserted_val", - originalRow: nil - ) - - // Fresh providerB needs 4 rows to match - var rows = TestFixtures.makeRows(count: 3, columns: columns) - rows.append(["", "", ""]) - let providerB = InMemoryRowProvider(rows: rows, columns: columns) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 3, column: 1) == "inserted_val") - } - - @Test("Deleted row does not affect sync") - func deletedRowDoesNotAffectSync() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 1)! - - manager.recordRowDeletion(rowIndex: 1, originalRow: originalRow) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - // Should not crash; delete changes have no cellChanges - reapplyChanges(from: manager, to: providerB) - - // ProviderB values remain unchanged - #expect(providerB.value(atRow: 0, column: 0) == "id_0") - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - #expect(providerB.value(atRow: 2, column: 2) == "email_2") - } - - @Test("Multiple edits to same cell — last value wins") - func multipleEditsToSameCellLastValueWins() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "b", - originalRow: originalRow - ) - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "b", - newValue: "c", - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "c") - } - - @Test("Reapply is idempotent") - func reapplyIsIdempotent() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: "updated", - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "updated") - - // Apply again — should remain correct, no corruption - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == "updated") - } - - @Test("Null value syncs correctly") - func nullValueSyncsCorrectly() { - let (manager, providerA) = makeScenario() - let originalRow = providerA.rowValues(at: 0)! - - manager.recordCellChange( - rowIndex: 0, - columnIndex: 1, - columnName: "name", - oldValue: "name_0", - newValue: nil, - originalRow: originalRow - ) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - #expect(providerB.value(atRow: 0, column: 1) == nil) - } - - @Test("Reapply with no changes is a no-op") - func reapplyWithNoChangesIsNoOp() { - let (manager, _) = makeScenario() - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - reapplyChanges(from: manager, to: providerB) - - #expect(providerB.value(atRow: 0, column: 0) == "id_0") - #expect(providerB.value(atRow: 0, column: 1) == "name_0") - #expect(providerB.value(atRow: 0, column: 2) == "email_0") - #expect(providerB.value(atRow: 1, column: 0) == "id_1") - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - #expect(providerB.value(atRow: 2, column: 2) == "email_2") - } - - @Test("Batch delete does not crash") - func batchDeleteDoesNotCrash() { - let (manager, providerA) = makeScenario() - let originalRow0 = providerA.rowValues(at: 0)! - let originalRow1 = providerA.rowValues(at: 1)! - - manager.recordBatchRowDeletion(rows: [ - (rowIndex: 0, originalRow: originalRow0), - (rowIndex: 1, originalRow: originalRow1), - ]) - - let rows = TestFixtures.makeRows(count: 3, columns: ["id", "name", "email"]) - let providerB = InMemoryRowProvider(rows: rows, columns: ["id", "name", "email"]) - - // Should not crash; batch delete changes have no cellChanges - reapplyChanges(from: manager, to: providerB) - - // Values remain unchanged - #expect(providerB.value(atRow: 0, column: 0) == "id_0") - #expect(providerB.value(atRow: 1, column: 1) == "name_1") - #expect(providerB.value(atRow: 2, column: 2) == "email_2") - } -} From 566314af0547544d96e1ed5c34460c3917823f30 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:19:26 +0700 Subject: [PATCH 13/31] test(datagrid): update fixtures for RowDeltaApplying.applyDelta and StatusBarSnapshot rename --- .../Views/Main/Child/DataTabGridDelegateTests.swift | 5 +++++ TableProTests/Views/Main/MainStatusBarLayoutTests.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift index a23ed8a05..80cec9159 100644 --- a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift +++ b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift @@ -14,6 +14,7 @@ private final class FakeRowDeltaApplier: RowDeltaApplying { var removedCalls: [IndexSet] = [] var fullReplaceCount: Int = 0 var invalidateCount: Int = 0 + var deltaCalls: [Delta] = [] func applyInsertedRows(_ indices: IndexSet) { insertedCalls.append(indices) @@ -30,6 +31,10 @@ private final class FakeRowDeltaApplier: RowDeltaApplying { func invalidateCachesForUndoRedo() { invalidateCount += 1 } + + func applyDelta(_ delta: Delta) { + deltaCalls.append(delta) + } } @Suite("DataTabGridDelegate row-delta forwarding") diff --git a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift index 42b1fd9c3..7e47c5255 100644 --- a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift @@ -17,7 +17,7 @@ struct MainStatusBarLayoutTests { let filterManager = FilterStateManager() let colVisManager = ColumnVisibilityManager() let view = MainStatusBarView( - snapshot: StatusBarSnapshot(tab: nil, buffer: nil), + snapshot: StatusBarSnapshot(tab: nil, tableRows: nil), filterStateManager: filterManager, columnVisibilityManager: colVisManager, allColumns: [], From bcd85e725f1a38d91c7b6384db5ecefbd7d90f8a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:30:04 +0700 Subject: [PATCH 14/31] fix(favorites): qualify ORDER BY columns in FTS-joined search query --- TablePro/Core/Storage/SQLFavoriteStorage.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index df821ebca..e9dfc00e7 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -504,6 +504,7 @@ internal final class SQLFavoriteStorage { var hasConnectionFilter = false var hasFolderFilter = false + let isJoined: Bool if let searchText = searchText, !searchText.isEmpty { sql = """ SELECT f.id, f.name, f.query, f.keyword, f.folder_id, f.connection_id, f.sort_order, f.created_at, f.updated_at @@ -511,6 +512,7 @@ internal final class SQLFavoriteStorage { INNER JOIN favorites_fts ON f.rowid = favorites_fts.rowid WHERE favorites_fts MATCH ? """ + isJoined = true if connectionIdString != nil { sql += " AND (f.connection_id IS NULL OR f.connection_id = ?)" @@ -526,6 +528,7 @@ internal final class SQLFavoriteStorage { SELECT id, name, query, keyword, folder_id, connection_id, sort_order, created_at, updated_at FROM favorites """ + isJoined = false var whereClauses: [String] = [] @@ -544,7 +547,7 @@ internal final class SQLFavoriteStorage { } } - sql += " ORDER BY sort_order ASC, name ASC;" + sql += isJoined ? " ORDER BY f.sort_order ASC, f.name ASC;" : " ORDER BY sort_order ASC, name ASC;" var statement: OpaquePointer? guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { From 417cee75d2c2172895cb478e7c888cd00e2bd330 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:37:45 +0700 Subject: [PATCH 15/31] fix(datagrid): guard commitCellEdit against re-entry from reload-driven resignFirstResponder --- TablePro/Views/Results/DataGridCoordinator.swift | 1 + .../Views/Results/Extensions/DataGridView+CellCommit.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 71f09071c..1e7a6032d 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -86,6 +86,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var hasUserResizedColumns: Bool = false var isWritingColumnLayout: Bool = false var isEscapeCancelling = false + var isCommittingCellEdit = false var layoutPersistTask: Task? static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 6100c7c95..7860afda3 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -7,6 +7,7 @@ import AppKit extension TableViewCoordinator { func commitCellEdit(row: Int, columnIndex: Int, newValue: String?) { + guard !isCommittingCellEdit else { return } guard let tableView else { return } let tableRows = tableRowsProvider() guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } @@ -15,6 +16,9 @@ extension TableViewCoordinator { let oldValue = displayRowValues.values[columnIndex] guard oldValue != newValue else { return } + isCommittingCellEdit = true + defer { isCommittingCellEdit = false } + let storageRow = tableRowsIndex(forDisplayRow: row) let columnName = tableRows.columns[columnIndex] let originalRow = displayRowValues.values From 0c39e0b097501ddb81558eeda99bd72b3f020962 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:52:16 +0700 Subject: [PATCH 16/31] fix(datagrid): dispatch rowsInserted delta from loadMoreRows and read offset inside mutation --- .../Main/Extensions/MainContentCoordinator+LoadMore.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index e65fbc84a..8c8880d4a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -96,10 +96,11 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - let existingRows = tableRowsStore.tableRows(for: tab.id) - let pageOffset = existingRows.rows.count + var appendDelta: Delta = .none + var pageOffset = 0 tableRowsStore.updateTableRows(for: tab.id) { rows in - _ = rows.appendPage(pagedResult.rows, startingAt: pageOffset) + pageOffset = rows.count + appendDelta = rows.appendPage(pagedResult.rows, startingAt: rows.count) } let newCount = pageOffset + pagedResult.rows.count tab.schemaVersion += 1 @@ -110,6 +111,7 @@ extension MainContentCoordinator { tab.pagination.baseQueryForMore = nil } tabManager.tabs[idx] = tab + dataTabDelegate?.tableViewCoordinator?.applyDelta(appendDelta) toolbarState.setExecuting(false) if capturedGeneration == queryGeneration { currentQueryTask = nil From 7c829c73a85da7b4ad7c083366f7808498ea5d09 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:52:29 +0700 Subject: [PATCH 17/31] fix(datagrid): dispatch fullReplace delta from fetchAllRows --- .../Main/Extensions/MainContentCoordinator+LoadMore.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index 8c8880d4a..a3b2f8005 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -223,13 +223,15 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] + var replaceDelta: Delta = .none tableRowsStore.updateTableRows(for: tab.id) { rows in - _ = rows.replace(rows: result.rows) + replaceDelta = rows.replace(rows: result.rows) } tab.execution.executionTime = result.executionTime tab.schemaVersion += 1 tab.pagination.resetLoadMore() tabManager.tabs[idx] = tab + dataTabDelegate?.tableViewCoordinator?.applyDelta(replaceDelta) toolbarState.setExecuting(false) toolbarState.lastQueryDuration = result.executionTime currentQueryTask = nil From 79f8703410c93cbad2695e1e4e49c26a84adf576 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:52:46 +0700 Subject: [PATCH 18/31] fix(datagrid): dispatch cellChanged delta from updateCellInTab --- .../Extensions/MainContentCoordinator+RowOperations.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 132480693..93fae16a4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -219,9 +219,11 @@ extension MainContentCoordinator { func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { guard let index = tabManager.selectedTabIndex else { return } let tabId = tabManager.tabs[index].id + var delta: Delta = .none tableRowsStore.updateTableRows(for: tabId) { rows in - rows.edit(row: rowIndex, column: columnIndex, value: value) + delta = rows.edit(row: rowIndex, column: columnIndex, value: value) } tabManager.tabs[index].hasUserInteraction = true + dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) } } From 133bd3d0be33fd62f0f01640a09a9ddbea612a93 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:52:58 +0700 Subject: [PATCH 19/31] fix(datagrid): remove TableRows entry on individual tab close --- TablePro/Views/Main/MainContentCommandActions.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index e9400fb27..70973a7fc 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -380,10 +380,14 @@ final class MainContentCommandActions { } else if coordinator?.tabManager.tabs.isEmpty == true { window.close() } else { - coordinator?.tableRowsStore.evictAll(except: nil) - coordinator?.tabManager.tabs.removeAll() - coordinator?.tabManager.selectedTabId = nil - coordinator?.toolbarState.isTableTab = false + if let coordinator { + for tab in coordinator.tabManager.tabs { + coordinator.tableRowsStore.removeTableRows(for: tab.id) + } + coordinator.tabManager.tabs.removeAll() + coordinator.tabManager.selectedTabId = nil + coordinator.toolbarState.isTableTab = false + } } Self.logger.info("[close] performClose done ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } From 08d977a843ec2c8c53763c93b83967254789ee35 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 23:53:32 +0700 Subject: [PATCH 20/31] refactor(datagrid): rebuildColumnMetadataCache reads live TableRows --- TablePro/Views/Results/DataGridCoordinator.swift | 10 +++++----- TablePro/Views/Results/DataGridView.swift | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 1e7a6032d..5ce6a3266 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -416,13 +416,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData updateCache() } - func rebuildColumnMetadataCache() { + func rebuildColumnMetadataCache(from tableRows: TableRows) { var enumSet = Set() var fkSet = Set() - let columns = cachedTableRows.columns - let types = cachedTableRows.columnTypes - let enumValues = cachedTableRows.columnEnumValues - let fkKeys = cachedTableRows.columnForeignKeys + let columns = tableRows.columns + let types = tableRows.columnTypes + let enumValues = tableRows.columnEnumValues + let fkKeys = tableRows.columnForeignKeys for i in 0.. Date: Tue, 28 Apr 2026 23:55:06 +0700 Subject: [PATCH 21/31] style(datagrid): drop doc comments per no-comments rule --- TablePro/Views/Results/DataGridCoordinator.swift | 16 ---------------- TablePro/Views/Results/DataGridView.swift | 4 ---- 2 files changed, 20 deletions(-) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 5ce6a3266..e398fdfd1 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1,16 +1,8 @@ -// -// DataGridCoordinator.swift -// TablePro -// -// Coordinator handling NSTableView delegate and data source for DataGridView. -// - import AppKit import SwiftUI // MARK: - Coordinator -/// Coordinator handling NSTableView delegate and data source @MainActor final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate @@ -32,14 +24,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var databaseType: DatabaseType? var tableName: String? var primaryKeyColumns: [String] = [] - /// First PK column, for copy-as-SQL and single-column contexts var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? - /// Capture current column widths and order from the live NSTableView - /// and persist directly to ColumnLayoutStorage. Called from dismantleNSView - /// to guarantee layout is saved even when the view is torn down without - /// a SwiftUI render cycle (e.g., closing a tab). func persistColumnLayoutToStorage() { guard tabType == .table else { return } guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return } @@ -171,7 +158,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } - /// Subscribe to coordinator teardown to release NSTableView cell views. func observeTeardown(connectionId: UUID) { teardownObserver = NotificationCenter.default.addObserver( forName: MainContentCoordinator.teardownNotification, @@ -184,8 +170,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } - /// Release all data and cell views from the NSTableView. - /// Called during coordinator teardown to free memory while SwiftUI holds the view. private func releaseData() { overlayEditor?.dismiss(commit: false) cachedTableRows = TableRows() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 173379870..9afb0f689 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -9,13 +9,11 @@ import AppKit import SwiftUI -/// Position of a cell in the grid (row, column) struct CellPosition: Hashable { let row: Int let column: Int } -/// Cached visual state for a row - avoids repeated changeManager lookups struct RowVisualState { let isDeleted: Bool let isInserted: Bool @@ -24,7 +22,6 @@ struct RowVisualState { static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) } -/// Identity snapshot used to skip redundant updateNSView work when nothing has changed struct DataGridIdentity: Equatable { let reloadVersion: Int let schemaVersion: Int @@ -54,7 +51,6 @@ struct DataGridIdentity: Equatable { } } -/// High-performance table view using AppKit NSTableView struct DataGridView: NSViewRepresentable { var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } From 7f67c411e93c208c572b9de2b629ea2c48591f15 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 00:20:21 +0700 Subject: [PATCH 22/31] fix(datagrid): sync ResultSet snapshot with store on every mutation Load More appended rows to the store but ResultSet.tableRows stayed frozen at applyPhase1Result time. The data grid read through the active ResultSet, so newly fetched rows showed empty cells. Route every TableRows mutation through mutateActiveTableRows on MainContentCoordinator. The helper updates the store, then writes the new value back into the active ResultSet. Result-set switches go through switchActiveResultSet so the store tracks the new active snapshot. Read paths simplify to reading the store directly. --- CHANGELOG.md | 1 + .../Main/Child/MainEditorContentView.swift | 20 +++------ .../MainContentCoordinator+Discard.swift | 26 ++++++----- .../MainContentCoordinator+LoadMore.swift | 10 ++--- .../MainContentCoordinator+QueryHelpers.swift | 3 +- ...MainContentCoordinator+RowOperations.swift | 41 ++++++++++------- ...ainContentCoordinator+SidebarActions.swift | 6 ++- ...ContentCoordinator+TableRowsMutation.swift | 44 +++++++++++++++++++ .../Main/MainContentCommandActions.swift | 4 +- 9 files changed, 102 insertions(+), 53 deletions(-) create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b57a5c802..43c76470c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id. +- Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. Fixes empty cells on Load More and stale grid contents after switching between multi-statement result tabs. - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 3769e1a4a..26e776750 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -488,9 +488,7 @@ struct MainEditorContentView: View { activeResultSetId: Binding( get: { tab.display.activeResultSetId }, set: { newId in - if let tabIdx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[tabIdx].display.activeResultSetId = newId - } + coordinator.switchActiveResultSet(to: newId, in: tab.id) } ), onClose: { id in @@ -525,8 +523,9 @@ struct MainEditorContentView: View { resolvedTableRowsForTab(coordinator: coordinator, tabId: tabId) }, tableRowsMutator: { [coordinator] mutate in - coordinator.tableRowsStore.updateTableRows(for: tabId) { rows in + coordinator.mutateActiveTableRows(for: tabId) { rows in mutate(&rows) + return .none } }, changeManager: currentChangeManager, @@ -558,21 +557,12 @@ struct MainEditorContentView: View { } private func resolvedTableRows(for tab: QueryTab) -> TableRows { - if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { - return rs.tableRows - } - return coordinator.tableRowsStore.existingTableRows(for: tab.id) ?? TableRows() + coordinator.tableRowsStore.existingTableRows(for: tab.id) ?? TableRows() } @MainActor private func resolvedTableRowsForTab(coordinator: MainContentCoordinator, tabId: UUID) -> TableRows { - guard let tab = coordinator.tabManager.tabs.first(where: { $0.id == tabId }) else { - return coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() - } - if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty { - return rs.tableRows - } - return coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() + coordinator.tableRowsStore.existingTableRows(for: tabId) ?? TableRows() } private func displayFormats(for tab: QueryTab) -> [ValueDisplayFormat?] { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index e53c4fe9b..bc659cc49 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -78,19 +78,21 @@ extension MainContentCoordinator { tabId: tabId, indices: changeManager.insertedRowIndices ) - tableRowsStore.updateTableRows(for: tabId) { tableRows in - let edits = originalValues.map { (row: $0.0, column: $0.1, value: $0.2) } - if !edits.isEmpty { - let editDelta = tableRows.editMany(edits) - if editDelta != .none { - deltas.append(editDelta) - } + let edits = originalValues.map { (row: $0.0, column: $0.1, value: $0.2) } + if !edits.isEmpty { + let editDelta = mutateActiveTableRows(for: tabId) { rows in + rows.editMany(edits) } - if !insertedIDs.isEmpty { - let removeDelta = tableRows.remove(rowIDs: insertedIDs) - if removeDelta != .none { - deltas.append(removeDelta) - } + if editDelta != .none { + deltas.append(editDelta) + } + } + if !insertedIDs.isEmpty { + let removeDelta = mutateActiveTableRows(for: tabId) { rows in + rows.remove(rowIDs: insertedIDs) + } + if removeDelta != .none { + deltas.append(removeDelta) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index a3b2f8005..2f92cfc48 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -96,11 +96,10 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - var appendDelta: Delta = .none var pageOffset = 0 - tableRowsStore.updateTableRows(for: tab.id) { rows in + let appendDelta = mutateActiveTableRows(for: tab.id) { rows in pageOffset = rows.count - appendDelta = rows.appendPage(pagedResult.rows, startingAt: rows.count) + return rows.appendPage(pagedResult.rows, startingAt: rows.count) } let newCount = pageOffset + pagedResult.rows.count tab.schemaVersion += 1 @@ -223,9 +222,8 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - var replaceDelta: Delta = .none - tableRowsStore.updateTableRows(for: tab.id) { rows in - replaceDelta = rows.replace(rows: result.rows) + let replaceDelta = mutateActiveTableRows(for: tab.id) { rows in + rows.replace(rows: result.rows) } tab.execution.executionTime = result.executionTime tab.schemaVersion += 1 diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index e54e79175..545460f03 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -478,10 +478,11 @@ extension MainContentCoordinator { existing.columnEnumValues[key] != value } if hasNewValues { - tableRowsStore.updateTableRows(for: tabId) { rows in + mutateActiveTableRows(for: tabId) { rows in for (col, vals) in columnEnumValues { rows.columnEnumValues[col] = vals } + return .columnsReplaced } tabManager.tabs[idx].metadataVersion += 1 } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 93fae16a4..b785b5c70 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -14,12 +14,14 @@ extension MainContentCoordinator { let columns = tableRowsStore.tableRows(for: tabId).columns var addResult: RowOperationsManager.AddNewRowResult? - tableRowsStore.updateTableRows(for: tabId) { rows in - addResult = rowOperationsManager.addNewRow( + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.addNewRow( columns: columns, columnDefaults: columnDefaults, tableRows: &rows ) + addResult = result + return result.delta } guard let result = addResult else { return } @@ -45,11 +47,13 @@ extension MainContentCoordinator { physicallyRemovedIndices: [], delta: .none ) - tableRowsStore.updateTableRows(for: tabId) { rows in - deleteResult = rowOperationsManager.deleteSelectedRows( + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.deleteSelectedRows( selectedIndices: indices, tableRows: &rows ) + deleteResult = result + return result.delta } let totalRows = tableRowsStore.tableRows(for: tabId).count @@ -82,12 +86,14 @@ extension MainContentCoordinator { guard index >= 0, index < tableRowsStore.tableRows(for: tabId).count else { return } var dupResult: RowOperationsManager.AddNewRowResult? - tableRowsStore.updateTableRows(for: tabId) { rows in - dupResult = rowOperationsManager.duplicateRow( + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.duplicateRow( sourceRowIndex: index, columns: columns, tableRows: &rows ) + dupResult = result + return result.delta } guard let result = dupResult else { return } @@ -109,12 +115,14 @@ extension MainContentCoordinator { adjustedSelection: selectionState.indices, delta: .none ) - tableRowsStore.updateTableRows(for: tabId) { rows in - undoResult = rowOperationsManager.undoInsertRow( + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.undoInsertRow( at: rowIndex, tableRows: &rows, selectedIndices: selectionState.indices ) + undoResult = result + return result.delta } selectionState.indices = undoResult.adjustedSelection @@ -130,8 +138,10 @@ extension MainContentCoordinator { let tabId = tab.id var application = RowOperationsManager.UndoApplicationResult(adjustedSelection: nil, delta: .none) - tableRowsStore.updateTableRows(for: tabId) { rows in - application = rowOperationsManager.applyUndoResult(result, tableRows: &rows) + mutateActiveTableRows(for: tabId) { rows in + let applied = rowOperationsManager.applyUndoResult(result, tableRows: &rows) + application = applied + return applied.delta } if let adjustedSelection = application.adjustedSelection { @@ -197,12 +207,14 @@ extension MainContentCoordinator { let columns = tableRowsStore.tableRows(for: tabId).columns var pasteResult = RowOperationsManager.PasteRowsResult(pastedRows: [], delta: .none) - tableRowsStore.updateTableRows(for: tabId) { rows in - pasteResult = rowOperationsManager.pasteRowsFromClipboard( + mutateActiveTableRows(for: tabId) { rows in + let result = rowOperationsManager.pasteRowsFromClipboard( columns: columns, primaryKeyColumns: changeManager.primaryKeyColumns, tableRows: &rows ) + pasteResult = result + return result.delta } guard !pasteResult.pastedRows.isEmpty else { return } @@ -219,9 +231,8 @@ extension MainContentCoordinator { func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { guard let index = tabManager.selectedTabIndex else { return } let tabId = tabManager.tabs[index].id - var delta: Delta = .none - tableRowsStore.updateTableRows(for: tabId) { rows in - delta = rows.edit(row: rowIndex, column: columnIndex, value: value) + let delta = mutateActiveTableRows(for: tabId) { rows in + rows.edit(row: rowIndex, column: columnIndex, value: value) } tabManager.tabs[index].hasUserInteraction = true dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index f6c390b25..ae25e6911 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -17,12 +17,14 @@ extension MainContentCoordinator { guard let tabIdx = tabManager.selectedTabIndex else { return } let rs = tabManager.tabs[tabIdx].display.resultSets.first { $0.id == id } guard rs?.isPinned != true else { return } + let tabId = tabManager.tabs[tabIdx].id tabManager.tabs[tabIdx].display.resultSets.removeAll { $0.id == id } if tabManager.tabs[tabIdx].display.activeResultSetId == id { - tabManager.tabs[tabIdx].display.activeResultSetId = tabManager.tabs[tabIdx].display.resultSets.last?.id + let newActiveId = tabManager.tabs[tabIdx].display.resultSets.last?.id + switchActiveResultSet(to: newActiveId, in: tabId) } if tabManager.tabs[tabIdx].display.resultSets.isEmpty { - tableRowsStore.setTableRows(TableRows(), for: tabManager.tabs[tabIdx].id) + tableRowsStore.setTableRows(TableRows(), for: tabId) tabManager.tabs[tabIdx].execution.errorMessage = nil tabManager.tabs[tabIdx].execution.rowsAffected = 0 tabManager.tabs[tabIdx].execution.executionTime = nil diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift new file mode 100644 index 000000000..6fca34bff --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -0,0 +1,44 @@ +// +// MainContentCoordinator+TableRowsMutation.swift +// TablePro +// +// Single mutation surface for the active ResultSet's TableRows. Routes every +// mutation through the store, then syncs the active ResultSet so reads via +// `tab.display.activeResultSet?.tableRows` stay coherent with store state. +// + +import Foundation + +extension MainContentCoordinator { + @discardableResult + func mutateActiveTableRows( + for tabId: UUID, + _ mutate: (inout TableRows) -> Delta + ) -> Delta { + var delta: Delta = .none + tableRowsStore.updateTableRows(for: tabId) { rows in + delta = mutate(&rows) + } + syncActiveResultSet(for: tabId) + return delta + } + + func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID) { + tableRowsStore.setTableRows(tableRows, for: tabId) + syncActiveResultSet(for: tabId) + } + + func switchActiveResultSet(to resultSetId: UUID?, in tabId: UUID) { + guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + tabManager.tabs[tabIdx].display.activeResultSetId = resultSetId + if let rs = tabManager.tabs[tabIdx].display.activeResultSet { + tableRowsStore.setTableRows(rs.tableRows, for: tabId) + } + } + + private func syncActiveResultSet(for tabId: UUID) { + guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }), + let activeRS = tabManager.tabs[tabIdx].display.activeResultSet else { return } + activeRS.tableRows = tableRowsStore.tableRows(for: tabId) + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 70973a7fc..f0b181f38 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -687,7 +687,7 @@ final class MainContentCommandActions { let currentId = tab.display.activeResultSetId ?? tab.display.resultSets.last?.id, let currentIndex = tab.display.resultSets.firstIndex(where: { $0.id == currentId }), currentIndex > 0 else { return } - coordinator.tabManager.tabs[tabIndex].display.activeResultSetId = tab.display.resultSets[currentIndex - 1].id + coordinator.switchActiveResultSet(to: tab.display.resultSets[currentIndex - 1].id, in: tab.id) } func nextResultTab() { @@ -697,7 +697,7 @@ final class MainContentCommandActions { let currentId = tab.display.activeResultSetId ?? tab.display.resultSets.last?.id, let currentIndex = tab.display.resultSets.firstIndex(where: { $0.id == currentId }), currentIndex < tab.display.resultSets.count - 1 else { return } - coordinator.tabManager.tabs[tabIndex].display.activeResultSetId = tab.display.resultSets[currentIndex + 1].id + coordinator.switchActiveResultSet(to: tab.display.resultSets[currentIndex + 1].id, in: tab.id) } func closeResultTab() { From 826a9701157cd4354ec830c4e910f6d70bfd4bc0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 00:21:21 +0700 Subject: [PATCH 23/31] fix(datagrid): unwrap optional result from addNewRow/duplicateRow --- .../Extensions/MainContentCoordinator+RowOperations.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index b785b5c70..3f6ceee5a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -21,7 +21,7 @@ extension MainContentCoordinator { tableRows: &rows ) addResult = result - return result.delta + return result?.delta ?? .none } guard let result = addResult else { return } @@ -93,7 +93,7 @@ extension MainContentCoordinator { tableRows: &rows ) dupResult = result - return result.delta + return result?.delta ?? .none } guard let result = dupResult else { return } From ed451831246ec827143655165e023ad1623dbc8a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 00:30:37 +0700 Subject: [PATCH 24/31] fix(datagrid): clear modified cell highlight on undo, keep FK metadata across reloads Two related visual regressions: 1. Edit cell -> yellow modified background -> undo -> value reverts but highlight persists. applyDataUndo updated pending state but did not bump reloadVersion, so the visual state cache was gated and the stale modifiedColumns set survived. Bumping reloadVersion forces a rebuild on the next render. 2. FK column arrow and dropdown chevron toggle visible/hidden on each reload of a table tab. applyPhase1Result rebuilt TableRows from scratch and only populated columnDefaults / columnForeignKeys / columnNullable / columnEnumValues when a fresh schema fetch ran. When isMetadataCached returned true (because metadata was in the previous TableRows), no fetch ran and the new TableRows wiped the metadata, which then caused the next reload to refetch -- so every other reload had FK info and every other one didn't. Carry the existing metadata over when the schema fetch is skipped. --- CHANGELOG.md | 2 ++ TablePro/Core/ChangeTracking/DataChangeManager.swift | 1 + .../Extensions/MainContentCoordinator+QueryHelpers.swift | 8 ++++++++ 3 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c76470c..60c2a95c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id. - Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. Fixes empty cells on Load More and stale grid contents after switching between multi-statement result tabs. +- Undo of a cell edit clears the modified-cell highlight: `DataChangeManager.applyDataUndo` now bumps `reloadVersion` so the data grid rebuilds its visual state cache. +- Reloading a table tab keeps cached column metadata (defaults, foreign keys, nullability, enum values) when no fresh schema fetch was needed, so the FK arrow and dropdown chevron stay visible across reloads instead of toggling. - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. - AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts - Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index d1ca992bc..c0d12c513 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -237,6 +237,7 @@ final class DataChangeManager: ChangeManaging { } hasChanges = !pending.isEmpty + reloadVersion += 1 if let result = lastUndoResult { onUndoApplied?(result) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 545460f03..cd8402ba5 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -277,6 +277,14 @@ extension MainContentCoordinator { updatedTab.pagination.totalRowCount = approxCount updatedTab.pagination.isApproximateRowCount = true } + } else { + let existing = tableRowsStore.tableRows(for: updatedTab.id) + columnDefaults = existing.columnDefaults + columnForeignKeys = existing.columnForeignKeys + columnNullable = existing.columnNullable + for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil { + columnEnumValues[col] = vals + } } if hasSchema { updatedTab.metadataVersion += 1 From 59acea11332fc766cd907e0b5aa406728e879344 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 00:42:31 +0700 Subject: [PATCH 25/31] perf(datagrid): stop syncing ResultSet snapshot on every mutation Inserting or undoing a row pegged the CPU at 100%. Each mutation went through mutateActiveTableRows, which wrote the live TableRows back into the active ResultSet's @Observable tableRows property. That triggered a full SwiftUI re-render of MainEditorContentView (which reads rs.resultColumns / rs.errorMessage / etc), on top of the existing observation triggers from changeManager.reloadVersion and tabManager.tabs. Fast key-repeat undo or paste cascaded re-renders. Move the per-ResultSet snapshot into a save-on-switch model: mutateActiveTableRows now only writes the store, and switchActiveResultSet saves the outgoing snapshot then loads the incoming one. Edits in a pinned result set still survive switching back, but routine inserts / undos / cell edits no longer cross the @Observable boundary on the ResultSet. Also short-circuit the inserted-rows scan in DataGridCoordinator.rebuildVisualStateCache: when the grid is unsorted, read changeManager.insertedRowIndices directly instead of iterating every row in TableRows. The full scan only runs in the sorted case where display indices differ from storage indices. --- CHANGELOG.md | 2 +- .../ChangeTracking/AnyChangeManager.swift | 2 ++ .../StructureChangeManager.swift | 2 ++ ...ContentCoordinator+TableRowsMutation.swift | 22 ++++++++----------- .../Views/Results/DataGridCoordinator.swift | 8 +++---- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c2a95c5..6214585e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id. -- Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. Fixes empty cells on Load More and stale grid contents after switching between multi-statement result tabs. +- Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. The snapshot now refreshes only when the user switches result sets (saving the outgoing tab, loading the incoming one), so each insert / undo / paste no longer triggers an `@Observable` re-render of the whole editor. Fixes empty cells on Load More and CPU spikes when adding or undoing rows. - Undo of a cell edit clears the modified-cell highlight: `DataChangeManager.applyDataUndo` now bumps `reloadVersion` so the data grid rebuilds its visual state cache. - Reloading a table tab keeps cached column metadata (defaults, foreign keys, nullability, enum values) when no fresh schema fetch was needed, so the FK arrow and dropdown chevron stay visible across reloads instead of toggling. - DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker. diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index cf8e8e2b4..cfa0c1ac3 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -7,6 +7,7 @@ protocol ChangeManaging: AnyObject { var reloadVersion: Int { get } var canRedo: Bool { get } var rowChanges: [RowChange] { get } + var insertedRowIndices: Set { get } func isRowDeleted(_ rowIndex: Int) -> Bool func recordCellChange( rowIndex: Int, @@ -30,6 +31,7 @@ final class AnyChangeManager { var reloadVersion: Int { wrapped.reloadVersion } var canRedo: Bool { wrapped.canRedo } var rowChanges: [RowChange] { wrapped.rowChanges } + var insertedRowIndices: Set { wrapped.insertedRowIndices } func isRowDeleted(_ rowIndex: Int) -> Bool { wrapped.isRowDeleted(rowIndex) diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 3af236ede..ba968bc5e 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -887,6 +887,8 @@ final class StructureChangeManager: ChangeManaging { var rowChanges: [RowChange] { [] } + var insertedRowIndices: Set { [] } + func isRowDeleted(_ rowIndex: Int) -> Bool { false } func recordCellChange( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index 6fca34bff..3c7881ac0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -2,9 +2,10 @@ // MainContentCoordinator+TableRowsMutation.swift // TablePro // -// Single mutation surface for the active ResultSet's TableRows. Routes every -// mutation through the store, then syncs the active ResultSet so reads via -// `tab.display.activeResultSet?.tableRows` stay coherent with store state. +// Single mutation surface for the active ResultSet's TableRows. Mutations +// flow through the store; the per-ResultSet snapshot is only refreshed when +// the user switches result sets (save outgoing, load incoming) so editing +// one tab doesn't trigger an `@Observable` re-render of the whole editor. // import Foundation @@ -19,26 +20,21 @@ extension MainContentCoordinator { tableRowsStore.updateTableRows(for: tabId) { rows in delta = mutate(&rows) } - syncActiveResultSet(for: tabId) return delta } func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID) { tableRowsStore.setTableRows(tableRows, for: tabId) - syncActiveResultSet(for: tabId) } func switchActiveResultSet(to resultSetId: UUID?, in tabId: UUID) { guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + if let outgoing = tabManager.tabs[tabIdx].display.activeResultSet { + outgoing.tableRows = tableRowsStore.tableRows(for: tabId) + } tabManager.tabs[tabIdx].display.activeResultSetId = resultSetId - if let rs = tabManager.tabs[tabIdx].display.activeResultSet { - tableRowsStore.setTableRows(rs.tableRows, for: tabId) + if let incoming = tabManager.tabs[tabIdx].display.activeResultSet { + tableRowsStore.setTableRows(incoming.tableRows, for: tabId) } } - - private func syncActiveResultSet(for tabId: UUID) { - guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }), - let activeRS = tabManager.tabs[tabIdx].display.activeResultSet else { return } - activeRS.tableRows = tableRowsStore.tableRows(for: tabId) - } } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index e398fdfd1..925d6fcef 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -462,16 +462,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData rowVisualStateCache.removeAll(keepingCapacity: true) - let tableRows = tableRowsProvider() - var insertedRowIndices = Set() + var insertedRowIndices: Set if let sorted = sortedIDs { + insertedRowIndices = Set() for (displayIndex, id) in sorted.enumerated() where id.isInserted { insertedRowIndices.insert(displayIndex) } } else { - for (index, row) in tableRows.rows.enumerated() where row.id.isInserted { - insertedRowIndices.insert(index) - } + insertedRowIndices = changeManager.insertedRowIndices } if !changeManager.hasChanges && insertedRowIndices.isEmpty { From d8c143c941f549f7874a549c17bbdaeb67ef82e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 08:26:22 +0700 Subject: [PATCH 26/31] =?UTF-8?q?test(datagrid):=20fix=20EvictionTests=20?= =?UTF-8?q?=E2=80=94=20selected=20tab=20is=20intentionally=20not=20evicted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `evictInactiveRowData` deliberately skips the currently selected tab ("kept in memory so the user sees no refresh flicker"), but the migrated tests added a tab and immediately called eviction — the new tab was the selected tab, so eviction was a no-op and the assertions failed. Fix: add a second tab so the first becomes background, then assert eviction on the background tab. --- TableProTests/Views/Main/EvictionTests.swift | 21 +++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index d413c9c40..8361409bb 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -45,19 +45,21 @@ struct EvictionTests { tabManager.tabs[index].execution.lastExecutedAt = Date() } - @Test("evictInactiveRowData evicts loaded tabs without pending changes") + @Test("evictInactiveRowData evicts background tabs without pending changes") func evictsLoadedTabs() { let (coordinator, tabManager) = makeCoordinator() addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let tabId = tabManager.tabs[0].id + let backgroundTabId = tabManager.tabs[0].id + // Add a second tab so the first becomes background (eviction skips the selected tab) + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - #expect(coordinator.tableRowsStore.tableRows(for: tabId).rows.count == 10) - #expect(coordinator.tableRowsStore.isEvicted(tabId) == false) + #expect(coordinator.tableRowsStore.tableRows(for: backgroundTabId).rows.count == 10) + #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == false) coordinator.evictInactiveRowData() - #expect(coordinator.tableRowsStore.isEvicted(tabId) == true) - #expect(coordinator.tableRowsStore.tableRows(for: tabId).rows.isEmpty) + #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == true) + #expect(coordinator.tableRowsStore.tableRows(for: backgroundTabId).rows.isEmpty) } @Test("evictInactiveRowData skips tabs with pending changes") @@ -78,13 +80,14 @@ struct EvictionTests { func preservesMetadataAfterEviction() { let (coordinator, tabManager) = makeCoordinator() addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") + let backgroundTabId = tabManager.tabs[0].id + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") coordinator.evictInactiveRowData() - let tabId = tabManager.tabs[0].id - let rows = coordinator.tableRowsStore.tableRows(for: tabId) + let rows = coordinator.tableRowsStore.tableRows(for: backgroundTabId) #expect(rows.columns == ["id", "name", "email"]) - #expect(coordinator.tableRowsStore.isEvicted(tabId) == true) + #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == true) } @Test("evictInactiveRowData with no tabs is no-op") From 5b633b354c13d7e262c720fa0102d97f3e5a1f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 08:50:30 +0700 Subject: [PATCH 27/31] perf(datagrid): updateCache reads live tableRowsProvider so post-Delta count is fresh --- TablePro/Views/Results/DataGridCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 925d6fcef..01b37bdb3 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -206,6 +206,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func updateCache() { + cachedTableRows = tableRowsProvider() cachedRowCount = sortedIDs?.count ?? cachedTableRows.count cachedColumnCount = cachedTableRows.columns.count } From 9897050e31c223100f915123c18f6bcdfca2d8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 09:55:50 +0700 Subject: [PATCH 28/31] refactor(datagrid): replace editingCell binding with direct beginEditing call addNewRow / duplicateSelectedRow now call coordinator.beginEditing(displayRow:column:) synchronously after applyDelta. The editingCell SwiftUI binding plumbing across 7 files is removed since no caller sets it to non-nil anymore. Each row-add press fully completes (commit prior edit, mutate model, apply delta, focus new cell) before the next press fires, so rapid Cmd+Shift+N keeps focus on the latest appended row instead of getting trapped in queued Tasks. --- DATAGRID_REFACTOR.md | 282 ++++++++++++++++++ .../Main/Child/DataTabGridDelegate.swift | 10 +- .../Main/Child/MainEditorContentView.swift | 3 - ...MainContentCoordinator+RowOperations.swift | 14 +- .../Extensions/MainContentView+Setup.swift | 3 +- .../Main/MainContentCommandActions.swift | 23 +- TablePro/Views/Main/MainContentView.swift | 4 +- .../Views/Results/DataGridCoordinator.swift | 22 ++ TablePro/Views/Results/DataGridView.swift | 19 -- TablePro/Views/Results/RowDeltaApplying.swift | 2 + .../Views/Structure/CreateTableView.swift | 2 - .../Views/Structure/TableStructureView.swift | 2 - .../Main/Child/DataTabGridDelegateTests.swift | 11 + .../Main/CommandActionsDispatchTests.swift | 4 +- .../Views/Main/SaveCompletionTests.swift | 12 +- 15 files changed, 339 insertions(+), 74 deletions(-) create mode 100644 DATAGRID_REFACTOR.md diff --git a/DATAGRID_REFACTOR.md b/DATAGRID_REFACTOR.md new file mode 100644 index 000000000..eaf783d00 --- /dev/null +++ b/DATAGRID_REFACTOR.md @@ -0,0 +1,282 @@ +# DataGrid Refactor — Handoff Doc + +This doc lets a fresh Claude Code session pick up the data grid refactor without re-deriving context from chat history. Read it top-to-bottom before touching DataGrid code. + +## Goal + +Replace the data grid's tangled state (six parallel collections, three reload paths, type-erased managers, counter-driven SwiftUI bridge) with a clean, native AppKit architecture: value-type model + delta-driven view updates + controller-owned `NSUndoManager`. + +The work is split into 7 + 2 phases. Each phase ships as its own PR and leaves the app fully working between merges. Don't compress phases into one PR — the value of the plan is that it's bisectable. + +--- + +## Phase plan + +| Phase | Title | Status | PR | Risk | Notes | +|---|---|---|---|---|---| +| A | TableSelection value type | **MERGED** | #926 | Low | Self-contained; pattern test for value-type approach. | +| B | PendingChanges value type | **OPEN** | #928 | Med | DataChangeManager 962→190 LOC. Three buglets fixed during review (see "Known gotchas"). | +| C | TableRows value type + Delta | Next | — | High | Replaces RowBuffer + InMemoryRowProvider + RowDataStore. Big one. | +| D | Drop SwiftUI counter bridge | After C | — | Med | Removes `reloadVersion` / `lastIdentity` / `lastReapplyVersion` after C lets us drive reloads from deltas. | +| E | Native NSPopover editor | Independent | — | Low | Replaces `CellOverlayEditor` (custom panel). Can run parallel to C/D. | +| F | Controller-owned NSUndoManager | After C | — | Med | Override `NSResponder.undoManager` on the data grid controller. Drops `undoManagerProvider` closure + `onUndoApplied` callback. | +| G | Cleanup | Last | — | Low | Drop `AnyChangeManager`, `RowDeltaApplying`, consolidate extension files. | +| H | Structure tab: selection | After G | — | Low | Reuse `TableSelection`. | +| I | Structure tab: rows + undo | After H | — | Med | Reuse `TableRows` + controller pattern. | + +**Decisions locked in** (don't relitigate without explicit user approval): +- One PR per phase +- Controller-owned `NSUndoManager` (separate from the SQL editor, NOT unified through window — see "Known gotchas: window.undoManager is chain-walking") +- Plugin breaking change OK; bump `currentPluginKitVersion` in `PluginManager.swift` when needed +- Tests with each phase +- Performance target: NSTableView's built-in virtualization handles 1M+ rows when paired with server-side pagination (industry standard — TablePlus, DataGrip, Sequel Ace all do this). Don't try to keep 1M rows in memory. + +--- + +## Phases A and B — what landed + +### Phase A: `TableSelection` +- File: `TablePro/Views/Results/TableSelection.swift` +- Replaces 4 stored properties on `KeyHandlingTableView` (`focusedRow`, `focusedColumn`, `selectionAnchor`, `selectionPivot`) with one `selection: TableSelection` value type +- Centralized `didSet` calls `selection.reloadIndexes(from: oldValue)` to compute affected cells, replacing two divergent didSet blocks +- Backward-compat accessors keep all call sites unchanged +- Tests: `TableProTests/Views/Results/TableSelectionTests.swift` (17 cases) + +### Phase B: `PendingChanges` +- File: `TablePro/Core/ChangeTracking/PendingChanges.swift` +- Consolidates 6 collections into one value type with mutating methods that own cross-collection invariants: + - `changes`, `changeIndex` (lookup cache), `deletedRowIndices`, `insertedRowIndices`, `modifiedCells`, `insertedRowData`, `changedRowIndices` +- `DataChangeManager` shrunk from ~960 → ~190 LOC; keeps undo/redo registration, plugin SQL generation, `@Observable` integration only +- `applyDataUndo` split into 5 focused per-action helpers +- Renamed `TabPendingChanges` → `TabChangeSnapshot` (it's a serialization DTO, distinct from the live tracker) +- Tests: `TableProTests/Core/ChangeTracking/PendingChangesTests.swift` (~30 cases including regressions) + +--- + +## Known gotchas (don't re-discover these) + +### `window.undoManager` walks the responder chain + +NSWindow's `undoManager` property walks the responder chain. When a text field is being edited, the field editor (NSTextView) is in the chain and may provide its own undoManager (especially if `allowsUndo = true`). Registering data grid undos on `window.undoManager` while a cell is being edited can land them on the wrong manager, where they're lost when editing ends. + +**Current workaround**: `DataChangeManager.undoManagerProvider = { contentWindow?.undoManager }` reads the manager fresh each registration. Works because data grid commits happen from `control(_:textShouldEndEditing:)` which fires after the field editor is leaving the chain. Fragile. + +**Phase F fix**: Override `NSResponder.undoManager` on the data grid controller. Then the responder chain finds the controller's manager directly, regardless of whether a field editor is also in the chain. The SQL editor keeps its own; Cmd+Z does the right thing based on focus. + +### `changedRowIndices` must be populated by every undo path + +The data grid's partial-reload optimization in `DataGridView.reloadAndSyncSelection` reads `consumeChangedRowIndices()`. If empty, it falls through to a `!changeManager.hasChanges` branch that does a **full** `reloadData()` over all rows. + +During Phase B I forgot to insert into `changedRowIndices` from PendingChanges' replay methods. Result: every undo back to a clean state did a 33ms full reload over 1k rows (gets worse with more rows). + +**Fixed**: every replay/undo method in PendingChanges now calls `changedRowIndices.insert(rowIndex)`. Six regression tests in `PendingChangesChangedRowIndicesTests` enforce this. Don't add a new mutator without preserving this invariant. + +### `cellChange.oldValue` must be the original DB value across redo + +The OLD `recordCellChangeForRedo` set the cellChange's `oldValue` from the action's `newValue` parameter (which IS the original DB value in the redo direction). I lost that in `reapplyCellChange` and set `oldValue: nil`. Result: edit→undo→redo→undo failed to collapse the change because `revertUpdateCell` couldn't match `previousValue` against the stale `nil` oldValue. Yellow modified-bg stuck on. + +**Fixed**: `reapplyCellChange` takes an `originalDBValue` parameter; call site passes `action.newValue`. Regression test: `editUndoRedoUndoCollapses` in `DataChangeManagerExtendedTests`. + +### Test fixture must wire up `undoManagerProvider` AND set `groupsByEvent = false` + +`DataChangeManager` defaults to no undo manager (provider is nil) so `canUndo`/`canRedo` return false. Tests that exercise undo must: + +```swift +let manager = DataChangeManager() +let undoManager = UndoManager() +undoManager.groupsByEvent = false // tests don't run a runloop +manager.undoManagerProvider = { undoManager } +``` + +`DataChangeManagerExtendedTests.makeManager` already does this. `DataChangeManagerTests.makeManagerWithUndo` is the equivalent for the smaller test suite. + +### NSTableView reloadData() with cellCalls=0 still costs ~37ms per 1k rows + +When `tableView.reloadData()` is called with no visible cells re-rendered (because they're not yet drawn or layout hasn't run), it still pays internal bookkeeping cost. We confirmed this with the OSLog trace technique below. + +**Implication**: avoid calling `reloadData()` more than once per user action. The data grid's two reload paths (`applyFullReplace` from delegate + SwiftUI's `reloadAndSyncSelection`) used to both fire on undo. Phase 3 (#924) fixed this for undo by replacing `applyFullReplace` with `invalidateCachesForUndoRedo` (cache only, no reload), letting the SwiftUI path do the partial reload. + +### Embedded repos in the workspace + +Your local workspace likely has `Sequel-Ace/`, `beekeeper-studio/`, `licenseapp/`, `pgadmin4/` checked out as reference. Add them to `.git/info/exclude` (NOT `.gitignore`, since they're personal): + +``` +Sequel-Ace/ +beekeeper-studio/ +licenseapp/ +pgadmin4/ +``` + +Otherwise `git status` is noisy and `gh pr create` warns about uncommitted changes. + +--- + +## Trace technique for performance investigations + +When a user reports lag, don't guess — instrument. Pattern that worked twice: + +1. Add `Logger(subsystem: "com.TablePro", category: "UndoTrace")` instances at suspect call sites +2. Wrap key blocks with `let start = Date()` ... `Date().timeIntervalSince(start) * 1000` +3. Have user run: + ```bash + log stream --predicate 'subsystem == "com.TablePro" AND category == "UndoTrace"' --level info + ``` +4. They reproduce the lag, paste logs back +5. You find the bottleneck from real measurements +6. Fix +7. Remove tracing (keep timing pattern in head, not in code) + +This caught: (a) the 37ms double-reload bug in Phase 3 #924, (b) the missing `changedRowIndices` insert in Phase B. + +--- + +## Repo layout (files most likely to touch in remaining phases) + +``` +TablePro/ + Core/ + ChangeTracking/ + DataChangeManager.swift # Phase B/C/F target + PendingChanges.swift # Phase B (done) + AnyChangeManager.swift # Phase G (delete) + ChangeManaging.swift # Phase G (delete) + DataChangeModels.swift # mostly stable + Services/Query/ + RowOperationsManager.swift # Phase C target (applyUndoResult logic) + Models/Query/ + RowBuffer.swift # Phase C target (replace) + RowProvider.swift # Phase C target (InMemoryRowProvider) + QueryTab.swift # uses TabChangeSnapshot + QueryTabState.swift # TabChangeSnapshot lives here + Views/ + Results/ + DataGridView.swift # Phase D target (drop counter bridge) + DataGridCoordinator.swift # Phase D/F target + KeyHandlingTableView.swift # Phase A done; Phase F (drop @objc undo:) + TableSelection.swift # Phase A (done) + RowDeltaApplying.swift # Phase G (delete) + CellOverlayEditor.swift # Phase E (replace with NSPopover) + Extensions/ + DataGridView+*.swift # many; consolidate in Phase G + Main/ + MainContentCoordinator.swift # wires undoManagerProvider/onUndoApplied + Extensions/ + MainContentCoordinator+RowOperations.swift # handleUndoResult + Core/Services/Query/ + RowDataStore.swift # Phase C target (replace) +TableProTests/ + Core/ChangeTracking/ + PendingChangesTests.swift # Phase B + DataChangeManagerTests.swift # has makeManagerWithUndo + DataChangeManagerExtendedTests.swift # has makeManager with provider + Views/Results/ + TableSelectionTests.swift # Phase A +``` + +--- + +## How to continue + +### Picking up where I left off + +1. Pull latest main: `git checkout main && git pull` +2. Check #928 (Phase B) status: `gh pr view 928 --json state,mergeable` + - If still open, give it a `gh pr review --approve` mental pass and merge or wait + - If merged, you're clear to start Phase C +3. Read this file end to end +4. Start the next phase (currently Phase C unless #928 hasn't merged yet) + +### Phase C plan (next up) + +Goal: replace `RowBuffer` + `InMemoryRowProvider` + `RowDataStore` with a single `TableRows` value type that emits `Delta`s on mutation. + +Sketch: +```swift +struct TableRows: Equatable { + private(set) var rows: ContiguousArray + private(set) var sortIndices: [Int]? + var pending: PendingChanges // composes Phase B + + @discardableResult + mutating func edit(row: Int, column: Int, value: String?) -> Delta { ... } + @discardableResult + mutating func insert(row: Int, values: [String?]) -> Delta { ... } + @discardableResult + mutating func delete(row: Int) -> Delta { ... } +} + +enum Delta { + case cellChanged(row: Int, column: Int) + case rowsInserted(IndexSet) + case rowsRemoved(IndexSet) + case fullReplace +} +``` + +Controller applies `Delta` to NSTableView via `insertRows(at:)` / `removeRows(at:)` / `reloadData(forRowIndexes:)`. No more `reloadVersion` int counter. + +Phase C is high-risk. Expect 2 PRs split: +- C.1: introduce `TableRows` alongside existing types, no callers yet +- C.2: migrate callers, remove old types + +### Branch naming + +Match the existing convention: `refactor/datagrid-phase4{letter}-{slug}`. Examples: +- `refactor/datagrid-phase4-selection` (A, merged) +- `refactor/datagrid-phase4b-pending-changes` (B, open) +- `refactor/datagrid-phase4c-table-rows` (C, next) + +### Commit message style + +Project follows Conventional Commits, single line. Examples from this work: +- `refactor(datagrid): extract TableSelection value type from KeyHandlingTableView` +- `fix(datagrid): undo replay paths now mark affected rows as changed` + +Don't use AI-generated filler ("seamless", "robust", "comprehensive"). No em dashes. Plain words. + +### PR description style + +Use the layout: Summary → Why → What → Tests → Files → Test plan. PRs #921, #924, #926, #928 are good templates. + +### Don't push without + +- `xcodebuild ... build` passes +- Tests for the new code pass +- For visible UI changes: tested manually in the running app + +### When the user asks for a "review" + +The `/codex:review` plugin works on the current branch's diff vs main. If the workspace has untracked external repos, Codex flags them every time — they're harmless, just paste the verdict and move on. If you've added them to `.git/info/exclude` (see Known gotchas), Codex's noise goes away. + +--- + +## Open questions / parking lot + +- **Phase E ordering**: said "after data tab is done" but it's independent and low-risk. If the user wants progress in parallel with C/D, it's a free win. +- **`removeChangeAt` O(n) reindex** in `PendingChanges`: deferred. Pre-existing behavior, not a regression. Could become O(1) with sentinel deletion if profiling shows it's hot — Phase G or later. +- **iOS support**: `Libs/ios/` exists. `TableSelection` and `PendingChanges` are platform-agnostic. View layer (KeyHandlingTableView, DataGridView) is AppKit-only by design. No active iOS work. +- **Multi-cursor in editor + data grid undo**: Phase 3 (#924) made data grid undo go through window.undoManager. Editor uses its own undoManager (CodeEditSourceEditor). Both work today via the responder chain. Phase F formalizes this with `NSResponder.undoManager` override on the data grid controller. + +--- + +## Past audit items already shipped + +For reference, the data grid audit produced numbered issues. These are already merged: + +- #6 single-click edit on focused cell (#921) +- #7 native NSButton checkbox for booleans → REVERTED, kept text + dropdown (user preference) +- #8 system focus ring (#921) +- #9 VoiceOver above 5k rows (#921) +- #10 Escape no-op outside edit (#924) +- #11 right-click during edit shows native text menu (#924) +- #12 Ctrl+H/J/K/L override deleted then restored (Ctrl+H = deleteBackward in Emacs bindings; Phase F may revisit) +- #24 NSUndoManager unified through window (#924) — Phase F will refine to controller-owned +- #32 tabular clipboard with TSV + HTML (#924) +- #33 multi-cell paste with undo grouping (#924) +- #35 Shift+Tab backward navigation (#924) +- #36 row drag carries TSV + HTML (#924) +- #50 hardcoded date picker font 13 (#921) +- #51 focus border color (#921) +- #52 a11y row/col index ranges (#921) + +Issues #19, #20, #54 from the audit: not found in current code; skipped. diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 1d53268ce..8ad439a63 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -7,7 +7,6 @@ // import AppKit -import SwiftUI @MainActor final class DataTabGridDelegate: DataGridViewDelegate { @@ -15,7 +14,6 @@ final class DataTabGridDelegate: DataGridViewDelegate { var columnVisibilityManager: ColumnVisibilityManager? var selectionState: GridSelectionState? - var editingCell: Binding? var onCellEdit: ((Int, Int, String?) -> Void)? var onSort: ((Int, Bool, Bool) -> Void)? @@ -59,16 +57,12 @@ final class DataTabGridDelegate: DataGridViewDelegate { } func dataGridPasteRows() { - var cell = editingCell?.wrappedValue - coordinator?.pasteRows(editingCell: &cell) - editingCell?.wrappedValue = cell + coordinator?.pasteRows() } func dataGridDuplicateRow() { guard let selectionState, let firstIndex = selectionState.indices.first else { return } - var cell = editingCell?.wrappedValue - coordinator?.duplicateSelectedRow(index: firstIndex, editingCell: &cell) - editingCell?.wrappedValue = cell + coordinator?.duplicateSelectedRow(index: firstIndex) } func dataGridExportResults() { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 26e776750..01e3739af 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -36,7 +36,6 @@ struct MainEditorContentView: View { // MARK: - Selection State let selectionState: GridSelectionState - @Binding var editingCell: CellPosition? // MARK: - Callbacks @@ -167,7 +166,6 @@ struct MainEditorContentView: View { dataTabDelegate.coordinator = coordinator dataTabDelegate.columnVisibilityManager = columnVisibilityManager dataTabDelegate.selectionState = selectionState - dataTabDelegate.editingCell = $editingCell dataTabDelegate.onCellEdit = onCellEdit dataTabDelegate.onSort = onSort dataTabDelegate.onUndoInsert = onUndoInsert @@ -550,7 +548,6 @@ struct MainEditorContentView: View { set: { selectionState.indices = $0 } ), sortState: sortStateBinding(for: tab), - editingCell: $editingCell, columnLayout: columnLayoutBinding(for: tab) ) .frame(maxHeight: .infinity, alignment: .top) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 3f6ceee5a..aa957e4db 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -1,7 +1,7 @@ import Foundation extension MainContentCoordinator { - func addNewRow(editingCell: inout CellPosition?) { + func addNewRow() { guard !safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } @@ -13,6 +13,8 @@ extension MainContentCoordinator { let columnDefaults = tableRowsStore.tableRows(for: tabId).columnDefaults let columns = tableRowsStore.tableRows(for: tabId).columns + dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + var addResult: RowOperationsManager.AddNewRowResult? mutateActiveTableRows(for: tabId) { rows in let result = rowOperationsManager.addNewRow( @@ -27,10 +29,10 @@ extension MainContentCoordinator { guard let result = addResult else { return } selectionState.indices = [result.rowIndex] - editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true querySortCache.removeValue(forKey: tabId) dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) + dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) } func deleteSelectedRows(indices: Set) { @@ -73,7 +75,7 @@ extension MainContentCoordinator { } } - func duplicateSelectedRow(index: Int, editingCell: inout CellPosition?) { + func duplicateSelectedRow(index: Int) { guard !safeModeLevel.blocksAllWrites, let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } @@ -85,6 +87,8 @@ extension MainContentCoordinator { let columns = tableRowsStore.tableRows(for: tabId).columns guard index >= 0, index < tableRowsStore.tableRows(for: tabId).count else { return } + dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + var dupResult: RowOperationsManager.AddNewRowResult? mutateActiveTableRows(for: tabId) { rows in let result = rowOperationsManager.duplicateRow( @@ -99,10 +103,10 @@ extension MainContentCoordinator { guard let result = dupResult else { return } selectionState.indices = [result.rowIndex] - editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true querySortCache.removeValue(forKey: tabId) dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) + dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) } func undoInsertRow(at rowIndex: Int) { @@ -196,7 +200,7 @@ extension MainContentCoordinator { ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } - func pasteRows(editingCell: inout CellPosition?) { + func pasteRows() { guard !safeModeLevel.blocksAllWrites, let index = tabManager.selectedTabIndex else { return } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 842ecdd9d..cb97526e2 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -283,8 +283,7 @@ extension MainContentView { pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, tableOperationOptions: $tableOperationOptions, - rightPanelState: rightPanelState, - editingCell: $editingCell + rightPanelState: rightPanelState ) actions.window = viewWindow coordinator.commandActions = actions diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index f0b181f38..8b0b19727 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -34,7 +34,6 @@ final class MainContentCommandActions { @ObservationIgnored private let pendingDeletes: Binding> @ObservationIgnored private let tableOperationOptions: Binding<[String: TableOperationOptions]> @ObservationIgnored private let rightPanelState: RightPanelState - @ObservationIgnored private let editingCell: Binding /// The window this instance belongs to — used for key-window guards. @ObservationIgnored weak var window: NSWindow? @@ -55,8 +54,7 @@ final class MainContentCommandActions { pendingTruncates: Binding>, pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, - rightPanelState: RightPanelState, - editingCell: Binding + rightPanelState: RightPanelState ) { self.coordinator = coordinator self.filterStateManager = filterStateManager @@ -67,7 +65,6 @@ final class MainContentCommandActions { self.pendingDeletes = pendingDeletes self.tableOperationOptions = tableOperationOptions self.rightPanelState = rightPanelState - self.editingCell = editingCell setupSaveAction() setupObservers() @@ -170,19 +167,14 @@ final class MainContentCommandActions { } observeKeyWindowOnly(.pasteRows) { [weak self] _ in - guard let self else { return } - var cell = self.editingCell.wrappedValue - self.coordinator?.pasteRows(editingCell: &cell) - self.editingCell.wrappedValue = cell + self?.coordinator?.pasteRows() } } // MARK: - Row Operations (Group A — Called Directly) func addNewRow() { - var cell = editingCell.wrappedValue - coordinator?.addNewRow(editingCell: &cell) - editingCell.wrappedValue = cell + coordinator?.addNewRow() } func deleteSelectedRows(rowIndices: Set? = nil) { @@ -214,10 +206,7 @@ final class MainContentCommandActions { func duplicateRow() { let indices = selectionState.indices guard let selectedIndex = indices.first, indices.count == 1 else { return } - - var cell = editingCell.wrappedValue - coordinator?.duplicateSelectedRow(index: selectedIndex, editingCell: &cell) - editingCell.wrappedValue = cell + coordinator?.duplicateSelectedRow(index: selectedIndex) } func copySelectedRows() { @@ -243,9 +232,7 @@ final class MainContentCommandActions { if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.pasteRows?() } else { - var cell = editingCell.wrappedValue - coordinator?.pasteRows(editingCell: &cell) - editingCell.wrappedValue = cell + coordinator?.pasteRows() } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index fff167dbf..604b588f6 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -47,7 +47,6 @@ struct MainContentView: View { // MARK: - Local State - @State var editingCell: CellPosition? @State var commandActions: MainContentCommandActions? @State var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)? @State var inspectorUpdateTask: Task? @@ -401,7 +400,6 @@ struct MainContentView: View { windowId: windowId, connectionId: connection.id, selectionState: coordinator.selectionState, - editingCell: $editingCell, onCellEdit: { rowIndex, colIndex, value in coordinator.updateCellInTab( rowIndex: rowIndex, columnIndex: colIndex, value: value) @@ -413,7 +411,7 @@ struct MainContentView: View { isMultiSort: isMultiSort) }, onAddRow: { - coordinator.addNewRow(editingCell: &editingCell) + coordinator.addNewRow() }, onUndoInsert: { rowIndex in coordinator.undoInsertRow(at: rowIndex) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 01b37bdb3..de022b85d 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -401,6 +401,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData updateCache() } + func commitActiveCellEdit() { + guard let tableView, let window = tableView.window else { return } + if tableView.editedRow >= 0 { + window.makeFirstResponder(tableView) + return + } + if let firstResponder = window.firstResponder as? NSView, + firstResponder.isDescendant(of: tableView) { + window.makeFirstResponder(tableView) + } + } + + func beginEditing(displayRow: Int, column: Int) { + guard let tableView else { return } + let displayCol = DataGridView.tableColumnIndex(for: column) + guard displayRow >= 0, displayRow < tableView.numberOfRows, + displayCol >= 0, displayCol < tableView.numberOfColumns else { return } + tableView.scrollRowToVisible(displayRow) + tableView.selectRowIndexes(IndexSet(integer: displayRow), byExtendingSelection: false) + tableView.editColumn(displayCol, row: displayRow, with: nil, select: true) + } + func rebuildColumnMetadataCache(from tableRows: TableRows) { var enumSet = Set() var fkSet = Set() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 9afb0f689..ef07c1329 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -66,7 +66,6 @@ struct DataGridView: NSViewRepresentable { @Binding var selectedRowIndices: Set @Binding var sortState: SortState - @Binding var editingCell: CellPosition? @Binding var columnLayout: ColumnLayoutState // MARK: - NSViewRepresentable @@ -554,23 +553,6 @@ struct DataGridView: NSViewRepresentable { tableView.selectRowIndexes(targetSelection, byExtendingSelection: false) coordinator.isSyncingSelection = false } - - if let cell = editingCell { - let tableColumn = DataGridView.tableColumnIndex(for: cell.column) - if cell.row < tableView.numberOfRows && tableColumn < tableView.numberOfColumns { - tableView.scrollRowToVisible(cell.row) - Task { @MainActor [weak tableView] in - guard let tableView else { return } - tableView.selectRowIndexes(IndexSet(integer: cell.row), byExtendingSelection: false) - tableView.editColumn(tableColumn, row: cell.row, with: nil, select: true) - self.editingCell = nil - } - } else { - Task { @MainActor in - self.editingCell = nil - } - } - } } // MARK: - Column Visibility @@ -697,7 +679,6 @@ private let previewTableRowsForDataGrid = TableRows.from( isEditable: true, selectedRowIndices: .constant([]), sortState: .constant(SortState()), - editingCell: .constant(nil), columnLayout: .constant(ColumnLayoutState()) ) .frame(width: 600, height: 400) diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/RowDeltaApplying.swift index bfc773f50..ab5cf15c6 100644 --- a/TablePro/Views/Results/RowDeltaApplying.swift +++ b/TablePro/Views/Results/RowDeltaApplying.swift @@ -7,6 +7,8 @@ protocol RowDeltaApplying: AnyObject { func applyFullReplace() func applyDelta(_ delta: Delta) func invalidateCachesForUndoRedo() + func commitActiveCellEdit() + func beginEditing(displayRow: Int, column: Int) } extension TableViewCoordinator: RowDeltaApplying {} diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 07af05175..eb9179b79 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -47,7 +47,6 @@ struct CreateTableView: View { // DataGridView state @State private var selectedRows: Set = [] @State private var sortState = SortState() - @State private var editingCell: CellPosition? @State private var columnLayout = ColumnLayoutState() init(connection: DatabaseConnection, coordinator: MainContentCoordinator?) { @@ -249,7 +248,6 @@ struct CreateTableView: View { delegate: gridDelegate, selectedRowIndices: $selectedRows, sortState: $sortState, - editingCell: $editingCell, columnLayout: $columnLayout ) } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 03e9a0d7b..41b867dda 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -48,7 +48,6 @@ struct TableStructureView: View { @State var wrappedChangeManager: AnyChangeManager @State var selectedRows: Set = [] @State var sortState = SortState() - @State var editingCell: CellPosition? @State var structureColumnLayout = ColumnLayoutState() @State var actionHandler = StructureViewActionHandler() @State var gridDelegate: StructureGridDelegate @@ -284,7 +283,6 @@ struct TableStructureView: View { delegate: gridDelegate, selectedRowIndices: $selectedRows, sortState: $sortState, - editingCell: $editingCell, columnLayout: $structureColumnLayout ) .safeAreaInset(edge: .top, spacing: 0) { diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift index 80cec9159..be3cfe339 100644 --- a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift +++ b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift @@ -15,6 +15,7 @@ private final class FakeRowDeltaApplier: RowDeltaApplying { var fullReplaceCount: Int = 0 var invalidateCount: Int = 0 var deltaCalls: [Delta] = [] + var commitEditCount: Int = 0 func applyInsertedRows(_ indices: IndexSet) { insertedCalls.append(indices) @@ -35,6 +36,16 @@ private final class FakeRowDeltaApplier: RowDeltaApplying { func applyDelta(_ delta: Delta) { deltaCalls.append(delta) } + + func commitActiveCellEdit() { + commitEditCount += 1 + } + + var beginEditingCalls: [(row: Int, column: Int)] = [] + + func beginEditing(displayRow: Int, column: Int) { + beginEditingCalls.append((row: displayRow, column: column)) + } } @Suite("DataTabGridDelegate row-delta forwarding") diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index 41e048da8..a8bc5c7dd 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -24,7 +24,6 @@ struct CommandActionsDispatchTests { var pendingTruncates: Set = [] var pendingDeletes: Set = [] var tableOperationOptions: [String: TableOperationOptions] = [:] - var editingCell: CellPosition? = nil let rightPanelState = RightPanelState() let actions = MainContentCommandActions( @@ -39,8 +38,7 @@ struct CommandActionsDispatchTests { get: { tableOperationOptions }, set: { tableOperationOptions = $0 } ), - rightPanelState: rightPanelState, - editingCell: Binding(get: { editingCell }, set: { editingCell = $0 }) + rightPanelState: rightPanelState ) return (actions, coordinator) diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index bfba6774c..adce6eaca 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -261,20 +261,16 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var editingCell: CellPosition? - - coordinator.addNewRow(editingCell: &editingCell) + coordinator.addNewRow() #expect(coordinator.selectionState.indices.isEmpty) - #expect(editingCell == nil) coordinator.selectionState.indices = [0] coordinator.deleteSelectedRows(indices: Set([0])) #expect(coordinator.selectionState.indices == [0]) coordinator.selectionState.indices = [] - coordinator.duplicateSelectedRow(index: 0, editingCell: &editingCell) + coordinator.duplicateSelectedRow(index: 0) #expect(coordinator.selectionState.indices.isEmpty) - #expect(editingCell == nil) } @Test("row operations allowed by alert level") @@ -286,9 +282,7 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var editingCell: CellPosition? - - coordinator.addNewRow(editingCell: &editingCell) + coordinator.addNewRow() #expect(tabManager.tabs.first?.execution.errorMessage == nil) } } From 23dccc05cef1086f0f5b69347511892cd82b6c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 09:57:06 +0700 Subject: [PATCH 29/31] chore: drop DATAGRID_REFACTOR.md handoff doc --- DATAGRID_REFACTOR.md | 282 ------------------------------------------- 1 file changed, 282 deletions(-) delete mode 100644 DATAGRID_REFACTOR.md diff --git a/DATAGRID_REFACTOR.md b/DATAGRID_REFACTOR.md deleted file mode 100644 index eaf783d00..000000000 --- a/DATAGRID_REFACTOR.md +++ /dev/null @@ -1,282 +0,0 @@ -# DataGrid Refactor — Handoff Doc - -This doc lets a fresh Claude Code session pick up the data grid refactor without re-deriving context from chat history. Read it top-to-bottom before touching DataGrid code. - -## Goal - -Replace the data grid's tangled state (six parallel collections, three reload paths, type-erased managers, counter-driven SwiftUI bridge) with a clean, native AppKit architecture: value-type model + delta-driven view updates + controller-owned `NSUndoManager`. - -The work is split into 7 + 2 phases. Each phase ships as its own PR and leaves the app fully working between merges. Don't compress phases into one PR — the value of the plan is that it's bisectable. - ---- - -## Phase plan - -| Phase | Title | Status | PR | Risk | Notes | -|---|---|---|---|---|---| -| A | TableSelection value type | **MERGED** | #926 | Low | Self-contained; pattern test for value-type approach. | -| B | PendingChanges value type | **OPEN** | #928 | Med | DataChangeManager 962→190 LOC. Three buglets fixed during review (see "Known gotchas"). | -| C | TableRows value type + Delta | Next | — | High | Replaces RowBuffer + InMemoryRowProvider + RowDataStore. Big one. | -| D | Drop SwiftUI counter bridge | After C | — | Med | Removes `reloadVersion` / `lastIdentity` / `lastReapplyVersion` after C lets us drive reloads from deltas. | -| E | Native NSPopover editor | Independent | — | Low | Replaces `CellOverlayEditor` (custom panel). Can run parallel to C/D. | -| F | Controller-owned NSUndoManager | After C | — | Med | Override `NSResponder.undoManager` on the data grid controller. Drops `undoManagerProvider` closure + `onUndoApplied` callback. | -| G | Cleanup | Last | — | Low | Drop `AnyChangeManager`, `RowDeltaApplying`, consolidate extension files. | -| H | Structure tab: selection | After G | — | Low | Reuse `TableSelection`. | -| I | Structure tab: rows + undo | After H | — | Med | Reuse `TableRows` + controller pattern. | - -**Decisions locked in** (don't relitigate without explicit user approval): -- One PR per phase -- Controller-owned `NSUndoManager` (separate from the SQL editor, NOT unified through window — see "Known gotchas: window.undoManager is chain-walking") -- Plugin breaking change OK; bump `currentPluginKitVersion` in `PluginManager.swift` when needed -- Tests with each phase -- Performance target: NSTableView's built-in virtualization handles 1M+ rows when paired with server-side pagination (industry standard — TablePlus, DataGrip, Sequel Ace all do this). Don't try to keep 1M rows in memory. - ---- - -## Phases A and B — what landed - -### Phase A: `TableSelection` -- File: `TablePro/Views/Results/TableSelection.swift` -- Replaces 4 stored properties on `KeyHandlingTableView` (`focusedRow`, `focusedColumn`, `selectionAnchor`, `selectionPivot`) with one `selection: TableSelection` value type -- Centralized `didSet` calls `selection.reloadIndexes(from: oldValue)` to compute affected cells, replacing two divergent didSet blocks -- Backward-compat accessors keep all call sites unchanged -- Tests: `TableProTests/Views/Results/TableSelectionTests.swift` (17 cases) - -### Phase B: `PendingChanges` -- File: `TablePro/Core/ChangeTracking/PendingChanges.swift` -- Consolidates 6 collections into one value type with mutating methods that own cross-collection invariants: - - `changes`, `changeIndex` (lookup cache), `deletedRowIndices`, `insertedRowIndices`, `modifiedCells`, `insertedRowData`, `changedRowIndices` -- `DataChangeManager` shrunk from ~960 → ~190 LOC; keeps undo/redo registration, plugin SQL generation, `@Observable` integration only -- `applyDataUndo` split into 5 focused per-action helpers -- Renamed `TabPendingChanges` → `TabChangeSnapshot` (it's a serialization DTO, distinct from the live tracker) -- Tests: `TableProTests/Core/ChangeTracking/PendingChangesTests.swift` (~30 cases including regressions) - ---- - -## Known gotchas (don't re-discover these) - -### `window.undoManager` walks the responder chain - -NSWindow's `undoManager` property walks the responder chain. When a text field is being edited, the field editor (NSTextView) is in the chain and may provide its own undoManager (especially if `allowsUndo = true`). Registering data grid undos on `window.undoManager` while a cell is being edited can land them on the wrong manager, where they're lost when editing ends. - -**Current workaround**: `DataChangeManager.undoManagerProvider = { contentWindow?.undoManager }` reads the manager fresh each registration. Works because data grid commits happen from `control(_:textShouldEndEditing:)` which fires after the field editor is leaving the chain. Fragile. - -**Phase F fix**: Override `NSResponder.undoManager` on the data grid controller. Then the responder chain finds the controller's manager directly, regardless of whether a field editor is also in the chain. The SQL editor keeps its own; Cmd+Z does the right thing based on focus. - -### `changedRowIndices` must be populated by every undo path - -The data grid's partial-reload optimization in `DataGridView.reloadAndSyncSelection` reads `consumeChangedRowIndices()`. If empty, it falls through to a `!changeManager.hasChanges` branch that does a **full** `reloadData()` over all rows. - -During Phase B I forgot to insert into `changedRowIndices` from PendingChanges' replay methods. Result: every undo back to a clean state did a 33ms full reload over 1k rows (gets worse with more rows). - -**Fixed**: every replay/undo method in PendingChanges now calls `changedRowIndices.insert(rowIndex)`. Six regression tests in `PendingChangesChangedRowIndicesTests` enforce this. Don't add a new mutator without preserving this invariant. - -### `cellChange.oldValue` must be the original DB value across redo - -The OLD `recordCellChangeForRedo` set the cellChange's `oldValue` from the action's `newValue` parameter (which IS the original DB value in the redo direction). I lost that in `reapplyCellChange` and set `oldValue: nil`. Result: edit→undo→redo→undo failed to collapse the change because `revertUpdateCell` couldn't match `previousValue` against the stale `nil` oldValue. Yellow modified-bg stuck on. - -**Fixed**: `reapplyCellChange` takes an `originalDBValue` parameter; call site passes `action.newValue`. Regression test: `editUndoRedoUndoCollapses` in `DataChangeManagerExtendedTests`. - -### Test fixture must wire up `undoManagerProvider` AND set `groupsByEvent = false` - -`DataChangeManager` defaults to no undo manager (provider is nil) so `canUndo`/`canRedo` return false. Tests that exercise undo must: - -```swift -let manager = DataChangeManager() -let undoManager = UndoManager() -undoManager.groupsByEvent = false // tests don't run a runloop -manager.undoManagerProvider = { undoManager } -``` - -`DataChangeManagerExtendedTests.makeManager` already does this. `DataChangeManagerTests.makeManagerWithUndo` is the equivalent for the smaller test suite. - -### NSTableView reloadData() with cellCalls=0 still costs ~37ms per 1k rows - -When `tableView.reloadData()` is called with no visible cells re-rendered (because they're not yet drawn or layout hasn't run), it still pays internal bookkeeping cost. We confirmed this with the OSLog trace technique below. - -**Implication**: avoid calling `reloadData()` more than once per user action. The data grid's two reload paths (`applyFullReplace` from delegate + SwiftUI's `reloadAndSyncSelection`) used to both fire on undo. Phase 3 (#924) fixed this for undo by replacing `applyFullReplace` with `invalidateCachesForUndoRedo` (cache only, no reload), letting the SwiftUI path do the partial reload. - -### Embedded repos in the workspace - -Your local workspace likely has `Sequel-Ace/`, `beekeeper-studio/`, `licenseapp/`, `pgadmin4/` checked out as reference. Add them to `.git/info/exclude` (NOT `.gitignore`, since they're personal): - -``` -Sequel-Ace/ -beekeeper-studio/ -licenseapp/ -pgadmin4/ -``` - -Otherwise `git status` is noisy and `gh pr create` warns about uncommitted changes. - ---- - -## Trace technique for performance investigations - -When a user reports lag, don't guess — instrument. Pattern that worked twice: - -1. Add `Logger(subsystem: "com.TablePro", category: "UndoTrace")` instances at suspect call sites -2. Wrap key blocks with `let start = Date()` ... `Date().timeIntervalSince(start) * 1000` -3. Have user run: - ```bash - log stream --predicate 'subsystem == "com.TablePro" AND category == "UndoTrace"' --level info - ``` -4. They reproduce the lag, paste logs back -5. You find the bottleneck from real measurements -6. Fix -7. Remove tracing (keep timing pattern in head, not in code) - -This caught: (a) the 37ms double-reload bug in Phase 3 #924, (b) the missing `changedRowIndices` insert in Phase B. - ---- - -## Repo layout (files most likely to touch in remaining phases) - -``` -TablePro/ - Core/ - ChangeTracking/ - DataChangeManager.swift # Phase B/C/F target - PendingChanges.swift # Phase B (done) - AnyChangeManager.swift # Phase G (delete) - ChangeManaging.swift # Phase G (delete) - DataChangeModels.swift # mostly stable - Services/Query/ - RowOperationsManager.swift # Phase C target (applyUndoResult logic) - Models/Query/ - RowBuffer.swift # Phase C target (replace) - RowProvider.swift # Phase C target (InMemoryRowProvider) - QueryTab.swift # uses TabChangeSnapshot - QueryTabState.swift # TabChangeSnapshot lives here - Views/ - Results/ - DataGridView.swift # Phase D target (drop counter bridge) - DataGridCoordinator.swift # Phase D/F target - KeyHandlingTableView.swift # Phase A done; Phase F (drop @objc undo:) - TableSelection.swift # Phase A (done) - RowDeltaApplying.swift # Phase G (delete) - CellOverlayEditor.swift # Phase E (replace with NSPopover) - Extensions/ - DataGridView+*.swift # many; consolidate in Phase G - Main/ - MainContentCoordinator.swift # wires undoManagerProvider/onUndoApplied - Extensions/ - MainContentCoordinator+RowOperations.swift # handleUndoResult - Core/Services/Query/ - RowDataStore.swift # Phase C target (replace) -TableProTests/ - Core/ChangeTracking/ - PendingChangesTests.swift # Phase B - DataChangeManagerTests.swift # has makeManagerWithUndo - DataChangeManagerExtendedTests.swift # has makeManager with provider - Views/Results/ - TableSelectionTests.swift # Phase A -``` - ---- - -## How to continue - -### Picking up where I left off - -1. Pull latest main: `git checkout main && git pull` -2. Check #928 (Phase B) status: `gh pr view 928 --json state,mergeable` - - If still open, give it a `gh pr review --approve` mental pass and merge or wait - - If merged, you're clear to start Phase C -3. Read this file end to end -4. Start the next phase (currently Phase C unless #928 hasn't merged yet) - -### Phase C plan (next up) - -Goal: replace `RowBuffer` + `InMemoryRowProvider` + `RowDataStore` with a single `TableRows` value type that emits `Delta`s on mutation. - -Sketch: -```swift -struct TableRows: Equatable { - private(set) var rows: ContiguousArray - private(set) var sortIndices: [Int]? - var pending: PendingChanges // composes Phase B - - @discardableResult - mutating func edit(row: Int, column: Int, value: String?) -> Delta { ... } - @discardableResult - mutating func insert(row: Int, values: [String?]) -> Delta { ... } - @discardableResult - mutating func delete(row: Int) -> Delta { ... } -} - -enum Delta { - case cellChanged(row: Int, column: Int) - case rowsInserted(IndexSet) - case rowsRemoved(IndexSet) - case fullReplace -} -``` - -Controller applies `Delta` to NSTableView via `insertRows(at:)` / `removeRows(at:)` / `reloadData(forRowIndexes:)`. No more `reloadVersion` int counter. - -Phase C is high-risk. Expect 2 PRs split: -- C.1: introduce `TableRows` alongside existing types, no callers yet -- C.2: migrate callers, remove old types - -### Branch naming - -Match the existing convention: `refactor/datagrid-phase4{letter}-{slug}`. Examples: -- `refactor/datagrid-phase4-selection` (A, merged) -- `refactor/datagrid-phase4b-pending-changes` (B, open) -- `refactor/datagrid-phase4c-table-rows` (C, next) - -### Commit message style - -Project follows Conventional Commits, single line. Examples from this work: -- `refactor(datagrid): extract TableSelection value type from KeyHandlingTableView` -- `fix(datagrid): undo replay paths now mark affected rows as changed` - -Don't use AI-generated filler ("seamless", "robust", "comprehensive"). No em dashes. Plain words. - -### PR description style - -Use the layout: Summary → Why → What → Tests → Files → Test plan. PRs #921, #924, #926, #928 are good templates. - -### Don't push without - -- `xcodebuild ... build` passes -- Tests for the new code pass -- For visible UI changes: tested manually in the running app - -### When the user asks for a "review" - -The `/codex:review` plugin works on the current branch's diff vs main. If the workspace has untracked external repos, Codex flags them every time — they're harmless, just paste the verdict and move on. If you've added them to `.git/info/exclude` (see Known gotchas), Codex's noise goes away. - ---- - -## Open questions / parking lot - -- **Phase E ordering**: said "after data tab is done" but it's independent and low-risk. If the user wants progress in parallel with C/D, it's a free win. -- **`removeChangeAt` O(n) reindex** in `PendingChanges`: deferred. Pre-existing behavior, not a regression. Could become O(1) with sentinel deletion if profiling shows it's hot — Phase G or later. -- **iOS support**: `Libs/ios/` exists. `TableSelection` and `PendingChanges` are platform-agnostic. View layer (KeyHandlingTableView, DataGridView) is AppKit-only by design. No active iOS work. -- **Multi-cursor in editor + data grid undo**: Phase 3 (#924) made data grid undo go through window.undoManager. Editor uses its own undoManager (CodeEditSourceEditor). Both work today via the responder chain. Phase F formalizes this with `NSResponder.undoManager` override on the data grid controller. - ---- - -## Past audit items already shipped - -For reference, the data grid audit produced numbered issues. These are already merged: - -- #6 single-click edit on focused cell (#921) -- #7 native NSButton checkbox for booleans → REVERTED, kept text + dropdown (user preference) -- #8 system focus ring (#921) -- #9 VoiceOver above 5k rows (#921) -- #10 Escape no-op outside edit (#924) -- #11 right-click during edit shows native text menu (#924) -- #12 Ctrl+H/J/K/L override deleted then restored (Ctrl+H = deleteBackward in Emacs bindings; Phase F may revisit) -- #24 NSUndoManager unified through window (#924) — Phase F will refine to controller-owned -- #32 tabular clipboard with TSV + HTML (#924) -- #33 multi-cell paste with undo grouping (#924) -- #35 Shift+Tab backward navigation (#924) -- #36 row drag carries TSV + HTML (#924) -- #50 hardcoded date picker font 13 (#921) -- #51 focus border color (#921) -- #52 a11y row/col index ranges (#921) - -Issues #19, #20, #54 from the audit: not found in current code; skipped. From 0e967c29bc4de843be12e963cbf3651605e57735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 10:13:56 +0700 Subject: [PATCH 30/31] fix(datagrid): clear cell display cache when row data is replaced setActiveTableRows now dispatches applyFullReplace to the active grid after every full row swap, routing through a single mutation surface instead of seven direct tableRowsStore.setTableRows callers across navigation, query helpers, multi-statement, FK navigation, and sidebar actions. Without the dispatch, the coordinator's RowID-keyed displayCache survived table switches and returned the previous table's formatted cell values for matching RowIDs, even though the cell views themselves had rebuilt with the new column set. --- .../Extensions/MainContentCoordinator+FKNavigation.swift | 2 +- .../MainContentCoordinator+MultiStatement.swift | 4 ++-- .../Extensions/MainContentCoordinator+Navigation.swift | 6 +++--- .../Extensions/MainContentCoordinator+QueryHelpers.swift | 2 +- .../MainContentCoordinator+SidebarActions.swift | 2 +- .../MainContentCoordinator+TableRowsMutation.swift | 9 +++++++++ TableProTests/Views/Main/EvictionTests.swift | 2 +- 7 files changed, 18 insertions(+), 9 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 98b334f37..853c6ee44 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -84,7 +84,7 @@ extension MainContentCoordinator { if needsQuery, let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - tableRowsStore.setTableRows(TableRows(), for: tabId) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index 617dc798a..58613a2b7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -233,14 +233,14 @@ extension MainContentCoordinator { tableName = lastSelectSQL.flatMap { extractTableName(from: $0) } } - tableRowsStore.setTableRows( + setActiveTableRows( TableRows.from(queryRows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), for: updatedTab.id ) updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = tableName != nil && updatedTab.tableContext.isEditable } else { - tableRowsStore.setTableRows(TableRows(), for: updatedTab.id) + setActiveTableRows(TableRows(), for: updatedTab.id) if updatedTab.tabType != .table { updatedTab.tableContext.tableName = nil } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 15c81149a..efec19dd3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -125,7 +125,7 @@ extension MainContentCoordinator { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - tableRowsStore.setTableRows(TableRows(), for: tabId) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } @@ -207,7 +207,7 @@ extension MainContentCoordinator { previewCoordinator.filterStateManager.clearAll() if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { let tabId = previewCoordinator.tabManager.tabs[tabIndex].id - previewCoordinator.tableRowsStore.setTableRows(TableRows(), for: tabId) + previewCoordinator.setActiveTableRows(TableRows(), for: tabId) previewCoordinator.tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() previewCoordinator.toolbarState.isTableTab = true @@ -279,7 +279,7 @@ extension MainContentCoordinator { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { let tabId = tabManager.tabs[tabIndex].id - tableRowsStore.setTableRows(TableRows(), for: tabId) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index cd8402ba5..2d52d6238 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -299,7 +299,7 @@ extension MainContentCoordinator { columnEnumValues: columnEnumValues, columnNullable: columnNullable ) - tableRowsStore.setTableRows(newTableRows, for: updatedTab.id) + setActiveTableRows(newTableRows, for: updatedTab.id) let rs = ResultSet(label: tableName ?? "Result", tableRows: newTableRows) rs.executionTime = updatedTab.execution.executionTime diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index ae25e6911..21b9feea8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -24,7 +24,7 @@ extension MainContentCoordinator { switchActiveResultSet(to: newActiveId, in: tabId) } if tabManager.tabs[tabIdx].display.resultSets.isEmpty { - tableRowsStore.setTableRows(TableRows(), for: tabId) + setActiveTableRows(TableRows(), for: tabId) tabManager.tabs[tabIdx].execution.errorMessage = nil tabManager.tabs[tabIdx].execution.rowsAffected = 0 tabManager.tabs[tabIdx].execution.executionTime = nil diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index 3c7881ac0..e252f41c6 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -25,6 +25,7 @@ extension MainContentCoordinator { func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID) { tableRowsStore.setTableRows(tableRows, for: tabId) + notifyFullReplaceIfActive(tabId: tabId) } func switchActiveResultSet(to resultSetId: UUID?, in tabId: UUID) { @@ -35,6 +36,14 @@ extension MainContentCoordinator { tabManager.tabs[tabIdx].display.activeResultSetId = resultSetId if let incoming = tabManager.tabs[tabIdx].display.activeResultSet { tableRowsStore.setTableRows(incoming.tableRows, for: tabId) + notifyFullReplaceIfActive(tabId: tabId) } } + + private func notifyFullReplaceIfActive(tabId: UUID) { + guard let idx = tabManager.selectedTabIndex, + idx < tabManager.tabs.count, + tabManager.tabs[idx].id == tabId else { return } + dataTabDelegate?.tableViewCoordinator?.applyFullReplace() + } } diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 8361409bb..bf6159cd8 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -41,7 +41,7 @@ struct EvictionTests { let columns = ["id", "name", "email"] let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) let tableRows = TableRows.from(queryRows: rows, columns: columns, columnTypes: columnTypes) - coordinator.tableRowsStore.setTableRows(tableRows, for: tabId) + coordinator.setActiveTableRows(tableRows, for: tabId) tabManager.tabs[index].execution.lastExecutedAt = Date() } From 87525fc3bd686f88c24cdff49ca20d81c83767f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 10:27:03 +0700 Subject: [PATCH 31/31] refactor(datagrid): rename RowDeltaApplying to TableViewCoordinating, add dispatch regression tests The protocol now exposes commitActiveCellEdit and beginEditing alongside the row-delta methods, so its name no longer matches its scope. Renaming to TableViewCoordinating tracks the conforming class TableViewCoordinator and the field DataTabGridDelegate.tableViewCoordinator. TableRowsMutationTests verifies that setActiveTableRows dispatches applyFullReplace exactly once for the active tab and skips background tabs, locking in the displayCache invalidation contract that was missing before commit 0e967c29. --- .../Main/Child/DataTabGridDelegate.swift | 2 +- ...ying.swift => TableViewCoordinating.swift} | 4 +- .../Main/Child/DataTabGridDelegateTests.swift | 8 +- .../Views/Main/TableRowsMutationTests.swift | 127 ++++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) rename TablePro/Views/Results/{RowDeltaApplying.swift => TableViewCoordinating.swift} (75%) create mode 100644 TableProTests/Views/Main/TableRowsMutationTests.swift diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 8ad439a63..abdce6059 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -101,7 +101,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { return menu } - weak var tableViewCoordinator: (any RowDeltaApplying)? + weak var tableViewCoordinator: (any TableViewCoordinating)? func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { self.tableViewCoordinator = tableViewCoordinator diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/TableViewCoordinating.swift similarity index 75% rename from TablePro/Views/Results/RowDeltaApplying.swift rename to TablePro/Views/Results/TableViewCoordinating.swift index ab5cf15c6..8c4b55bb3 100644 --- a/TablePro/Views/Results/RowDeltaApplying.swift +++ b/TablePro/Views/Results/TableViewCoordinating.swift @@ -1,7 +1,7 @@ import Foundation @MainActor -protocol RowDeltaApplying: AnyObject { +protocol TableViewCoordinating: AnyObject { func applyInsertedRows(_ indices: IndexSet) func applyRemovedRows(_ indices: IndexSet) func applyFullReplace() @@ -11,4 +11,4 @@ protocol RowDeltaApplying: AnyObject { func beginEditing(displayRow: Int, column: Int) } -extension TableViewCoordinator: RowDeltaApplying {} +extension TableViewCoordinator: TableViewCoordinating {} diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift index be3cfe339..9a8d7d3bb 100644 --- a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift +++ b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift @@ -9,7 +9,7 @@ import Testing @testable import TablePro @MainActor -private final class FakeRowDeltaApplier: RowDeltaApplying { +private final class FakeTableViewCoordinator: TableViewCoordinating { var insertedCalls: [IndexSet] = [] var removedCalls: [IndexSet] = [] var fullReplaceCount: Int = 0 @@ -55,7 +55,7 @@ struct DataTabGridDelegateTests { @Test("dataGridDidInsertRows(at:) forwards the IndexSet to applyInsertedRows") func insertForwardsIndices() { let delegate = DataTabGridDelegate() - let applier = FakeRowDeltaApplier() + let applier = FakeTableViewCoordinator() delegate.tableViewCoordinator = applier let indices = IndexSet([1, 3, 5]) @@ -70,7 +70,7 @@ struct DataTabGridDelegateTests { @Test("dataGridDidRemoveRows(at:) forwards the IndexSet to applyRemovedRows") func removeForwardsIndices() { let delegate = DataTabGridDelegate() - let applier = FakeRowDeltaApplier() + let applier = FakeTableViewCoordinator() delegate.tableViewCoordinator = applier let indices = IndexSet(integersIn: 4..<7) @@ -85,7 +85,7 @@ struct DataTabGridDelegateTests { @Test("dataGridDidReplaceAllRows() forwards to applyFullReplace") func fullReplaceForwards() { let delegate = DataTabGridDelegate() - let applier = FakeRowDeltaApplier() + let applier = FakeTableViewCoordinator() delegate.tableViewCoordinator = applier delegate.dataGridDidReplaceAllRows() diff --git a/TableProTests/Views/Main/TableRowsMutationTests.swift b/TableProTests/Views/Main/TableRowsMutationTests.swift new file mode 100644 index 000000000..3e1476bec --- /dev/null +++ b/TableProTests/Views/Main/TableRowsMutationTests.swift @@ -0,0 +1,127 @@ +// +// TableRowsMutationTests.swift +// TableProTests +// +// Regression tests for the setActiveTableRows / switchActiveResultSet +// dispatch path. Without applyFullReplace, the data grid coordinator's +// RowID-keyed display cache survives table switches and returns stale +// cell values for matching RowIDs across tables. +// + +import AppKit +import Foundation +import Testing +@testable import TablePro + +@MainActor +private final class FakeTableViewCoordinator: TableViewCoordinating { + var fullReplaceCount = 0 + var insertedCount = 0 + var removedCount = 0 + var deltaCount = 0 + var invalidateCount = 0 + var commitEditCount = 0 + var beginEditingCalls: [(row: Int, column: Int)] = [] + + func applyInsertedRows(_ indices: IndexSet) { insertedCount += 1 } + func applyRemovedRows(_ indices: IndexSet) { removedCount += 1 } + func applyFullReplace() { fullReplaceCount += 1 } + func applyDelta(_ delta: Delta) { deltaCount += 1 } + func invalidateCachesForUndoRedo() { invalidateCount += 1 } + func commitActiveCellEdit() { commitEditCount += 1 } + func beginEditing(displayRow: Int, column: Int) { + beginEditingCalls.append((row: displayRow, column: column)) + } +} + +@Suite("setActiveTableRows dispatch") +@MainActor +struct TableRowsMutationTests { + private struct Fixture { + let coordinator: MainContentCoordinator + let tabManager: QueryTabManager + let delegate: DataTabGridDelegate + let fake: FakeTableViewCoordinator + } + + private func makeFixture() -> Fixture { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + filterStateManager: FilterStateManager(), + columnVisibilityManager: ColumnVisibilityManager(), + toolbarState: ConnectionToolbarState() + ) + let delegate = DataTabGridDelegate() + let fake = FakeTableViewCoordinator() + delegate.tableViewCoordinator = fake + coordinator.dataTabDelegate = delegate + return Fixture(coordinator: coordinator, tabManager: tabManager, delegate: delegate, fake: fake) + } + + private func makeTableRows(rowCount: Int) -> TableRows { + let columns = ["id", "name"] + let rows = (0..