From 232f9baacab285a11ce944e8fcab851af4e9b257 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 00:17:07 +0700 Subject: [PATCH 01/10] refactor(perf): decouple persistence and inspector from tabs writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A: persistence - Add tabStructureVersion on QueryTabManager, bumped only on tab add/remove/rename/replaceTabContent and on user query dispatch. - Replace .onChange(of: tabManager.tabs) with .onChange(of: tabManager.tabStructureVersion). Remove handleTabsChange; add handleStructureChange. - Remove per-keystroke saveLastQuery from queryTextBinding; remove now-unused saveLastQuery on TabPersistenceCoordinator. Phase B: inspector - Move updateSidebarEditState inside the 50ms inspector debounce so per-row-click N×M field configuration coalesces. - Drop coordinator.tableMetadata?.tableName from inspectorTrigger; metadata-driven inspector refresh now flows through the existing .task(id: tableName) modifier with an explicit scheduleInspectorUpdate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TabPersistenceCoordinator.swift | 8 - TablePro/Models/Query/QueryTabManager.swift | 22 +- .../Main/Child/MainEditorContentView.swift | 11 +- .../Extensions/MainContentView+Bindings.swift | 13 +- .../MainContentView+EventHandlers.swift | 11 +- .../Extensions/MainContentView+Helpers.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 2 + TablePro/Views/Main/MainContentView.swift | 7 +- docs/development/datagrid-refactor-design.md | 283 ++++++++++++++++++ 9 files changed, 317 insertions(+), 42 deletions(-) create mode 100644 docs/development/datagrid-refactor-design.md diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 73bfebc7c..7402d137d 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -122,14 +122,6 @@ internal final class TabPersistenceCoordinator { // MARK: - Last Query - /// Save the editor's last query text for this connection. - internal func saveLastQuery(_ query: String) { - let connId = connectionId - Task { - await TabDiskActor.shared.saveLastQuery(query, for: connId) - } - } - /// Load the editor's last query text for this connection. internal func loadLastQuery() async -> String? { await TabDiskActor.shared.loadLastQuery(for: connectionId) diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index f694d5885..5a3cabe70 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -11,11 +11,18 @@ import os @MainActor @Observable final class QueryTabManager { var tabs: [QueryTab] = [] { - didSet { _tabIndexMapDirty = true } + didSet { + _tabIndexMapDirty = true + if oldValue.map(\.id) != tabs.map(\.id) { + tabStructureVersion += 1 + } + } } var selectedTabId: UUID? + var tabStructureVersion: Int = 0 + @ObservationIgnored private var _tabIndexMap: [UUID: Int] = [:] @ObservationIgnored private var _tabIndexMapDirty = true @@ -85,6 +92,7 @@ final class QueryTabManager { } tabs.append(newTab) selectedTabId = newTab.id + tabStructureVersion += 1 } func addTableTab( @@ -114,6 +122,7 @@ final class QueryTabManager { newTab.tableContext.databaseName = databaseName tabs.append(newTab) selectedTabId = newTab.id + tabStructureVersion += 1 } func addCreateTableTab(databaseName: String = "") { @@ -124,6 +133,7 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id + tabStructureVersion += 1 } func addERDiagramTab(schemaKey: String, databaseName: String = "") { @@ -135,6 +145,7 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id + tabStructureVersion += 1 } func addServerDashboardTab() { @@ -148,6 +159,7 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id + tabStructureVersion += 1 } func addTerminalTab(databaseName: String = "") { @@ -162,6 +174,7 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id + tabStructureVersion += 1 } func addPreviewTableTab( @@ -185,6 +198,7 @@ final class QueryTabManager { newTab.isPreview = true tabs.append(newTab) selectedTabId = newTab.id + tabStructureVersion += 1 } /// Replace the currently selected tab's content with a new table. @@ -236,6 +250,7 @@ final class QueryTabManager { tab.tableContext.schemaName = schemaName tab.isPreview = isPreview tabs[selectedIndex] = tab + tabStructureVersion += 1 return true } @@ -245,6 +260,11 @@ final class QueryTabManager { } } + func markTabRenamed(_ tabId: UUID) { + guard tabs.contains(where: { $0.id == tabId }) else { return } + tabStructureVersion += 1 + } + deinit { #if DEBUG Logger(subsystem: "com.TablePro", category: "QueryTabManager") diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 933ce6265..aa59bf619 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -126,7 +126,8 @@ struct MainEditorContentView: View { guard let query = notification.userInfo?["query"] as? String else { return } favoriteDialogQuery = FavoriteDialogQuery(query: query) } - .onChange(of: tabManager.tabIds) { _, newIds in + .onChange(of: tabManager.tabStructureVersion) { _, _ in + let newIds = tabManager.tabIds guard !sortCache.isEmpty || !tabProviderCache.isEmpty || !erDiagramViewModels.isEmpty || !serverDashboardViewModels.isEmpty else { coordinator.cleanupSortCache(openTabIds: Set(newIds)) @@ -342,7 +343,6 @@ struct MainEditorContentView: View { tabManager.tabs[index].content.query = newValue - // Update window dirty indicator and toolbar for file-backed tabs if tabManager.tabs[index].content.sourceFileURL != nil { let isDirty = tabManager.tabs[index].content.isFileDirty Task { @MainActor in @@ -351,13 +351,6 @@ struct MainEditorContentView: View { } } } - - // Skip persistence for very large queries (e.g., imported SQL dumps). - // JSON-encoding 40MB freezes the main thread. - let queryLength = (newValue as NSString).length - guard queryLength < TabQueryContent.maxPersistableQuerySize else { return } - - coordinator.persistence.saveLastQuery(newValue) } ) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index e06e71962..c7037a359 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -103,30 +103,19 @@ extension MainContentView { // MARK: - Consolidated onChange Triggers - /// Trigger for inspector updates — combines result version and table metadata name. - /// Replaces separate handlers for `currentTab?.resultRows` and - /// `coordinator.tableMetadata?.tableName` that both only called `scheduleInspectorUpdate()`. - /// Uses `resultVersion` instead of the full `resultRows` array to avoid deep equality checks. var inspectorTrigger: InspectorTrigger { InspectorTrigger( tableName: currentTab?.tableContext.tableName, resultVersion: currentTab?.resultVersion ?? -1, - metadataVersion: currentTab?.metadataVersion ?? -1, - metadataTableName: coordinator.tableMetadata?.tableName + metadataVersion: currentTab?.metadataVersion ?? -1 ) } } -// MARK: - Equatable Trigger Types - -/// Lightweight equatable value combining tab table name, result version, and metadata table name -/// for consolidated inspector onChange observation. Folding `tableName` here avoids a separate -/// `onChange(of: currentTab?.tableName)` handler that would cascade with this trigger. struct InspectorTrigger: Equatable { let tableName: String? let resultVersion: Int let metadataVersion: Int - let metadataTableName: String? } /// Lightweight equatable value combining all pending-change sources diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 6cd2f6c4d..1d2f7fde4 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -41,15 +41,13 @@ extension MainContentView { ) } - func handleTabsChange(_ newTabs: [QueryTab]) { + func handleStructureChange() { guard !coordinator.isTearingDown else { - MainContentView.lifecycleLogger.debug("[switch] handleTabsChange SKIPPED (tearingDown) tabCount=\(newTabs.count) connId=\(coordinator.connectionId, privacy: .public)") + MainContentView.lifecycleLogger.debug("[switch] handleStructureChange SKIPPED (tearingDown) tabCount=\(tabManager.tabs.count) connId=\(coordinator.connectionId, privacy: .public)") return } let t0 = Date() - // Only update title when the tab array changes independently of a tab switch. - // During a tab switch, handleTabSelectionChange already updates the title. if !coordinator.isHandlingTabSwitch { updateWindowTitleAndFileState() } @@ -60,7 +58,7 @@ extension MainContentView { coordinator.promotePreviewTab() } - let persistableTabs = newTabs.filter { !$0.isPreview } + let persistableTabs = tabManager.tabs.filter { !$0.isPreview } if persistableTabs.isEmpty { coordinator.persistence.clearSavedState() } else { @@ -73,7 +71,7 @@ extension MainContentView { ) } MainContentView.lifecycleLogger.debug( - "[switch] handleTabsChange tabCount=\(newTabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" + "[switch] handleStructureChange tabCount=\(tabManager.tabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) } @@ -267,7 +265,6 @@ extension MainContentView { ) } } - } func lazyLoadExcludedColumnsIfNeeded() { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index ac4b74597..531cbbe20 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -63,11 +63,11 @@ extension MainContentView { // MARK: - Inspector Context func scheduleInspectorUpdate(lazyLoadExcludedColumns: Bool = false) { - updateSidebarEditState() inspectorUpdateTask?.cancel() inspectorUpdateTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled else { return } + updateSidebarEditState() updateInspectorContext() if lazyLoadExcludedColumns { lazyLoadExcludedColumnsIfNeeded() diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f1951067e..86517231b 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -769,6 +769,7 @@ final class MainContentCoordinator { return } + tabManager.tabStructureVersion += 1 dispatchParameterizedStatements( paramStatements, parameters: reconciled, @@ -781,6 +782,7 @@ final class MainContentCoordinator { let statements = SQLStatementScanner.allStatements(in: sql) guard !statements.isEmpty else { return } + tabManager.tabStructureVersion += 1 dispatchStatements(statements, tabIndex: index) } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 55dee0736..1694c3736 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -242,10 +242,9 @@ struct MainContentView: View { } } .task(id: currentTab?.tableContext.tableName) { - // Only load metadata after the tab has executed at least once — - // avoids a redundant DB query racing with the initial data query guard currentTab?.execution.lastExecutedAt != nil else { return } await loadTableMetadataIfNeeded() + scheduleInspectorUpdate() } .onChange(of: inspectorTrigger) { scheduleInspectorUpdate() @@ -348,8 +347,8 @@ struct MainContentView: View { (viewWindow?.windowController as? TabWindowController)?.refreshUserActivity() handleTabSelectionChange(from: oldTabId, to: newTabId) } - .onChange(of: tabManager.tabs) { _, newTabs in - handleTabsChange(newTabs) + .onChange(of: tabManager.tabStructureVersion) { _, _ in + handleStructureChange() } .onChange(of: currentTab?.resultColumns) { _, newColumns in handleColumnsChange(newColumns: newColumns) diff --git a/docs/development/datagrid-refactor-design.md b/docs/development/datagrid-refactor-design.md new file mode 100644 index 000000000..8d2b2c8c0 --- /dev/null +++ b/docs/development/datagrid-refactor-design.md @@ -0,0 +1,283 @@ +# DataGrid Performance Refactor — Design Document + +Branch: refactor/delegate-dispatch (worktree datagrid-perf-arch) +Scope: Phases A through F, single PR + +## Canonical Signal Taxonomy + +| Signal | Type | Owner | Semantics | +|---|---|---|---| +| schemaVersion | Int on QueryTab | QueryTab | Columns changed: new query result with different column names, types, or count. Bumped by applyPhase1Result. Replaces resultVersion for schema-only concerns. | +| tabStructureVersion | Int on QueryTabManager | QueryTabManager | Tab list changed: add, remove, rename, title update, user-initiated query set. Drives persistence (debounced 300ms). | +| changeManager.reloadVersion | Int on DataChangeManager | DataChangeManager | Cell edit recorded or change state cleared. Drives per-row NSTableView reload. | +| Delegate row-delta calls | DataGridViewDelegate methods | DataGridViewDelegate | Row shape changed: addRow, deleteRows, insertRows. Drives insertRows(at:withAnimation:) / removeRows(at:withAnimation:) directly on NSTableView. No SwiftUI re-eval. | +| selectionState.indices | GridSelectionState | GridSelectionState | Row selection changed. Already isolated. | +| paginationVersion | Int on QueryTab | QueryTab | Page changed. Already correct. | +| metadataVersion | Int on QueryTab | QueryTab | FK / column-type metadata arrived. Already correct. | +| RightPanelState | @Observable class | RightPanelState | Inspector context updated. Already isolated. | + +Signals removed: resultVersion as row-mutation counter; onChange(of: tabManager.tabs) driving persistence; queryTextBinding triggering persistence per keystroke. + +## Ordering Rationale + +A + B in parallel: independent. C requires A (persistence already decoupled from tabs writes) and B (inspectorTrigger already cleaned up). D requires C (DataGridIdentity is now keyed on schemaVersion). E requires D (delegate-delta path wired so RowDataStore can notify NSTableView directly). F always last. + +## Phase A — Persistence Decoupling + +### Goal +Tab persistence triggers only on structural tab changes, not row mutations or per-keystroke text edits. + +### Changes + +**`TablePro/Models/Query/QueryTabManager.swift`** +- Add `var tabStructureVersion: Int = 0` +- In each of `addTab`, `addTableTab` (new-tab path only), `addCreateTableTab`, `addERDiagramTab`, `addServerDashboardTab` (new path only), `addTerminalTab` (new path only), `addPreviewTableTab`, `replaceTabContent`: bump `tabStructureVersion += 1` after `selectedTabId = newTab.id`. +- Add `func markTabRenamed(_ tabId: UUID) { tabStructureVersion += 1 }` +- Do NOT bump in `updateTab` (used by result application paths). +- Audit removeTab/closeTab: must bump. + +**`TablePro/Views/Main/Child/MainEditorContentView.swift`** +- Lines 129–141 `.onChange(of: tabManager.tabIds)` — replace with `.onChange(of: tabManager.tabStructureVersion)`. Same body. +- `queryTextBinding` lines 356–361 — REMOVE the saveLastQuery block. Per-keystroke persistence is eliminated. + +**`TablePro/Views/Main/MainContentView.swift`** +- Line 351 `.onChange(of: tabManager.tabs)` — replace with `.onChange(of: tabManager.tabStructureVersion)` calling new `handleStructureChange()`. + +**`TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift`** +- Remove `handleTabsChange(_:)`. +- Add `handleStructureChange()` body: window title update, preview promotion, `coordinator.persistence.saveNow(...)` with persistableTabs. + +**`TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift`** +- If `saveLastQuery(_:)` only called from queryTextBinding, remove it. Otherwise, keep but ensure callers are intentional (tab switch, window close). +- saveNow itself debounces internally via 300ms; if not, add a `pendingSaveTask: Task?` and 300ms sleep before encoding. + +### Risk Callouts +- Loss-on-crash: bump `tabStructureVersion` inside `runQuery()` so every executed query is a structural event. This catches "user committed work." +- Audit all callers of saveLastQuery before removing. + +## Phase B — Inspector Decoupling + +### Goal +updateSidebarEditState runs inside the 50ms debounce. coordinator.tableMetadata removed from inspectorTrigger. + +### Changes + +**`TablePro/Views/Main/Extensions/MainContentView+Bindings.swift`** +- `inspectorTrigger` (lines 110–117): drop `metadataTableName` field. Drop `coordinator.tableMetadata?.tableName` read. +- `InspectorTrigger` struct (line 125): drop `metadataTableName: String?`. + +**`TablePro/Views/Main/Extensions/MainContentView+Helpers.swift`** +- `scheduleInspectorUpdate(...)` (lines 65–76): move `updateSidebarEditState()` call INSIDE the Task body, after `Task.sleep`. Currently runs synchronously before the debounce. + +**`TablePro/Views/Main/MainContentView.swift`** +- Existing `.task(id: currentTab?.tableContext.tableName)` block: extend to call `scheduleInspectorUpdate()` after `loadTableMetadataIfNeeded()` returns. This replaces the implicit observation of coordinator.tableMetadata via inspectorTrigger. + +### Risk Callouts +- 50ms lag on inspector field updates after row click — already true for inspector context; making sidebar edit state match is correct. + +## Phase C — Version Split + Signal Cleanup + +### Goal +Replace QueryTab.resultVersion with QueryTab.schemaVersion (column shape only). Remove row-mutation counter entirely. Row operations drive NSTableView via insertRows/removeRows through DataGridViewDelegate. + +### Changes + +**`TablePro/Models/Query/QueryTab.swift`** +- Line 67: rename `var resultVersion: Int` → `var schemaVersion: Int`. +- Lines 94, 128: rename in initializers. +- Lines 193–207 `static func ==`: rename comparison. + +**`TablePro/Views/Results/DataGridViewDelegate.swift`** (or wherever the protocol lives) +- Add three optional methods to protocol: + - `func dataGridDidInsertRows(at indices: IndexSet)` + - `func dataGridDidRemoveRows(at indices: IndexSet)` + - `func dataGridDidReplaceAllRows()` +- Provide default empty implementations in extension. + +**TableViewCoordinator (in DataGridView.swift or sibling)** +- Add `applyInsertedRows(_ indices: IndexSet)` calling `tableView.insertRows(at: indices, withAnimation: .slideDown)` +- Add `applyRemovedRows(_ indices: IndexSet)` calling `tableView.removeRows(at: indices, withAnimation: .slideUp)` +- Add `applyFullReplace()` calling `tableView.reloadData()` +- After each delta call, also call existing `updateCache()` so cachedRowCount stays in sync. + +**`TablePro/Views/Main/Child/DataTabGridDelegate.swift`** +- Implement the three new protocol methods, forwarding to coordinator's apply* methods. + +**`TablePro/Views/Main/MainContentCoordinator.swift`** +- Add `@ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate?` +- Wire in MainEditorContentView.onAppear; clear in onTeardown. + +**`TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift`** +- Line 30 addNewRow: remove `resultVersion += 1`. Add `dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex))`. +- Line 53 deleteSelectedRows: remove bump. Add `dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(indices))`. +- Line 74 duplicateSelectedRow: remove bump. Add insertRows. +- Line 85 undoInsertRow: remove bump. Add removeRows. +- Line 99 undoLastChange: remove bump. Add `dataTabDelegate?.dataGridDidReplaceAllRows()` (conservative). +- Line 113 redoLastChange: remove bump. Add replaceAllRows. +- Line 171 pasteRows: remove bump. Add insertRows with multiple indices. + +**`TablePro/Views/Main/MainContentCoordinator.swift`** +- Lines 1395 and 1401: REMOVE `changeManager.reloadVersion += 1` from sort completion. Sort drives via querySortCache + provider rebuild via sortState mismatch. + +**`TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift`** +- Line 252: rename to `schemaVersion += 1`. +- Line 271: REMOVE the `changeManager.reloadVersion += 1` (double-signal). + +**`TablePro/Views/Main/Child/MainEditorContentView.swift`** +- Line 507 (pin toggle in resultTabBar): REMOVE the `resultVersion += 1`. Add a local `@State var displayRefreshToken: UUID = UUID()` toggled in onPin and use `.id(displayRefreshToken)` on the relevant subview if needed. +- Line 165 `.onChange(of: tabManager.selectedTab?.resultVersion)`: rename to schemaVersion. +- `RowProviderCacheEntry` (lines 23): rename resultVersion → schemaVersion. +- `cacheRowProvider`, `rowProvider(for:)`: rename version checks. +- DataGridView call site (lines 543–547): rename parameter resultVersion → schemaVersion. + +**`TablePro/Views/Results/DataGridView.swift`** +- Line 67 `var resultVersion: Int = 0`: rename to schemaVersion. +- Lines 38–61 `DataGridIdentity` struct: rename field. +- Lines 239–248 identity construction: rename. + +**`TablePro/Views/Main/MainContentCoordinator.swift`** applyPhase1Result +- Find the `updatedTab.resultVersion += 1` call. Rename to schemaVersion. + +### Migration Sequence within Phase C +1. Rename resultVersion → schemaVersion across all files. +2. Add delegate protocol methods + extension defaults. +3. Implement TableViewCoordinator apply* methods. +4. Implement DataTabGridDelegate forwarding. +5. Add coordinator's weak dataTabDelegate reference. +6. Replace each row op's resultVersion bump with the appropriate delegate call. +7. Remove sort-completion reloadVersion bumps. +8. Remove pin-toggle resultVersion bump. +9. Remove double-signal in applyMultiStatementResults. + +### Risk Callouts +- Row index ordering for removeRows: must use pre-deletion indices, sorted descending if mutating in single call. Verify rowOperationsManager returns correct sets. +- After each delta, update coordinator.cachedRowCount. +- Invalidate querySortCache on row add/delete (cache holds stale indices otherwise). +- Phase 2 metadata path still uses metadataVersion — separate from schemaVersion. + +## Phase D — DataGridConfiguration Equatable + Body Cleanup + +### Goal +DataGridConfiguration : Equatable; updateNSView short-circuits on equal config. Remove imperative dataTabDelegate writes from MainEditorContentView body. + +### Changes + +**`TablePro/Views/Results/DataGridConfiguration.swift`** (or wherever defined) +- Add `: Equatable`. Verify all fields are Equatable (DatabaseType is String-based; should already conform). + +**`TablePro/Views/Results/DataGridView.swift`** +- DataGridIdentity: add `tabType: TabType` field if missing. +- updateNSView identity guard: no logic change; now correctly covers all relevant config fields. + +**`TablePro/Views/Main/Child/MainEditorContentView.swift`** +- Lines 530–541: REMOVE the `let _ = { ... }()` imperative block. +- Stable refs (coordinator, columnVisibilityManager, selectionState, editingCell, onSort, onFilterColumn, onRefresh): set once in onAppear. +- Mutable refs that depend on isEditable (onCellEdit, onAddRow, onUndoInsert): set via `.onChange(of: tabManager.selectedTab?.tableContext.isEditable)` and any other dependent properties. +- Delegate computes `shouldShowEmptySpaceMenu` itself from coordinator instead of receiving via closure. + +### Risk Callouts +- DatabaseType Equatable conformance: verify it's a String-based struct (it is per CLAUDE.md). +- Delegate property may be stale for one render frame after isEditable change; use `.onChange(initial: true)` if needed. + +## Phase E — Move Row Data Out of QueryTab into RowDataStore + +### Goal +Row data lives in RowDataStore keyed by tab.id. QueryTab becomes pure metadata. SwiftUI never sees row mutations. + +### New Type + +**New file: `TablePro/Core/Services/Query/RowDataStore.swift`** + +``` +@MainActor @Observable +final class RowDataStore { + private var store: [UUID: RowBuffer] = [:] + func buffer(for tabId: UUID) -> RowBuffer + func setBuffer(_ buffer: RowBuffer, for tabId: UUID) + func removeBuffer(for tabId: UUID) + func evict(for tabId: UUID) + func evictAll(except activeTabId: UUID?) +} +``` + +@ObservationIgnored on `store` so SwiftUI does not observe individual buffer changes through this dictionary. Reads/writes go through methods. + +### Changes + +**`TablePro/Views/Main/MainContentCoordinator.swift`** +- Add `let rowDataStore = RowDataStore()`. +- Pass through to MainEditorContentView as new parameter. + +**`TablePro/Models/Query/QueryTab.swift`** +- Remove `var rowBuffer: RowBuffer`. +- Remove all proxy properties: resultColumns, columnTypes, columnDefaults, columnForeignKeys, columnEnumValues, columnNullable, resultRows. +- Keep schemaVersion, metadataVersion, paginationVersion, content, display, execution, filterState, sortState, columnLayout, tableContext. +- Update init(from persisted:) — no rowBuffer init. +- Update `==`: remove row-related field checks. + +**Replace all `tab.rowBuffer` and proxy reads** across: +- MainContentCoordinator+RowOperations.swift (writes too) +- MainContentCoordinator+MultiStatement.swift (applyMultiStatementResults: write columns/rows to rowDataStore.buffer(for: tabId)) +- MainContentCoordinator+LoadMore.swift (loadMoreRows / performFetchAll: append to buffer in store) +- MainContentView+EventHandlers.swift (updateSidebarEditState) +- MainContentView+Bindings.swift (selectedRowDataForSidebar) +- MainContentView+Helpers.swift (buildQueryResultsSummary) +- MainEditorContentView.swift (makeRowProvider, sortIndicesForTab, resultsSection, JSON view) +- Export paths in MainContentView.swift + +with `coordinator.rowDataStore.buffer(for: tab.id)` accesses. + +**`TablePro/Models/Query/QueryTabManager.swift`** +- replaceTabContent: remove `tab.rowBuffer = RowBuffer()`. Caller must clear via `coordinator.rowDataStore.setBuffer(RowBuffer(), for: selectedId)`. + +**Eviction** +- evictInactiveRowData → delegate to `rowDataStore.evictAll(except: tabManager.selectedTabId)`. +- teardown → `rowDataStore.evictAll(except: nil)`. + +### Migration Sequence +1. Create RowDataStore.swift. +2. Add to coordinator + pass into view. +3. Migrate write paths (applyPhase1Result, applyMultiStatementResults, RowOperations, LoadMore). +4. Migrate read paths (sidebar, json, sort, provider). +5. Remove rowBuffer + proxies from QueryTab. +6. Update teardown / eviction. + +### Risk Callouts +- ResultSet buffers (tab.display.resultSets[i].rowBuffer) — out of scope for Phase E. Keep ResultSet local row data as-is. +- InMemoryRowProvider holds RowBuffer reference — must be invalidated on schemaVersion bump (already covered by RowProviderCacheEntry.schemaVersion check). +- PersistedTab does not reference rowBuffer — verify. +- All row-data access is on @MainActor — coordinator extensions already are. + +## Phase F — Build, Lint, CHANGELOG, Manual Verify + +### Commands +``` +xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation +swiftlint lint --strict +``` + +### CHANGELOG.md (Unreleased > Changed) +- DataGrid persistence triggers only on structural tab changes (add/remove/rename), not on row mutations or keystrokes +- Inspector sidebar edit state updates inside 50ms debounce instead of immediately on every result version change +- DataGrid row operations (add/delete/duplicate/paste/undo/redo) use NSTableView insertRows/removeRows instead of full reloadData +- Row data moved out of QueryTab @Observable array into RowDataStore, eliminating SwiftUI observation cycles for row mutations +- Removed spurious resultVersion bumps from pin toggle, sort completion, and multi-statement double-signal + +### Manual checklist +1. Connect to test DB; open table with 500+ rows. +2. Add/delete/duplicate/paste rows: verify NSTableView animations, no full reload. +3. Cell edit + save + undo + redo. +4. Sort large query result: no flicker. +5. Pin result set: data grid stays stable. +6. Multi-statement query: results appear once. +7. Switch tables rapidly: no stale data. +8. Type 100 chars: zero persistence I/O. +9. Tab restoration after close/reopen. +10. Inspector: 50ms debounced field display. + +## Cross-Cutting + +- No comments in source. No backward-compat shims. Native macOS only. +- New user-facing strings: none in this refactor. +- swiftlint --strict must pass. +- Tests: QueryTabManager tabStructureVersion increments correctly; RowDataStore CRUD; DataGridViewDelegate delta methods called with correct indices. From db84521fed20b39db55e74a850baf686aac3b351 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 00:32:51 +0700 Subject: [PATCH 02/10] refactor(perf): split resultVersion and route row ops through delegate deltas - Rename QueryTab.resultVersion to schemaVersion (column shape only). ResultSet keeps its own resultVersion for result-set tabs. - Add dataGridDidInsertRows/dataGridDidRemoveRows/dataGridDidReplaceAllRows to DataGridViewDelegate. DataTabGridDelegate forwards to the table view coordinator. - TableViewCoordinator gains applyInsertedRows/applyRemovedRows/applyFullReplace that call NSTableView.insertRows / removeRows / reloadData directly, refresh visual state, and invalidate identity so subsequent updateNSView passes don't short-circuit. - Row ops (add, delete, duplicate, undo, redo, paste) drive the new delta path instead of bumping a counter. RowOperationsManager.deleteSelectedRows now returns physicallyRemovedIndices so soft-deletes stay on the existing reloadVersion path while inserted-row removals get NSTableView animation. - querySortCache invalidates per tab on row-count changes. - Sort completion routes a full reload via the delegate; remove the redundant changeManager.reloadVersion bumps. - applyMultiStatementResults: keep the schemaVersion bump, drop the redundant reloadVersion bump. - Pin toggle no longer mutates a tab counter; TabDisplayState.resultSets is already @Observable. - Wire MainContentCoordinator.dataTabDelegate weak ref in MainEditorContentView.onAppear / clear in teardown. --- .../Services/Query/RowOperationsManager.swift | 44 ++++++++++--------- TablePro/Models/Query/QueryTab.swift | 8 ++-- TablePro/Models/Query/QueryTabManager.swift | 2 +- .../Main/Child/DataTabGridDelegate.swift | 18 ++++++++ .../Main/Child/MainEditorContentView.swift | 25 ++++++----- .../MainContentCoordinator+LoadMore.swift | 8 ++-- ...ainContentCoordinator+MultiStatement.swift | 3 +- .../MainContentCoordinator+QueryHelpers.swift | 4 +- ...MainContentCoordinator+RowOperations.swift | 36 ++++++++++----- ...ainContentCoordinator+SidebarActions.swift | 2 +- .../Extensions/MainContentView+Bindings.swift | 4 +- .../Extensions/MainContentView+Helpers.swift | 4 +- .../Views/Main/MainContentCoordinator.swift | 17 ++++--- .../Views/Results/DataGridCoordinator.swift | 24 ++++++++++ TablePro/Views/Results/DataGridView.swift | 17 ++++--- .../Views/Results/DataGridViewDelegate.swift | 8 ++++ .../Views/Structure/TableStructureView.swift | 2 +- 17 files changed, 150 insertions(+), 76 deletions(-) diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index bb0554d0e..5283502cd 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -91,16 +91,18 @@ final class RowOperationsManager { // MARK: - Delete Rows - /// Delete selected rows - /// - Parameters: - /// - selectedIndices: Indices of rows to delete - /// - resultRows: Current rows (will be mutated) - /// - Returns: Next row index to select after deletion, or -1 if no rows left + struct DeleteRowsResult { + let nextRowToSelect: Int + let physicallyRemovedIndices: [Int] + } + func deleteSelectedRows( selectedIndices: Set, resultRows: inout [[String?]] - ) -> Int { - guard !selectedIndices.isEmpty else { return -1 } + ) -> DeleteRowsResult { + guard !selectedIndices.isEmpty else { + return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: []) + } var insertedRowsToDelete: [Int] = [] var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = [] @@ -118,40 +120,40 @@ final class RowOperationsManager { } } - // Process inserted rows deletion - if !insertedRowsToDelete.isEmpty { - let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) + let sortedInsertedRows = insertedRowsToDelete.sorted(by: >) - // Remove from resultRows first (descending order) + if !sortedInsertedRows.isEmpty { for rowIndex in sortedInsertedRows { guard rowIndex < resultRows.count else { continue } resultRows.remove(at: rowIndex) } - - // Update changeManager for ALL deleted inserted rows at once changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) } - // Record batch deletion for existing rows (single undo action for all rows) if !existingRowsToDelete.isEmpty { changeManager.recordBatchRowDeletion(rows: existingRowsToDelete) } - // Calculate next row selection, accounting for deleted inserted rows let totalRows = resultRows.count - let rowsDeleted = insertedRowsToDelete.count + let rowsDeleted = sortedInsertedRows.count let adjustedMaxRow = maxSelectedRow - rowsDeleted - let adjustedMinRow = minSelectedRow - insertedRowsToDelete.count(where: { $0 < minSelectedRow }) + let adjustedMinRow = minSelectedRow - sortedInsertedRows.count(where: { $0 < minSelectedRow }) + let nextRow: Int if adjustedMaxRow + 1 < totalRows { - return min(adjustedMaxRow + 1, totalRows - 1) + nextRow = min(adjustedMaxRow + 1, totalRows - 1) } else if adjustedMinRow > 0 { - return adjustedMinRow - 1 + nextRow = adjustedMinRow - 1 } else if totalRows > 0 { - return 0 + nextRow = 0 } else { - return -1 + nextRow = -1 } + + return DeleteRowsResult( + nextRowToSelect: nextRow, + physicallyRemovedIndices: sortedInsertedRows + ) } // MARK: - Undo/Redo diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 0993389be..8eebf4713 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -64,7 +64,7 @@ struct QueryTab: Identifiable, Equatable { var columnLayout: ColumnLayoutState var pagination: PaginationState var hasUserInteraction: Bool - var resultVersion: Int + var schemaVersion: Int var metadataVersion: Int var paginationVersion: Int @@ -91,7 +91,7 @@ struct QueryTab: Identifiable, Equatable { self.columnLayout = ColumnLayoutState() self.pagination = PaginationState() self.hasUserInteraction = false - self.resultVersion = 0 + self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 } @@ -123,7 +123,7 @@ struct QueryTab: Identifiable, Equatable { self.columnLayout = ColumnLayoutState() self.pagination = PaginationState() self.hasUserInteraction = false - self.resultVersion = 0 + self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 } @@ -194,7 +194,7 @@ struct QueryTab: Identifiable, Equatable { lhs.id == rhs.id && lhs.title == rhs.title && lhs.execution == rhs.execution - && lhs.resultVersion == rhs.resultVersion + && lhs.schemaVersion == rhs.schemaVersion && lhs.paginationVersion == rhs.paginationVersion && lhs.pagination == rhs.pagination && lhs.sortState == rhs.sortState diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 5a3cabe70..88870bf12 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -231,7 +231,7 @@ final class QueryTabManager { tab.title = tableName tab.tableContext.tableName = tableName tab.content.query = query - tab.resultVersion += 1 + tab.schemaVersion += 1 tab.execution.executionTime = nil tab.execution.statusMessage = nil tab.execution.errorMessage = nil diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 58971a14a..79aec94c6 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -110,4 +110,22 @@ final class DataTabGridDelegate: DataGridViewDelegate { menu.addItem(item) return menu } + + weak var tableViewCoordinator: TableViewCoordinator? + + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + self.tableViewCoordinator = tableViewCoordinator + } + + func dataGridDidInsertRows(at indices: IndexSet) { + tableViewCoordinator?.applyInsertedRows(indices) + } + + func dataGridDidRemoveRows(at indices: IndexSet) { + tableViewCoordinator?.applyRemovedRows(indices) + } + + func dataGridDidReplaceAllRows() { + tableViewCoordinator?.applyFullReplace() + } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index aa59bf619..39f727497 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -15,13 +15,13 @@ private struct SortedRowsCache { let sortedIndices: [Int] let columnIndex: Int let direction: SortDirection - let resultVersion: Int + let schemaVersion: Int } /// Per-tab row provider cache entry — groups all cache-invalidation keys together private struct RowProviderCacheEntry { let provider: InMemoryRowProvider - let resultVersion: Int + let schemaVersion: Int let metadataVersion: Int let sortState: SortState } @@ -145,7 +145,7 @@ struct MainEditorContentView: View { guard let newId, let tab = tabManager.selectedTab else { return } let cached = tabProviderCache[newId] - if cached?.resultVersion != tab.resultVersion + if cached?.schemaVersion != tab.schemaVersion || cached?.metadataVersion != tab.metadataVersion { cacheRowProvider(for: tab) @@ -157,13 +157,15 @@ struct MainEditorContentView: View { if let tab = tabManager.selectedTab { cacheRowProvider(for: tab) } + coordinator.dataTabDelegate = dataTabDelegate coordinator.onTeardown = { [self] in tabProviderCache.removeAll() sortCache.removeAll() cachedChangeManager = nil + coordinator.dataTabDelegate = nil } } - .onChange(of: tabManager.selectedTab?.resultVersion) { _, newVersion in + .onChange(of: tabManager.selectedTab?.schemaVersion) { _, newVersion in guard let tab = tabManager.selectedTab, newVersion != nil else { return } cacheRowProvider(for: tab) } @@ -497,7 +499,6 @@ struct MainEditorContentView: View { onPin: { id in guard let tabIdx = coordinator.tabManager.selectedTabIndex else { return } coordinator.tabManager.tabs[tabIdx].display.resultSets.first { $0.id == id }?.isPinned.toggle() - coordinator.tabManager.tabs[tabIdx].resultVersion += 1 } ) } @@ -536,7 +537,7 @@ struct MainEditorContentView: View { DataGridView( rowProvider: rowProvider(for: tab), changeManager: currentChangeManager, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, paginationVersion: tab.paginationVersion, isEditable: isEditable, @@ -567,7 +568,7 @@ struct MainEditorContentView: View { return makeRowProvider(for: tab) } if let entry = tabProviderCache[tab.id], - entry.resultVersion == tab.resultVersion, + entry.schemaVersion == tab.schemaVersion, entry.metadataVersion == tab.metadataVersion, entry.sortState == tab.sortState { @@ -577,7 +578,7 @@ struct MainEditorContentView: View { Task { @MainActor in tabProviderCache[tab.id] = RowProviderCacheEntry( provider: provider, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, sortState: tab.sortState ) @@ -589,7 +590,7 @@ struct MainEditorContentView: View { let provider = makeRowProvider(for: tab) tabProviderCache[tab.id] = RowProviderCacheEntry( provider: provider, - resultVersion: tab.resultVersion, + schemaVersion: tab.schemaVersion, metadataVersion: tab.metadataVersion, sortState: tab.sortState ) @@ -715,7 +716,7 @@ struct MainEditorContentView: View { if let cached = coordinator.querySortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, - cached.resultVersion == tab.resultVersion + cached.schemaVersion == tab.schemaVersion { return cached.sortedIndices } @@ -729,7 +730,7 @@ struct MainEditorContentView: View { if let cached = sortCache[tab.id], cached.columnIndex == (tab.sortState.columnIndex ?? -1), cached.direction == tab.sortState.direction, - cached.resultVersion == tab.resultVersion + cached.schemaVersion == tab.schemaVersion { return cached.sortedIndices } @@ -763,7 +764,7 @@ struct MainEditorContentView: View { sortedIndices: sortedIndices, columnIndex: tab.sortState.columnIndex ?? -1, direction: tab.sortState.direction, - resultVersion: tab.resultVersion + schemaVersion: tab.schemaVersion ) return sortedIndices diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index 2538b1a23..f7cec40ff 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -97,7 +97,7 @@ extension MainContentCoordinator { var tab = tabManager.tabs[idx] tab.rowBuffer.rows.append(contentsOf: pagedResult.rows) - tab.resultVersion += 1 + tab.schemaVersion += 1 tab.pagination.loadMoreOffset = pagedResult.nextOffset tab.pagination.hasMoreRows = pagedResult.hasMore tab.pagination.isLoadingMore = false @@ -105,7 +105,7 @@ extension MainContentCoordinator { tab.pagination.baseQueryForMore = nil } if let rs = tab.display.activeResultSet { - rs.resultVersion = tab.resultVersion + rs.resultVersion = tab.schemaVersion } tabManager.tabs[idx] = tab toolbarState.setExecuting(false) @@ -221,10 +221,10 @@ extension MainContentCoordinator { var tab = tabManager.tabs[idx] tab.rowBuffer.rows = result.rows tab.execution.executionTime = result.executionTime - tab.resultVersion += 1 + tab.schemaVersion += 1 tab.pagination.resetLoadMore() if let rs = tab.display.activeResultSet { - rs.resultVersion = tab.resultVersion + rs.resultVersion = tab.schemaVersion } tabManager.tabs[idx] = tab toolbarState.setExecuting(false) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index bccc155fe..41d2ece5b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -249,7 +249,7 @@ extension MainContentCoordinator { updatedTab.tableContext.isEditable = false } - updatedTab.resultVersion += 1 + updatedTab.schemaVersion += 1 updatedTab.execution.executionTime = cumulativeTime updatedTab.execution.rowsAffected = totalRowsAffected updatedTab.execution.isExecuting = false @@ -268,7 +268,6 @@ extension MainContentCoordinator { if tabManager.selectedTabId == tabId { changeManager.clearChangesAndUndoHistory() - changeManager.reloadVersion += 1 } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 16d622053..74008647d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -251,7 +251,7 @@ extension MainContentCoordinator { updatedTab.resultColumns = columns updatedTab.columnTypes = columnTypes updatedTab.resultRows = rows - updatedTab.resultVersion += 1 + updatedTab.schemaVersion += 1 updatedTab.execution.executionTime = executionTime updatedTab.execution.rowsAffected = rowsAffected updatedTab.execution.statusMessage = statusMessage @@ -291,7 +291,7 @@ extension MainContentCoordinator { rs.statusMessage = updatedTab.execution.statusMessage rs.tableName = updatedTab.tableContext.tableName rs.isEditable = updatedTab.tableContext.isEditable - rs.resultVersion = updatedTab.resultVersion + rs.resultVersion = updatedTab.schemaVersion rs.metadataVersion = updatedTab.metadataVersion rs.columnTypes = updatedTab.columnTypes rs.columnDefaults = updatedTab.columnDefaults diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 90c9f38b8..f3e2fa899 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -27,7 +27,8 @@ extension MainContentCoordinator { selectionState.indices = [result.rowIndex] editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) } func deleteSelectedRows(indices: Set) { @@ -37,19 +38,27 @@ extension MainContentCoordinator { tabManager.tabs[tabIndex].tableContext.isEditable, !indices.isEmpty else { return } - let nextRow = rowOperationsManager.deleteSelectedRows( + let tabId = tabManager.tabs[tabIndex].id + let result = rowOperationsManager.deleteSelectedRows( selectedIndices: indices, resultRows: &tabManager.tabs[tabIndex].resultRows ) - if nextRow >= 0 && nextRow < tabManager.tabs[tabIndex].resultRows.count { - selectionState.indices = [nextRow] + if result.nextRowToSelect >= 0 + && result.nextRowToSelect < tabManager.tabs[tabIndex].resultRows.count { + selectionState.indices = [result.nextRowToSelect] } else { selectionState.indices.removeAll() } tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + + if !result.physicallyRemovedIndices.isEmpty { + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.dataGridDidRemoveRows( + at: IndexSet(result.physicallyRemovedIndices) + ) + } } func duplicateSelectedRow(index: Int, editingCell: inout CellPosition?) { @@ -70,25 +79,29 @@ extension MainContentCoordinator { selectionState.indices = [result.rowIndex] editingCell = CellPosition(row: result.rowIndex, column: 0) tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex)) } func undoInsertRow(at rowIndex: Int) { guard let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } + let tabId = tabManager.tabs[tabIndex].id selectionState.indices = rowOperationsManager.undoInsertRow( at: rowIndex, resultRows: &tabManager.tabs[tabIndex].resultRows, selectedIndices: selectionState.indices ) - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(integer: rowIndex)) } func undoLastChange() { guard let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } + let tabId = tabManager.tabs[tabIndex].id if let adjustedSelection = rowOperationsManager.undoLastChange( resultRows: &tabManager.tabs[tabIndex].resultRows ) { @@ -96,7 +109,8 @@ extension MainContentCoordinator { } tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tabId) + dataTabDelegate?.dataGridDidReplaceAllRows() } func redoLastChange() { @@ -110,7 +124,8 @@ extension MainContentCoordinator { ) tabManager.tabs[tabIndex].hasUserInteraction = true - tabManager.tabs[tabIndex].resultVersion += 1 + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidReplaceAllRows() } func copySelectedRowsToClipboard(indices: Set) { @@ -168,7 +183,6 @@ extension MainContentCoordinator { ) tabManager.tabs[index].resultRows = tab.resultRows - tabManager.tabs[index].resultVersion += 1 if !pastedRows.isEmpty { let newIndices = Set(pastedRows.map { $0.rowIndex }) @@ -176,6 +190,8 @@ extension MainContentCoordinator { tabManager.tabs[index].selectedRowIndices = newIndices tabManager.tabs[index].hasUserInteraction = true + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(newIndices)) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 6f37b8d08..d36c93976 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -30,7 +30,7 @@ extension MainContentCoordinator { tabManager.tabs[tabIdx].execution.rowsAffected = 0 tabManager.tabs[tabIdx].execution.executionTime = nil tabManager.tabs[tabIdx].execution.statusMessage = nil - tabManager.tabs[tabIdx].resultVersion += 1 + tabManager.tabs[tabIdx].schemaVersion += 1 tabManager.tabs[tabIdx].display.isResultsCollapsed = true toolbarState.isResultsCollapsed = true } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index c7037a359..1a03f71fa 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -106,7 +106,7 @@ extension MainContentView { var inspectorTrigger: InspectorTrigger { InspectorTrigger( tableName: currentTab?.tableContext.tableName, - resultVersion: currentTab?.resultVersion ?? -1, + schemaVersion: currentTab?.schemaVersion ?? -1, metadataVersion: currentTab?.metadataVersion ?? -1 ) } @@ -114,7 +114,7 @@ extension MainContentView { struct InspectorTrigger: Equatable { let tableName: String? - let resultVersion: Int + let schemaVersion: Int let metadataVersion: Int } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 531cbbe20..f33231aeb 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -90,12 +90,12 @@ extension MainContentView { private func cachedQueryResultsSummary() -> String? { guard let tab = currentTab else { return nil } if let cache = queryResultsSummaryCache, - cache.tabId == tab.id, cache.version == tab.resultVersion + cache.tabId == tab.id, cache.version == tab.schemaVersion { return cache.summary } let summary = buildQueryResultsSummary() - queryResultsSummaryCache = (tabId: tab.id, version: tab.resultVersion, summary: summary) + queryResultsSummaryCache = (tabId: tab.id, version: tab.schemaVersion, summary: summary) return summary } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 86517231b..13039e224 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -26,7 +26,7 @@ struct QuerySortCacheEntry { let sortedIndices: [Int] let columnIndex: Int let direction: SortDirection - let resultVersion: Int + let schemaVersion: Int } /// Sidebar table loading state — single source of truth for sidebar UI @@ -111,6 +111,10 @@ final class MainContentCoordinator { /// Direct reference to right panel state — enables showing AI panel programmatically @ObservationIgnored weak var rightPanelState: RightPanelState? + /// Direct reference to the data tab grid delegate — enables row mutation operations to + /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. + @ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate? + /// Proxy for toggling the inspector NSSplitViewItem from coordinator code @ObservationIgnored weak var inspectorProxy: InspectorVisibilityProxy? @@ -138,7 +142,7 @@ final class MainContentCoordinator { var sidebarLoadingState: SidebarLoadingState = .idle /// Cache for async-sorted query tab rows (large datasets sorted on background thread) - @ObservationIgnored private(set) var querySortCache: [UUID: QuerySortCacheEntry] = [:] + @ObservationIgnored var querySortCache: [UUID: QuerySortCacheEntry] = [:] // MARK: - Internal State @@ -1353,7 +1357,7 @@ final class MainContentCoordinator { tabManager.tabs[tabIndex].pagination.reset() let rows = tab.resultRows let tabId = tab.id - let resultVersion = tab.resultVersion + let schemaVersion = tab.schemaVersion let sortColumns = currentSort.columns let colTypes = tab.columnTypes @@ -1385,7 +1389,7 @@ final class MainContentCoordinator { sortedIndices: sortedIndices, columnIndex: sortColumns.first?.columnIndex ?? 0, direction: sortColumns.first?.direction ?? .ascending, - resultVersion: resultVersion + schemaVersion: schemaVersion ) var sortedTab = self.tabManager.tabs[idx] sortedTab.execution.isExecuting = false @@ -1394,13 +1398,12 @@ final class MainContentCoordinator { self.toolbarState.setExecuting(false) self.toolbarState.lastQueryDuration = sortDuration self.activeSortTasks.removeValue(forKey: tabId) - self.changeManager.reloadVersion += 1 + self.dataTabDelegate?.dataGridDidReplaceAllRows() } } activeSortTasks[tabId] = task } else { - // Small dataset: view sorts synchronously, just trigger reload - changeManager.reloadVersion += 1 + dataTabDelegate?.dataGridDidReplaceAllRows() } return } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 31879e868..3daa7a86e 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -240,6 +240,30 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData cachedColumnCount = rowProvider.columns.count } + func applyInsertedRows(_ indices: IndexSet) { + guard let tableView else { return } + rebuildVisualStateCache() + tableView.insertRows(at: indices, withAnimation: .slideDown) + updateCache() + lastIdentity = nil + } + + func applyRemovedRows(_ indices: IndexSet) { + guard let tableView else { return } + rebuildVisualStateCache() + tableView.removeRows(at: indices, withAnimation: .slideUp) + updateCache() + lastIdentity = nil + } + + func applyFullReplace() { + guard let tableView else { return } + rebuildVisualStateCache() + tableView.reloadData() + updateCache() + lastIdentity = nil + } + func rebuildColumnMetadataCache() { var enumSet = Set() var fkSet = Set() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 7e00c3dc6..a1ee24e7e 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -27,7 +27,7 @@ struct RowVisualState { /// Identity snapshot used to skip redundant updateNSView work when nothing has changed struct DataGridIdentity: Equatable { let reloadVersion: Int - let resultVersion: Int + let schemaVersion: Int let metadataVersion: Int let paginationVersion: Int let rowCount: Int @@ -35,10 +35,10 @@ struct DataGridIdentity: Equatable { let isEditable: Bool let hiddenColumns: Set - init(reloadVersion: Int, resultVersion: Int, metadataVersion: Int, paginationVersion: Int, + init(reloadVersion: Int, schemaVersion: Int, metadataVersion: Int, paginationVersion: Int, rowCount: Int, columnCount: Int, isEditable: Bool, hiddenColumns: Set) { self.reloadVersion = reloadVersion - self.resultVersion = resultVersion + self.schemaVersion = schemaVersion self.metadataVersion = metadataVersion self.paginationVersion = paginationVersion self.rowCount = rowCount @@ -47,10 +47,10 @@ struct DataGridIdentity: Equatable { self.hiddenColumns = hiddenColumns } - init(reloadVersion: Int, resultVersion: Int, metadataVersion: Int, paginationVersion: Int, + init(reloadVersion: Int, schemaVersion: Int, metadataVersion: Int, paginationVersion: Int, rowCount: Int, columnCount: Int, isEditable: Bool, configuration: DataGridConfiguration) { self.reloadVersion = reloadVersion - self.resultVersion = resultVersion + self.schemaVersion = schemaVersion self.metadataVersion = metadataVersion self.paginationVersion = paginationVersion self.rowCount = rowCount @@ -64,7 +64,7 @@ struct DataGridIdentity: Equatable { struct DataGridView: NSViewRepresentable { let rowProvider: InMemoryRowProvider var changeManager: AnyChangeManager - var resultVersion: Int = 0 + var schemaVersion: Int = 0 var metadataVersion: Int = 0 var paginationVersion: Int = 0 let isEditable: Bool @@ -182,6 +182,7 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView context.coordinator.delegate = delegate + delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns context.coordinator.typePickerColumns = configuration.typePickerColumns context.coordinator.customDropdownOptions = configuration.customDropdownOptions @@ -238,7 +239,7 @@ struct DataGridView: NSViewRepresentable { // AppSettingsManager access on every SwiftUI re-evaluation. let currentIdentity = DataGridIdentity( reloadVersion: changeManager.reloadVersion, - resultVersion: resultVersion, + schemaVersion: schemaVersion, metadataVersion: metadataVersion, paginationVersion: paginationVersion, rowCount: rowProvider.totalRowCount, @@ -249,6 +250,7 @@ struct DataGridView: NSViewRepresentable { if currentIdentity == coordinator.lastIdentity { // Only refresh delegate reference — it may have changed between body evals coordinator.delegate = delegate + delegate?.dataGridAttach(tableViewCoordinator: coordinator) return } let previousIdentity = coordinator.lastIdentity @@ -305,6 +307,7 @@ struct DataGridView: NSViewRepresentable { coordinator.changeManager = changeManager coordinator.isEditable = isEditable coordinator.delegate = delegate + delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns coordinator.typePickerColumns = configuration.typePickerColumns coordinator.customDropdownOptions = configuration.customDropdownOptions diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 803939b2e..7ea875e49 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -29,6 +29,10 @@ protocol DataGridViewDelegate: AnyObject { func dataGridVisualState(forRow row: Int) -> RowVisualState? func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? func dataGridEmptySpaceMenu() -> NSMenu? + func dataGridDidInsertRows(at indices: IndexSet) + func dataGridDidRemoveRows(at indices: IndexSet) + func dataGridDidReplaceAllRows() + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) } extension DataGridViewDelegate { @@ -52,4 +56,8 @@ extension DataGridViewDelegate { func dataGridVisualState(forRow row: Int) -> RowVisualState? { nil } func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { nil } func dataGridEmptySpaceMenu() -> NSMenu? { nil } + func dataGridDidInsertRows(at indices: IndexSet) {} + func dataGridDidRemoveRows(at indices: IndexSet) {} + func dataGridDidReplaceAllRows() {} + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) {} } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 2ede1713c..5bcd35fd4 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -271,7 +271,7 @@ struct TableStructureView: View { return DataGridView( rowProvider: provider.asInMemoryProvider(), changeManager: wrappedChangeManager, - resultVersion: displayVersion, + schemaVersion: displayVersion, isEditable: canEdit, configuration: DataGridConfiguration( dropdownColumns: allDropdownColumns, From 1d9790713bc1ffe60c1679e659676e379da0a654 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 00:36:46 +0700 Subject: [PATCH 03/10] refactor(perf): broaden DataGridIdentity and stop re-wiring delegate per render - Extend DataGridIdentity with tabType, tableName, and primaryKeyColumns so updateNSView's identity guard catches all configuration fields that should drive a column rebuild. Drop the legacy initializer. - Move DataTabGridDelegate property assignments out of the dataGridView(tab:) body. Stable refs are set once in onAppear; isEditable / isView / tableName / safeModeLevel drive a focused refresh via .onChange. - DataGridConfiguration was already Equatable; no change. --- .../Main/Child/MainEditorContentView.swift | 53 +++++++++++++------ TablePro/Views/Results/DataGridView.swift | 18 +++---- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 39f727497..491801cca 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -157,6 +157,8 @@ struct MainEditorContentView: View { if let tab = tabManager.selectedTab { cacheRowProvider(for: tab) } + wireDataTabDelegateStableRefs() + refreshDataTabDelegateMutableRefs() coordinator.dataTabDelegate = dataTabDelegate coordinator.onTeardown = { [self] in tabProviderCache.removeAll() @@ -180,6 +182,42 @@ struct MainEditorContentView: View { .onChange(of: selectionState.indices) { _, newIndices in onSelectionChange(newIndices) } + .onChange(of: tabManager.selectedTab?.tableContext.isEditable) { _, _ in + refreshDataTabDelegateMutableRefs() + } + .onChange(of: tabManager.selectedTab?.tableContext.isView) { _, _ in + refreshDataTabDelegateMutableRefs() + } + .onChange(of: tabManager.selectedTab?.tableContext.tableName) { _, _ in + refreshDataTabDelegateMutableRefs() + } + .onChange(of: coordinator.safeModeLevel) { _, _ in + refreshDataTabDelegateMutableRefs() + } + } + + private func wireDataTabDelegateStableRefs() { + dataTabDelegate.coordinator = coordinator + dataTabDelegate.columnVisibilityManager = columnVisibilityManager + dataTabDelegate.selectionState = selectionState + dataTabDelegate.editingCell = $editingCell + dataTabDelegate.onCellEdit = onCellEdit + dataTabDelegate.onSort = onSort + dataTabDelegate.onUndoInsert = onUndoInsert + dataTabDelegate.onFilterColumn = onFilterColumn + dataTabDelegate.onRefresh = onRefresh + } + + private func refreshDataTabDelegateMutableRefs() { + dataTabDelegate.onAddRow = currentTabAllowsAddRow ? onAddRow : nil + } + + private var currentTabAllowsAddRow: Bool { + guard let tab = tabManager.selectedTab else { return false } + let isEditable = tab.tableContext.isEditable + && !tab.tableContext.isView + && !coordinator.safeModeLevel.blocksAllWrites + return isEditable && tab.tableContext.tableName != nil } // MARK: - Tab Content @@ -518,21 +556,6 @@ struct MainEditorContentView: View { @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { let isEditable = tab.tableContext.isEditable && !tab.tableContext.isView && !coordinator.safeModeLevel.blocksAllWrites - let showEmptySpaceMenu = isEditable && tab.tableContext.tableName != nil - - // Update delegate state for current render - let _ = { // swiftlint:disable:this redundant_discardable_let - dataTabDelegate.coordinator = coordinator - dataTabDelegate.columnVisibilityManager = columnVisibilityManager - dataTabDelegate.selectionState = selectionState - dataTabDelegate.editingCell = $editingCell - dataTabDelegate.onCellEdit = onCellEdit - dataTabDelegate.onSort = onSort - dataTabDelegate.onAddRow = showEmptySpaceMenu ? onAddRow : nil - dataTabDelegate.onUndoInsert = onUndoInsert - dataTabDelegate.onFilterColumn = onFilterColumn - dataTabDelegate.onRefresh = onRefresh - }() DataGridView( rowProvider: rowProvider(for: tab), diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index a1ee24e7e..ac5ef40d6 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -33,20 +33,11 @@ struct DataGridIdentity: Equatable { let rowCount: Int let columnCount: Int let isEditable: Bool + let tabType: TabType? + let tableName: String? + let primaryKeyColumns: [String] let hiddenColumns: Set - init(reloadVersion: Int, schemaVersion: Int, metadataVersion: Int, paginationVersion: Int, - rowCount: Int, columnCount: Int, isEditable: Bool, hiddenColumns: Set) { - self.reloadVersion = reloadVersion - self.schemaVersion = schemaVersion - self.metadataVersion = metadataVersion - self.paginationVersion = paginationVersion - self.rowCount = rowCount - self.columnCount = columnCount - self.isEditable = isEditable - self.hiddenColumns = hiddenColumns - } - init(reloadVersion: Int, schemaVersion: Int, metadataVersion: Int, paginationVersion: Int, rowCount: Int, columnCount: Int, isEditable: Bool, configuration: DataGridConfiguration) { self.reloadVersion = reloadVersion @@ -56,6 +47,9 @@ struct DataGridIdentity: Equatable { self.rowCount = rowCount self.columnCount = columnCount self.isEditable = isEditable + self.tabType = configuration.tabType + self.tableName = configuration.tableName + self.primaryKeyColumns = configuration.primaryKeyColumns self.hiddenColumns = configuration.hiddenColumns } } From 9f470104a8714d7d9392042699454e38631c0922 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 01:03:18 +0700 Subject: [PATCH 04/10] refactor(perf): move row data out of QueryTab into RowDataStore QueryTab is now pure metadata. Row data (RowBuffer) lives in a RowDataStore keyed by tab.id, owned by MainContentCoordinator. Mutations to row data no longer flow through SwiftUI's @Observable tracking on tabManager.tabs. - New RowDataStore (@MainActor, @Observable, store @ObservationIgnored): buffer(for:), existingBuffer(for:), setBuffer, removeBuffer, evict, evictAll(except:), tearDown. - Drop rowBuffer and the resultRows/resultColumns/columnTypes/columnDefaults/columnForeignKeys/columnEnumValues/columnNullable proxy properties from QueryTab. Update == and init(from:) accordingly. - Migrate every read and write site (QueryHelpers, MultiStatement, LoadMore, RowOperations, Filtering, FKNavigation, Navigation, Discard, SaveChanges, SidebarActions, SidebarSave, TabSwitch, WindowLifecycle, CommandActions, plus inspector and editor views) to use coordinator.rowDataStore.buffer(for: tab.id). - replaceTabContent on QueryTabManager no longer resets a per-tab buffer; callers reset via setBuffer. - Eviction routes through rowDataStore.evictAll(except:); teardown clears the store. - Tests updated to read row data from the coordinator's rowDataStore. --- .../Core/Services/Query/RowDataStore.swift | 44 ++++++ TablePro/Models/Query/QueryTab.swift | 39 ------ TablePro/Models/Query/QueryTabManager.swift | 1 - .../Main/Child/MainEditorContentView.swift | 50 ++++--- .../Views/Main/Child/MainStatusBarView.swift | 8 +- .../MainContentCoordinator+Discard.swift | 12 +- .../MainContentCoordinator+FKNavigation.swift | 5 +- .../MainContentCoordinator+Filtering.swift | 11 +- .../MainContentCoordinator+LoadMore.swift | 10 +- ...ainContentCoordinator+MultiStatement.swift | 11 +- .../MainContentCoordinator+Navigation.swift | 8 ++ .../MainContentCoordinator+QueryHelpers.swift | 49 +++---- ...MainContentCoordinator+RowOperations.swift | 66 +++++---- .../MainContentCoordinator+SaveChanges.swift | 3 + ...ainContentCoordinator+SidebarActions.swift | 8 +- .../MainContentCoordinator+SidebarSave.swift | 5 +- .../MainContentCoordinator+TabSwitch.swift | 42 +++--- ...inContentCoordinator+WindowLifecycle.swift | 7 +- .../Extensions/MainContentView+Bindings.swift | 11 +- .../MainContentView+EventHandlers.swift | 29 ++-- .../Extensions/MainContentView+Helpers.swift | 11 +- .../Main/MainContentCommandActions.swift | 4 +- .../Views/Main/MainContentCoordinator.swift | 28 ++-- TablePro/Views/Main/MainContentView.swift | 7 +- TableProTests/Views/Main/EvictionTests.swift | 57 ++++---- .../Views/Main/TabEvictionTests.swift | 126 ++++++++++-------- 26 files changed, 359 insertions(+), 293 deletions(-) create mode 100644 TablePro/Core/Services/Query/RowDataStore.swift diff --git a/TablePro/Core/Services/Query/RowDataStore.swift b/TablePro/Core/Services/Query/RowDataStore.swift new file mode 100644 index 000000000..135519b64 --- /dev/null +++ b/TablePro/Core/Services/Query/RowDataStore.swift @@ -0,0 +1,44 @@ +import Foundation + +@MainActor +@Observable +final class RowDataStore { + @ObservationIgnored private var store: [UUID: RowBuffer] = [:] + + func buffer(for tabId: UUID) -> RowBuffer { + if let existing = store[tabId] { + return existing + } + let buffer = RowBuffer() + store[tabId] = buffer + return buffer + } + + func existingBuffer(for tabId: UUID) -> RowBuffer? { + store[tabId] + } + + func setBuffer(_ buffer: RowBuffer, for tabId: UUID) { + store[tabId] = buffer + } + + func removeBuffer(for tabId: UUID) { + store.removeValue(forKey: tabId) + } + + func evict(for tabId: UUID) { + store[tabId]?.evict() + } + + func evictAll(except activeTabId: UUID?) { + for (id, buffer) in store where id != activeTabId { + if !buffer.rows.isEmpty && !buffer.isEvicted { + buffer.evict() + } + } + } + + func tearDown() { + store.removeAll() + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 8eebf4713..fc43cd4b7 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -20,43 +20,6 @@ struct QueryTab: Identifiable, Equatable { var tableContext: TabTableContext var display: TabDisplayState - var rowBuffer: RowBuffer - - var resultColumns: [String] { - get { rowBuffer.columns } - set { rowBuffer.columns = newValue } - } - - var columnTypes: [ColumnType] { - get { rowBuffer.columnTypes } - set { rowBuffer.columnTypes = newValue } - } - - var columnDefaults: [String: String?] { - get { rowBuffer.columnDefaults } - set { rowBuffer.columnDefaults = newValue } - } - - var columnForeignKeys: [String: ForeignKeyInfo] { - get { rowBuffer.columnForeignKeys } - set { rowBuffer.columnForeignKeys = newValue } - } - - var columnEnumValues: [String: [String]] { - get { rowBuffer.columnEnumValues } - set { rowBuffer.columnEnumValues = newValue } - } - - var columnNullable: [String: Bool] { - get { rowBuffer.columnNullable } - set { rowBuffer.columnNullable = newValue } - } - - var resultRows: [[String?]] { - get { rowBuffer.rows } - set { rowBuffer.rows = newValue } - } - var pendingChanges: TabPendingChanges var selectedRowIndices: Set var sortState: SortState @@ -83,7 +46,6 @@ struct QueryTab: Identifiable, Equatable { self.execution = TabExecutionState() self.tableContext = TabTableContext(tableName: tableName, isEditable: tabType == .table) self.display = TabDisplayState() - self.rowBuffer = RowBuffer() self.pendingChanges = TabPendingChanges() self.selectedRowIndices = [] self.sortState = SortState() @@ -115,7 +77,6 @@ struct QueryTab: Identifiable, Equatable { isView: persisted.isView ) self.display = TabDisplayState(erDiagramSchemaKey: persisted.erDiagramSchemaKey) - self.rowBuffer = RowBuffer() self.pendingChanges = TabPendingChanges() self.selectedRowIndices = [] self.sortState = SortState() diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 88870bf12..07acc5f91 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -226,7 +226,6 @@ final class QueryTabManager { let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize var tab = tabs[selectedIndex] - tab.rowBuffer = RowBuffer() tab.tabType = .table tab.title = tableName tab.tableContext.tableName = tableName diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 491801cca..5b507c6d6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -440,10 +440,11 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } case .json: + let jsonBuffer = coordinator.rowDataStore.buffer(for: tab.id) ResultsJsonView( - columns: tab.resultColumns, - columnTypes: tab.columnTypes, - rows: tab.resultRows, + columns: jsonBuffer.columns, + columnTypes: jsonBuffer.columnTypes, + rows: jsonBuffer.rows, selectedRowIndices: selectionState.indices ) case .data: @@ -467,6 +468,7 @@ struct MainEditorContentView: View { } // Content: success view OR filter+grid + let resolvedBuffer = coordinator.rowDataStore.buffer(for: tab.id) if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, !tab.execution.isExecuting { @@ -475,7 +477,7 @@ struct MainEditorContentView: View { executionTime: rs.executionTime, statusMessage: rs.statusMessage ) - } else if tab.resultColumns.isEmpty && tab.execution.errorMessage == nil + } else if resolvedBuffer.columns.isEmpty && tab.execution.errorMessage == nil && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting { if tab.display.resultSets.isEmpty { @@ -492,7 +494,7 @@ struct MainEditorContentView: View { if filterStateManager.isVisible && tab.tabType == .table { FilterPanelView( filterState: filterStateManager, - columns: tab.resultColumns, + columns: resolvedBuffer.columns, primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, onApply: onApplyFilters, @@ -501,8 +503,8 @@ struct MainEditorContentView: View { Divider() } - if tab.tabType == .query && !tab.resultColumns.isEmpty - && tab.resultRows.isEmpty && tab.execution.lastExecutedAt != nil + if tab.tabType == .query && !resolvedBuffer.columns.isEmpty + && resolvedBuffer.rows.isEmpty && tab.execution.lastExecutedAt != nil && !tab.execution.isExecuting && !filterStateManager.hasAppliedFilters { emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime) @@ -586,7 +588,8 @@ struct MainEditorContentView: View { } private func rowProvider(for tab: QueryTab) -> InMemoryRowProvider { - if tab.rowBuffer.isEvicted { + let buffer = coordinator.rowDataStore.buffer(for: tab.id) + if buffer.isEvicted { Task { @MainActor in tabProviderCache.removeValue(forKey: tab.id) } return makeRowProvider(for: tab) } @@ -635,15 +638,16 @@ struct MainEditorContentView: View { columnNullable: rs.columnNullable ) } else { + let buffer = coordinator.rowDataStore.buffer(for: tab.id) provider = InMemoryRowProvider( - rowBuffer: tab.rowBuffer, + rowBuffer: buffer, sortIndices: sortIndicesForTab(tab), - columns: tab.resultColumns, - columnDefaults: tab.columnDefaults, - columnTypes: tab.columnTypes, - columnForeignKeys: tab.columnForeignKeys, - columnEnumValues: tab.columnEnumValues, - columnNullable: tab.columnNullable + columns: buffer.columns, + columnDefaults: buffer.columnDefaults, + columnTypes: buffer.columnTypes, + columnForeignKeys: buffer.columnForeignKeys, + columnEnumValues: buffer.columnEnumValues, + columnNullable: buffer.columnNullable ) } @@ -663,7 +667,7 @@ struct MainEditorContentView: View { var detected: [ValueDisplayFormat?] = Array(repeating: nil, count: columns.count) if settings.enableSmartValueDetection { let sampleRows: [[String?]]? = { - let rows = tab.display.activeResultSet?.resultRows ?? tab.resultRows + let rows = tab.display.activeResultSet?.resultRows ?? coordinator.rowDataStore.buffer(for: tab.id).rows return rows.isEmpty ? nil : Array(rows.prefix(10)) }() detected = ValueDisplayDetector.detect( @@ -718,9 +722,10 @@ struct MainEditorContentView: View { rows = rs.resultRows colTypes = rs.columnTypes } else { - rowBuffer = tab.rowBuffer - rows = tab.resultRows - colTypes = tab.columnTypes + let buffer = coordinator.rowDataStore.buffer(for: tab.id) + rowBuffer = buffer + rows = buffer.rows + colTypes = buffer.columnTypes } guard !rowBuffer.isEvicted else { return nil } @@ -823,11 +828,12 @@ struct MainEditorContentView: View { // MARK: - Status Bar private func statusBar(tab: QueryTab) -> some View { - MainStatusBarView( - snapshot: StatusBarSnapshot(tab: tab), + let buffer = coordinator.rowDataStore.buffer(for: tab.id) + return MainStatusBarView( + snapshot: StatusBarSnapshot(tab: tab, buffer: buffer), filterStateManager: filterStateManager, columnVisibilityManager: columnVisibilityManager, - allColumns: tab.resultColumns, + allColumns: buffer.columns, selectedRowIndices: selectionState.indices, viewMode: resultsViewModeBinding(for: tab), onFirstPage: onFirstPage, diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 188e6e814..65f201bbf 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -17,12 +17,12 @@ struct StatusBarSnapshot: Equatable { let pagination: PaginationState let statusMessage: String? - init(tab: QueryTab?) { + init(tab: QueryTab?, buffer: RowBuffer?) { self.tabId = tab?.id self.tabType = tab?.tabType - self.hasRows = !(tab?.resultRows.isEmpty ?? true) - self.hasColumns = !(tab?.resultColumns.isEmpty ?? true) - self.rowCount = tab?.resultRows.count ?? 0 + self.hasRows = !(buffer?.rows.isEmpty ?? true) + self.hasColumns = !(buffer?.columns.isEmpty ?? true) + self.rowCount = buffer?.rows.count ?? 0 self.hasTableName = tab?.tableContext.tableName != nil self.pagination = tab?.pagination ?? PaginationState() self.statusMessage = tab?.execution.statusMessage diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index f0d1f4aa6..93ebc63fe 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -72,17 +72,19 @@ extension MainContentCoordinator { ) { let originalValues = changeManager.getOriginalValues() if let index = tabManager.selectedTabIndex { + let tabId = tabManager.tabs[index].id + let buffer = rowDataStore.buffer(for: tabId) for (rowIndex, columnIndex, originalValue) in originalValues { - if rowIndex < tabManager.tabs[index].resultRows.count, - columnIndex < tabManager.tabs[index].resultRows[rowIndex].count { - tabManager.tabs[index].resultRows[rowIndex][columnIndex] = originalValue + if rowIndex < buffer.rows.count, + columnIndex < buffer.rows[rowIndex].count { + buffer.rows[rowIndex][columnIndex] = originalValue } } let insertedIndices = changeManager.insertedRowIndices.sorted(by: >) for rowIndex in insertedIndices { - if rowIndex < tabManager.tabs[index].resultRows.count { - tabManager.tabs[index].resultRows.remove(at: rowIndex) + if rowIndex < buffer.rows.count { + buffer.rows.remove(at: rowIndex) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index a192a11b8..a2a19e400 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -83,6 +83,8 @@ extension MainContentCoordinator { ) if needsQuery, let tabIndex = tabManager.selectedTabIndex { + let tabId = tabManager.tabs[tabIndex].id + rowDataStore.setBuffer(RowBuffer(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() } @@ -98,11 +100,12 @@ extension MainContentCoordinator { // New tab — build filtered query directly, run once guard let tabIndex = tabManager.selectedTabIndex else { return } let tab = tabManager.tabs[tabIndex] + let buffer = rowDataStore.buffer(for: tab.id) let filteredQuery = queryBuilder.buildFilteredQuery( tableName: referencedTable, schemaName: fkInfo.referencedSchema, filters: [filter], - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift index 4a1476e50..945456474 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift @@ -26,13 +26,14 @@ extension MainContentCoordinator { self.tabManager.tabs[capturedTabIndex].pagination.reset() let tab = self.tabManager.tabs[capturedTabIndex] + let buffer = self.rowDataStore.buffer(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildFilteredQuery( tableName: capturedTableName, filters: capturedFilters, logicMode: self.filterStateManager.filterLogicMode, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions @@ -63,11 +64,12 @@ extension MainContentCoordinator { guard capturedTabIndex < self.tabManager.tabs.count else { return } let tab = self.tabManager.tabs[capturedTabIndex] + let buffer = self.rowDataStore.buffer(for: tab.id) let exclusions = self.columnExclusions(for: capturedTableName) let newQuery = self.queryBuilder.buildBaseQuery( tableName: capturedTableName, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions @@ -93,6 +95,7 @@ extension MainContentCoordinator { let tableName = tabManager.tabs[tabIndex].tableContext.tableName else { return } let tab = tabManager.tabs[tabIndex] + let buffer = rowDataStore.buffer(for: tab.id) let hasFilters = filterStateManager.hasAppliedFilters let exclusions = columnExclusions(for: tableName) @@ -103,7 +106,7 @@ extension MainContentCoordinator { filters: filterStateManager.appliedFilters, logicMode: filterStateManager.filterLogicMode, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions @@ -112,7 +115,7 @@ extension MainContentCoordinator { newQuery = queryBuilder.buildBaseQuery( tableName: tableName, sortState: tab.sortState, - columns: tab.resultColumns, + columns: buffer.columns, limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset, columnExclusions: exclusions diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index f7cec40ff..e4780cfe0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -96,7 +96,8 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - tab.rowBuffer.rows.append(contentsOf: pagedResult.rows) + let buffer = rowDataStore.buffer(for: tab.id) + buffer.rows.append(contentsOf: pagedResult.rows) tab.schemaVersion += 1 tab.pagination.loadMoreOffset = pagedResult.nextOffset tab.pagination.hasMoreRows = pagedResult.hasMore @@ -112,7 +113,7 @@ extension MainContentCoordinator { if capturedGeneration == queryGeneration { currentQueryTask = nil } - progressLog.info("[loadMore] applied totalRows=\(tab.rowBuffer.rows.count)") + progressLog.info("[loadMore] applied totalRows=\(buffer.rows.count)") } } catch { await MainActor.run { [weak self] in @@ -138,7 +139,7 @@ extension MainContentCoordinator { tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore else { return } - let loadedCount = tab.resultRows.count + let loadedCount = rowDataStore.buffer(for: tab.id).rows.count let totalEstimate = tab.pagination.totalRowCount let message: String @@ -219,7 +220,8 @@ extension MainContentCoordinator { } var tab = tabManager.tabs[idx] - tab.rowBuffer.rows = result.rows + let buffer = rowDataStore.buffer(for: tab.id) + buffer.rows = result.rows tab.execution.executionTime = result.executionTime tab.schemaVersion += 1 tab.pagination.resetLoadMore() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index 41d2ece5b..d6f5246ed 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -234,15 +234,14 @@ extension MainContentCoordinator { tableName = lastSelectSQL.flatMap { extractTableName(from: $0) } } - updatedTab.resultColumns = safeColumns - updatedTab.columnTypes = safeColumnTypes - updatedTab.resultRows = safeRows + rowDataStore.setBuffer( + RowBuffer(rows: safeRows, columns: safeColumns, columnTypes: safeColumnTypes), + for: updatedTab.id + ) updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = tableName != nil && updatedTab.tableContext.isEditable } else { - updatedTab.resultColumns = [] - updatedTab.columnTypes = [] - updatedTab.resultRows = [] + rowDataStore.setBuffer(RowBuffer(), for: updatedTab.id) if updatedTab.tabType != .table { updatedTab.tableContext.tableName = nil } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 62f9c5f42..973358f32 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -124,6 +124,8 @@ extension MainContentCoordinator { ) { filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { + let tabId = tabManager.tabs[tabIndex].id + rowDataStore.setBuffer(RowBuffer(), for: tabId) tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } @@ -204,6 +206,8 @@ extension MainContentCoordinator { ) previewCoordinator.filterStateManager.clearAll() if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { + let tabId = previewCoordinator.tabManager.tabs[tabIndex].id + previewCoordinator.rowDataStore.setBuffer(RowBuffer(), for: tabId) previewCoordinator.tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() previewCoordinator.toolbarState.isTableTab = true @@ -274,6 +278,8 @@ extension MainContentCoordinator { ) filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { + let tabId = tabManager.tabs[tabIndex].id + rowDataStore.setBuffer(RowBuffer(), for: tabId) tabManager.tabs[tabIndex].display.resultsViewMode = showStructure ? .structure : .data tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true @@ -383,6 +389,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + rowDataStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in @@ -417,6 +424,7 @@ extension MainContentCoordinator { closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + rowDataStore.tearDown() tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 74008647d..a124b3d86 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -188,19 +188,20 @@ extension MainContentCoordinator { return false } let tab = tabManager.tabs[idx] + let buffer = rowDataStore.buffer(for: tab.id) guard tab.tableContext.tableName == tableName, - !tab.columnDefaults.isEmpty, + !buffer.columnDefaults.isEmpty, !tab.tableContext.primaryKeyColumns.isEmpty else { return false } // Ensure every ENUM/SET column has its allowed values loaded - let enumSetColumnNames: [String] = tab.resultColumns.enumerated().compactMap { i, name in - guard i < tab.columnTypes.count, - tab.columnTypes[i].isEnumType || tab.columnTypes[i].isSetType else { return nil } + let enumSetColumnNames: [String] = buffer.columns.enumerated().compactMap { i, name in + guard i < buffer.columnTypes.count, + buffer.columnTypes[i].isEnumType || buffer.columnTypes[i].isSetType else { return nil } return name } if !enumSetColumnNames.isEmpty, - !enumSetColumnNames.allSatisfy({ tab.columnEnumValues[$0] != nil }) { + !enumSetColumnNames.allSatisfy({ buffer.columnEnumValues[$0] != nil }) { return false } return true @@ -248,9 +249,7 @@ extension MainContentCoordinator { guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } var updatedTab = tabManager.tabs[idx] - updatedTab.resultColumns = columns - updatedTab.columnTypes = columnTypes - updatedTab.resultRows = rows + let newBuffer = RowBuffer(rows: rows, columns: columns, columnTypes: columnTypes) updatedTab.schemaVersion += 1 updatedTab.execution.executionTime = executionTime updatedTab.execution.rowsAffected = rowsAffected @@ -260,19 +259,19 @@ extension MainContentCoordinator { updatedTab.tableContext.tableName = tableName updatedTab.tableContext.isEditable = isEditable // Populate enum values from column types for the enum popover - for (index, colType) in updatedTab.columnTypes.enumerated() { - if case .enumType(_, let values) = colType, let vals = values, index < updatedTab.resultColumns.count { - updatedTab.columnEnumValues[updatedTab.resultColumns[index]] = vals + for (index, colType) in newBuffer.columnTypes.enumerated() { + if case .enumType(_, let values) = colType, let vals = values, index < newBuffer.columns.count { + newBuffer.columnEnumValues[newBuffer.columns[index]] = vals } } // Merge FK metadata into the same update if available if let metadata { - updatedTab.columnDefaults = metadata.columnDefaults - updatedTab.columnForeignKeys = metadata.columnForeignKeys - updatedTab.columnNullable = metadata.columnNullable + newBuffer.columnDefaults = metadata.columnDefaults + newBuffer.columnForeignKeys = metadata.columnForeignKeys + newBuffer.columnNullable = metadata.columnNullable for (col, vals) in metadata.columnEnumValues { - updatedTab.columnEnumValues[col] = vals + newBuffer.columnEnumValues[col] = vals } if let approxCount = metadata.approximateRowCount, approxCount > 0 { updatedTab.pagination.totalRowCount = approxCount @@ -283,9 +282,11 @@ extension MainContentCoordinator { updatedTab.metadataVersion += 1 } + rowDataStore.setBuffer(newBuffer, for: updatedTab.id) + // Create a ResultSet for this single-statement execution let rs = ResultSet(label: tableName ?? "Result") - rs.rowBuffer = updatedTab.rowBuffer + rs.rowBuffer = newBuffer rs.executionTime = updatedTab.execution.executionTime rs.rowsAffected = updatedTab.execution.rowsAffected rs.statusMessage = updatedTab.execution.statusMessage @@ -293,11 +294,11 @@ extension MainContentCoordinator { rs.isEditable = updatedTab.tableContext.isEditable rs.resultVersion = updatedTab.schemaVersion rs.metadataVersion = updatedTab.metadataVersion - rs.columnTypes = updatedTab.columnTypes - rs.columnDefaults = updatedTab.columnDefaults - rs.columnForeignKeys = updatedTab.columnForeignKeys - rs.columnEnumValues = updatedTab.columnEnumValues - rs.columnNullable = updatedTab.columnNullable + rs.columnTypes = newBuffer.columnTypes + rs.columnDefaults = newBuffer.columnDefaults + rs.columnForeignKeys = newBuffer.columnForeignKeys + rs.columnEnumValues = newBuffer.columnEnumValues + rs.columnNullable = newBuffer.columnNullable // Keep pinned results, replace unpinned let pinned = updatedTab.display.resultSets.filter(\.isPinned) @@ -471,13 +472,13 @@ extension MainContentCoordinator { guard capturedGeneration == queryGeneration else { return } guard !Task.isCancelled else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - let existing = tabManager.tabs[idx].columnEnumValues + let buffer = rowDataStore.buffer(for: tabId) let hasNewValues = columnEnumValues.contains { key, value in - existing[key] != value + buffer.columnEnumValues[key] != value } if hasNewValues { for (col, vals) in columnEnumValues { - tabManager.tabs[idx].columnEnumValues[col] = vals + buffer.columnEnumValues[col] = vals } tabManager.tabs[idx].metadataVersion += 1 } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index f3e2fa899..2c20996aa 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -18,10 +18,11 @@ extension MainContentCoordinator { let tab = tabManager.tabs[tabIndex] guard tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } + let buffer = rowDataStore.buffer(for: tab.id) guard let result = rowOperationsManager.addNewRow( - columns: tab.resultColumns, - columnDefaults: tab.columnDefaults, - resultRows: &tabManager.tabs[tabIndex].resultRows + columns: buffer.columns, + columnDefaults: buffer.columnDefaults, + resultRows: &buffer.rows ) else { return } selectionState.indices = [result.rowIndex] @@ -39,13 +40,14 @@ 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: &tabManager.tabs[tabIndex].resultRows + resultRows: &buffer.rows ) if result.nextRowToSelect >= 0 - && result.nextRowToSelect < tabManager.tabs[tabIndex].resultRows.count { + && result.nextRowToSelect < buffer.rows.count { selectionState.indices = [result.nextRowToSelect] } else { selectionState.indices.removeAll() @@ -67,13 +69,14 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - guard tab.tableContext.isEditable, tab.tableContext.tableName != nil, - index < tab.resultRows.count else { return } + guard tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } + let buffer = rowDataStore.buffer(for: tab.id) + guard index < buffer.rows.count else { return } guard let result = rowOperationsManager.duplicateRow( sourceRowIndex: index, - columns: tab.resultColumns, - resultRows: &tabManager.tabs[tabIndex].resultRows + columns: buffer.columns, + resultRows: &buffer.rows ) else { return } selectionState.indices = [result.rowIndex] @@ -88,9 +91,10 @@ 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: &tabManager.tabs[tabIndex].resultRows, + resultRows: &buffer.rows, selectedIndices: selectionState.indices ) querySortCache.removeValue(forKey: tabId) @@ -102,8 +106,9 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tabId = tabManager.tabs[tabIndex].id + let buffer = rowDataStore.buffer(for: tabId) if let adjustedSelection = rowOperationsManager.undoLastChange( - resultRows: &tabManager.tabs[tabIndex].resultRows + resultRows: &buffer.rows ) { selectionState.indices = adjustedSelection } @@ -118,9 +123,10 @@ extension MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] + let buffer = rowDataStore.buffer(for: tab.id) _ = rowOperationsManager.redoLastChange( - resultRows: &tabManager.tabs[tabIndex].resultRows, - columns: tab.resultColumns + resultRows: &buffer.rows, + columns: buffer.columns ) tabManager.tabs[tabIndex].hasUserInteraction = true @@ -133,9 +139,10 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] + let buffer = rowDataStore.buffer(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: tab.resultRows + resultRows: buffer.rows ) } @@ -144,10 +151,11 @@ extension MainContentCoordinator { !indices.isEmpty else { return } let tab = tabManager.tabs[index] + let buffer = rowDataStore.buffer(for: tab.id) rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, - resultRows: tab.resultRows, - columns: tab.resultColumns, + resultRows: buffer.rows, + columns: buffer.columns, includeHeaders: true ) } @@ -156,14 +164,15 @@ extension MainContentCoordinator { guard let index = tabManager.selectedTabIndex, !indices.isEmpty else { return } let tab = tabManager.tabs[index] + let buffer = rowDataStore.buffer(for: tab.id) let rows = indices.sorted().compactMap { idx -> [String?]? in - guard idx < tab.resultRows.count else { return nil } - return tab.resultRows[idx] + guard idx < buffer.rows.count else { return nil } + return buffer.rows[idx] } guard !rows.isEmpty else { return } let converter = JsonRowConverter( - columns: tab.resultColumns, - columnTypes: tab.columnTypes + columns: buffer.columns, + columnTypes: buffer.columnTypes ) ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } @@ -172,18 +181,17 @@ extension MainContentCoordinator { guard !safeModeLevel.blocksAllWrites, let index = tabManager.selectedTabIndex else { return } - var tab = tabManager.tabs[index] + let tab = tabManager.tabs[index] guard tab.tabType == .table else { return } + let buffer = rowDataStore.buffer(for: tab.id) let pastedRows = rowOperationsManager.pasteRowsFromClipboard( - columns: tab.resultColumns, + columns: buffer.columns, primaryKeyColumns: changeManager.primaryKeyColumns, - resultRows: &tab.resultRows + resultRows: &buffer.rows ) - tabManager.tabs[index].resultRows = tab.resultRows - if !pastedRows.isEmpty { let newIndices = Set(pastedRows.map { $0.rowIndex }) selectionState.indices = newIndices @@ -198,10 +206,12 @@ extension MainContentCoordinator { // MARK: - Cell Operations func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { - guard let index = tabManager.selectedTabIndex, - rowIndex < tabManager.tabs[index].resultRows.count else { return } + guard let index = tabManager.selectedTabIndex else { return } + let tabId = tabManager.tabs[index].id + let buffer = rowDataStore.buffer(for: tabId) + guard rowIndex < buffer.rows.count else { return } - tabManager.tabs[index].resultRows[rowIndex][columnIndex] = value + buffer.rows[rowIndex][columnIndex] = value tabManager.tabs[index].hasUserInteraction = true } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 2bec53d1f..2446cee9d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -248,6 +248,9 @@ extension MainContentCoordinator { if !tabIdsToRemove.isEmpty { let firstRemovedIndex = tabManager.tabs .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 + for tabId in tabIdsToRemove { + rowDataStore.removeBuffer(for: tabId) + } tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } if !tabManager.tabs.isEmpty { let neighborIndex = min(firstRemovedIndex, tabManager.tabs.count - 1) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index d36c93976..7e65f610b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -22,10 +22,7 @@ extension MainContentCoordinator { tabManager.tabs[tabIdx].display.activeResultSetId = tabManager.tabs[tabIdx].display.resultSets.last?.id } if tabManager.tabs[tabIdx].display.resultSets.isEmpty { - tabManager.tabs[tabIdx].rowBuffer = RowBuffer() - tabManager.tabs[tabIdx].resultColumns = [] - tabManager.tabs[tabIdx].columnTypes = [] - tabManager.tabs[tabIdx].resultRows = [] + rowDataStore.setBuffer(RowBuffer(), for: tabManager.tabs[tabIdx].id) tabManager.tabs[tabIdx].execution.errorMessage = nil tabManager.tabs[tabIdx].execution.rowsAffected = 0 tabManager.tabs[tabIdx].execution.executionTime = nil @@ -107,7 +104,8 @@ extension MainContentCoordinator { } func openExportQueryResultsDialog() { - guard let tab = tabManager.selectedTab, !tab.rowBuffer.rows.isEmpty else { return } + guard let tab = tabManager.selectedTab, + !rowDataStore.buffer(for: tab.id).rows.isEmpty else { return } activeSheet = .exportQueryResults } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index 35919933e..364bec943 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -23,9 +23,10 @@ extension MainContentCoordinator { let editedFields = editState.getEditedFields() guard !editedFields.isEmpty else { return } + let buffer = rowDataStore.buffer(for: tab.id) let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex in - guard rowIndex < tab.resultRows.count else { return nil } - let originalRow = tab.resultRows[rowIndex] + guard rowIndex < buffer.rows.count else { return nil } + let originalRow = buffer.rows[rowIndex] return RowChange( rowIndex: rowIndex, type: .update, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 2eaa7bd77..015d18604 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -57,6 +57,7 @@ extension MainContentCoordinator { if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { let newTab = tabManager.tabs[newIndex] + let newBuffer = rowDataStore.buffer(for: newId) // Restore filter state for new tab filterStateManager.restoreFromTabState(newTab.filterState) @@ -74,9 +75,9 @@ extension MainContentCoordinator { } else { changeManager.configureForTable( tableName: newTab.tableContext.tableName ?? "", - columns: newTab.resultColumns, + columns: newBuffer.columns, primaryKeyColumns: newTab.tableContext.primaryKeyColumns.isEmpty - ? newTab.resultColumns.prefix(1).map { $0 } + ? newBuffer.columns.prefix(1).map { $0 } : newTab.tableContext.primaryKeyColumns, databaseType: connection.type, triggerReload: false @@ -111,7 +112,7 @@ extension MainContentCoordinator { // If the tab shows isExecuting but has no results, the previous query was // likely cancelled when the user rapidly switched away. Force-clear the stale // flag so the lazy-load check below can re-execute the query. - if newTab.execution.isExecuting && newTab.resultRows.isEmpty && newTab.execution.lastExecutedAt == nil { + if newTab.execution.isExecuting && newBuffer.rows.isEmpty && newTab.execution.lastExecutedAt == nil { let tabId = newId Task { [weak self] in guard let self, @@ -121,9 +122,9 @@ extension MainContentCoordinator { } } - let isEvicted = newTab.rowBuffer.isEvicted + let isEvicted = newBuffer.isEvicted let needsLazyQuery = newTab.tabType == .table - && (newTab.resultRows.isEmpty || isEvicted) + && (newBuffer.rows.isEmpty || isEvicted) && (newTab.execution.lastExecutedAt == nil || isEvicted) && newTab.execution.errorMessage == nil && !newTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -153,25 +154,28 @@ extension MainContentCoordinator { private func evictInactiveTabs(excluding activeTabIds: Set) { let start = Date() - let candidates = tabManager.tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + let candidates: [(tab: QueryTab, buffer: RowBuffer)] = tabManager.tabs.compactMap { tab in + guard !activeTabIds.contains(tab.id), + tab.execution.lastExecutedAt != nil, + !tab.pendingChanges.hasChanges, + let buffer = rowDataStore.existingBuffer(for: tab.id), + !buffer.isEvicted, + !buffer.rows.isEmpty + else { return nil } + return (tab, buffer) } let sorted = candidates.sorted { - let t0 = $0.execution.lastExecutedAt ?? .distantFuture - let t1 = $1.execution.lastExecutedAt ?? .distantFuture + let t0 = $0.tab.execution.lastExecutedAt ?? .distantFuture + let t1 = $1.tab.execution.lastExecutedAt ?? .distantFuture if t0 != t1 { return t0 < t1 } let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.rowBuffer.rows.count, - columnCount: $0.rowBuffer.columns.count + rowCount: $0.buffer.rows.count, + columnCount: $0.buffer.columns.count ) let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.rowBuffer.rows.count, - columnCount: $1.rowBuffer.columns.count + rowCount: $1.buffer.rows.count, + columnCount: $1.buffer.columns.count ) return size0 > size1 } @@ -185,8 +189,8 @@ extension MainContentCoordinator { } let toEvict = sorted.dropLast(maxInactiveLoaded) - for tab in toEvict { - tab.rowBuffer.evict() + for entry in toEvict { + entry.buffer.evict() } Self.lifecycleLogger.debug( "[switch] evictInactiveTabs evicted=\(toEvict.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 17e3ad0e7..70806c38e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -40,9 +40,10 @@ extension MainContentCoordinator { DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false let needsLazyLoad = tabManager.selectedTab.map { tab in - tab.tabType == .table - && (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted) - && (tab.execution.lastExecutedAt == nil || tab.rowBuffer.isEvicted) + let buffer = rowDataStore.buffer(for: tab.id) + return tab.tabType == .table + && (buffer.rows.isEmpty || buffer.isEvicted) + && (tab.execution.lastExecutedAt == nil || buffer.isEvicted) && tab.execution.errorMessage == nil && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 1a03f71fa..7066017a1 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -15,19 +15,20 @@ extension MainContentView { var selectedRowDataForSidebar: [(column: String, value: String?, type: String)]? { guard let tab = coordinator.tabManager.selectedTab, !coordinator.selectionState.indices.isEmpty, - let firstIndex = coordinator.selectionState.indices.min(), - firstIndex < tab.resultRows.count else { return nil } + let firstIndex = coordinator.selectionState.indices.min() else { return nil } + let buffer = coordinator.rowDataStore.buffer(for: tab.id) + guard firstIndex < buffer.rows.count else { return nil } - let row = tab.resultRows[firstIndex] + let row = buffer.rows[firstIndex] var data: [(column: String, value: String?, type: String)] = [] let service = ValueDisplayFormatService.shared let connId = coordinator.connection.id let tblName = tab.tableContext.tableName - for (i, col) in tab.resultColumns.enumerated() { + for (i, col) in buffer.columns.enumerated() { var value = i < row.count ? row[i] : nil - let type = i < tab.columnTypes.count ? tab.columnTypes[i].displayName : "string" + let type = i < buffer.columnTypes.count ? buffer.columnTypes[i].displayName : "string" // Apply display format if active if let rawValue = value { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 1d2f7fde4..9c761d52a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -176,18 +176,19 @@ extension MainContentView { rightPanelState.editState.onFieldChanged = nil return } + let buffer = coordinator.rowDataStore.buffer(for: tab.id) var allRows: [[String?]] = [] for index in selectedIndices.sorted() { - if index < tab.resultRows.count { - allRows.append(tab.resultRows[index]) + if index < buffer.rows.count { + allRows.append(buffer.rows[index]) } } // Enrich column types with loaded enum values from Phase 2b - var columnTypes = tab.columnTypes - for (i, col) in tab.resultColumns.enumerated() where i < columnTypes.count { - if let values = tab.columnEnumValues[col], !values.isEmpty { + var columnTypes = buffer.columnTypes + for (i, col) in buffer.columns.enumerated() where i < columnTypes.count { + if let values = buffer.columnEnumValues[col], !values.isEmpty { let ct = columnTypes[i] if ct.isEnumType { columnTypes[i] = .enumType(rawType: ct.rawType, values: values) @@ -216,12 +217,12 @@ extension MainContentView { } let pkColumns = Set(tab.tableContext.primaryKeyColumns) - let fkColumns = Set(tab.columnForeignKeys.keys) + let fkColumns = Set(buffer.columnForeignKeys.keys) rightPanelState.editState.configure( selectedRowIndices: selectedIndices, allRows: allRows, - columns: tab.resultColumns, + columns: buffer.columns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, excludedColumnNames: excludedNames, @@ -238,12 +239,13 @@ extension MainContentView { let capturedEditState = rightPanelState.editState rightPanelState.editState.onFieldChanged = { columnIndex, newValue in guard let tab = capturedCoordinator.tabManager.selectedTab else { return } + let buffer = capturedCoordinator.rowDataStore.buffer(for: tab.id) let columnName = - columnIndex < tab.resultColumns.count ? tab.resultColumns[columnIndex] : "" + columnIndex < buffer.columns.count ? buffer.columns[columnIndex] : "" for rowIndex in capturedEditState.selectedRowIndices { - guard rowIndex < tab.resultRows.count else { continue } - let originalRow = tab.resultRows[rowIndex] + guard rowIndex < buffer.rows.count else { continue } + let originalRow = buffer.rows[rowIndex] // Use full (lazy-loaded) original value if available, not truncated row data let oldValue: String? @@ -281,15 +283,16 @@ extension MainContentView { let capturedCoordinator = coordinator let capturedEditState = rightPanelState.editState + let buffer = coordinator.rowDataStore.buffer(for: tab.id) if !excludedNames.isEmpty, selectedIndices.count == 1, let tableName = tab.tableContext.tableName, let pkColumn = tab.tableContext.primaryKeyColumn, let rowIndex = selectedIndices.first, - rowIndex < tab.resultRows.count + rowIndex < buffer.rows.count { - let row = tab.resultRows[rowIndex] - if let pkColIndex = tab.resultColumns.firstIndex(of: pkColumn), + let row = buffer.rows[rowIndex] + if let pkColIndex = buffer.columns.firstIndex(of: pkColumn), pkColIndex < row.count, let pkValue = row[pkColIndex] { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index f33231aeb..d2f0dbe1c 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -100,13 +100,12 @@ extension MainContentView { } private func buildQueryResultsSummary() -> String? { - guard let tab = currentTab, - !tab.resultColumns.isEmpty, - !tab.resultRows.isEmpty - else { return nil } + guard let tab = currentTab else { return nil } + let buffer = coordinator.rowDataStore.buffer(for: tab.id) + guard !buffer.columns.isEmpty, !buffer.rows.isEmpty else { return nil } - let columns = tab.resultColumns - let rows = tab.resultRows + let columns = buffer.columns + let rows = buffer.rows let maxRows = 10 let displayRows = Array(rows.prefix(maxRows)) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 3a2712eb0..db053689a 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -380,9 +380,7 @@ final class MainContentCommandActions { } else if coordinator?.tabManager.tabs.isEmpty == true { window.close() } else { - for tab in coordinator?.tabManager.tabs ?? [] { - tab.rowBuffer.evict() - } + coordinator?.rowDataStore.evictAll(except: nil) coordinator?.tabManager.tabs.removeAll() coordinator?.tabManager.selectedTabId = nil coordinator?.toolbarState.isTableTab = false diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 13039e224..6e50c2188 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -87,6 +87,7 @@ final class MainContentCoordinator { let filterStateManager: FilterStateManager let columnVisibilityManager: ColumnVisibilityManager let toolbarState: ConnectionToolbarState + let rowDataStore = RowDataStore() // MARK: - Services @@ -162,7 +163,7 @@ final class MainContentCoordinator { @ObservationIgnored private var fileWatcher: DatabaseFileWatcher? @ObservationIgnored private var lastSchemaRefreshDate = Date.distantPast - /// Set during handleTabChange to suppress redundant onChange(of: resultColumns) reconfiguration + /// Set during handleTabChange to suppress redundant column-change reconfiguration @ObservationIgnored internal var isHandlingTabSwitch = false @ObservationIgnored var isUpdatingColumnLayout = false @@ -336,12 +337,10 @@ final class MainContentCoordinator { /// Background tabs are re-fetched automatically when selected. func evictInactiveRowData() { let selectedId = tabManager.selectedTabId - for tab in tabManager.tabs where !tab.rowBuffer.isEvicted - && !tab.resultRows.isEmpty - && !tab.pendingChanges.hasChanges - && tab.id != selectedId - { - tab.rowBuffer.evict() + for tab in tabManager.tabs where tab.id != selectedId && !tab.pendingChanges.hasChanges { + guard let buffer = rowDataStore.existingBuffer(for: tab.id), + !buffer.isEvicted, !buffer.rows.isEmpty else { continue } + buffer.evict() } } @@ -585,9 +584,7 @@ final class MainContentCoordinator { ) // Release heavy data so memory drops even if SwiftUI delays deallocation - for tab in tabManager.tabs { - tab.rowBuffer.evict() - } + rowDataStore.tearDown() querySortCache.removeAll() cachedTableColumnTypes.removeAll() cachedTableColumnNames.removeAll() @@ -1310,7 +1307,8 @@ final class MainContentCoordinator { tabIndex < tabManager.tabs.count else { return } let tab = tabManager.tabs[tabIndex] - guard columnIndex >= 0 && columnIndex < tab.resultColumns.count else { return } + let buffer = rowDataStore.buffer(for: tab.id) + guard columnIndex >= 0 && columnIndex < buffer.columns.count else { return } var currentSort = tab.sortState let newDirection: SortDirection = ascending ? .ascending : .descending @@ -1338,7 +1336,7 @@ final class MainContentCoordinator { // When more rows are available server-side, re-execute with ORDER BY // instead of sorting locally (we only have a partial result set) if tab.pagination.hasMoreRows { - let columnName = tab.resultColumns[columnIndex] + let columnName = buffer.columns[columnIndex] let direction = currentSort.columns.first?.direction == .ascending ? "ASC" : "DESC" let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) @@ -1355,11 +1353,11 @@ final class MainContentCoordinator { tabManager.tabs[tabIndex].sortState = currentSort tabManager.tabs[tabIndex].hasUserInteraction = true tabManager.tabs[tabIndex].pagination.reset() - let rows = tab.resultRows + let rows = buffer.rows let tabId = tab.id let schemaVersion = tab.schemaVersion let sortColumns = currentSort.columns - let colTypes = tab.columnTypes + let colTypes = buffer.columnTypes if rows.count > 1_000 { // Sort on background thread to avoid UI freeze @@ -1411,7 +1409,7 @@ final class MainContentCoordinator { let tabId = tab.id let capturedSort = currentSort let capturedQuery = tab.content.query - let capturedColumns = tab.resultColumns + let capturedColumns = buffer.columns confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in guard let self, confirmed, let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 1694c3736..914fe7e13 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -180,7 +180,7 @@ struct MainContentView: View { isPresented: dismissBinding, mode: .queryResults( connection: connectionWithCurrentDatabase, - rowBuffer: tab.rowBuffer, + rowBuffer: coordinator.rowDataStore.buffer(for: tab.id), suggestedFileName: fileName ) ) @@ -350,8 +350,9 @@ struct MainContentView: View { .onChange(of: tabManager.tabStructureVersion) { _, _ in handleStructureChange() } - .onChange(of: currentTab?.resultColumns) { _, newColumns in - handleColumnsChange(newColumns: newColumns) + .onChange(of: currentTab?.schemaVersion) { _, _ in + let columns = currentTab.map { coordinator.rowDataStore.buffer(for: $0.id).columns } + handleColumnsChange(newColumns: columns) } .task { handleConnectionStatusChange() } .onReceive( diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 5c778c5f0..4da3408fb 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -29,85 +29,90 @@ struct EvictionTests { return (coordinator, tabManager) } - private func addLoadedTab(to tabManager: QueryTabManager, tableName: String = "users") { + private func addLoadedTab( + to coordinator: MainContentCoordinator, + tabManager: QueryTabManager, + tableName: String = "users" + ) { tabManager.addTableTab(tableName: tableName) guard let index = tabManager.selectedTabIndex else { return } let rows = TestFixtures.makeRows(count: 10) - tabManager.tabs[index].rowBuffer.rows = rows - tabManager.tabs[index].rowBuffer.columns = ["id", "name", "email"] + let tabId = tabManager.tabs[index].id + let buffer = coordinator.rowDataStore.buffer(for: tabId) + buffer.rows = rows + buffer.columns = ["id", "name", "email"] tabManager.tabs[index].execution.lastExecutedAt = Date() } @Test("evictInactiveRowData evicts loaded tabs without pending changes") func evictsLoadedTabs() { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") + let tabId = tabManager.tabs[0].id + let buffer = coordinator.rowDataStore.buffer(for: tabId) - #expect(tabManager.tabs[0].resultRows.count == 10) - #expect(tabManager.tabs[0].rowBuffer.isEvicted == false) + #expect(buffer.rows.count == 10) + #expect(buffer.isEvicted == false) coordinator.evictInactiveRowData() - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) - #expect(tabManager.tabs[0].resultRows.isEmpty) + #expect(buffer.isEvicted == true) + #expect(buffer.rows.isEmpty) } @Test("evictInactiveRowData skips tabs with pending changes") func skipsTabsWithPendingChanges() { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - // Add a pending change tabManager.tabs[0].pendingChanges.deletedRowIndices = [0] coordinator.evictInactiveRowData() - // Should NOT be evicted because it has pending changes - #expect(tabManager.tabs[0].rowBuffer.isEvicted == false) - #expect(tabManager.tabs[0].resultRows.count == 10) + let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) + #expect(buffer.isEvicted == false) + #expect(buffer.rows.count == 10) } @Test("evictInactiveRowData skips already evicted tabs") func skipsAlreadyEvicted() { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - // Pre-evict - tabManager.tabs[0].rowBuffer.evict() - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) + let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) + buffer.evict() + #expect(buffer.isEvicted == true) - // Should not crash or change state coordinator.evictInactiveRowData() - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) + #expect(buffer.isEvicted == true) } @Test("evictInactiveRowData skips tabs with empty results") func skipsEmptyResults() { let (coordinator, tabManager) = makeCoordinator() tabManager.addTableTab(tableName: "empty_table") - // Don't add any rows — resultRows is empty coordinator.evictInactiveRowData() - // Should not evict (nothing to evict) - #expect(tabManager.tabs[0].rowBuffer.isEvicted == false) + let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) + #expect(buffer.isEvicted == false) } @Test("evictInactiveRowData preserves column metadata after eviction") func preservesMetadataAfterEviction() { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: tabManager, tableName: "users") + addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") coordinator.evictInactiveRowData() - #expect(tabManager.tabs[0].rowBuffer.columns == ["id", "name", "email"]) - #expect(tabManager.tabs[0].rowBuffer.isEvicted == true) + let buffer = coordinator.rowDataStore.buffer(for: tabManager.tabs[0].id) + #expect(buffer.columns == ["id", "name", "email"]) + #expect(buffer.isEvicted == true) } @Test("evictInactiveRowData with no tabs is no-op") func noTabsIsNoOp() { let (coordinator, _) = makeCoordinator() - // No tabs added — should not crash coordinator.evictInactiveRowData() } } diff --git a/TableProTests/Views/Main/TabEvictionTests.swift b/TableProTests/Views/Main/TabEvictionTests.swift index 2efb19cba..9ddb7fbb3 100644 --- a/TableProTests/Views/Main/TabEvictionTests.swift +++ b/TableProTests/Views/Main/TabEvictionTests.swift @@ -11,6 +11,7 @@ import Testing @testable import TablePro @Suite("Tab Eviction") +@MainActor struct TabEvictionTests { // MARK: - Helpers @@ -19,35 +20,44 @@ struct TabEvictionTests { (0.. QueryTab { + ) -> TestTab { var tab = QueryTab(id: id, title: "Test", query: "SELECT 1", tabType: tabType) tab.execution.lastExecutedAt = lastExecutedAt + let buffer: RowBuffer if rowCount > 0 { - let rows = makeTestRows(count: rowCount) - tab.rowBuffer = RowBuffer( - rows: rows, + buffer = RowBuffer( + rows: makeTestRows(count: rowCount), columns: ["col1"], columnTypes: [.text(rawType: "VARCHAR")] ) + } else { + buffer = RowBuffer() } + store.setBuffer(buffer, for: tab.id) if isEvicted { - tab.rowBuffer.evict() + buffer.evict() } if hasUnsavedChanges { tab.pendingChanges.deletedRowIndices = [0] } - return tab + return TestTab(tab: tab, buffer: buffer) } // MARK: - RowBuffer Eviction @@ -109,67 +119,72 @@ struct TabEvictionTests { @Test("Tabs with pending changes are excluded from eviction candidates") func tabsWithPendingChangesExcluded() { - let tab = makeTestTab( + let store = RowDataStore() + let entry = makeTestTab( + store: store, rowCount: 10, lastExecutedAt: Date(), hasUnsavedChanges: true ) - let isCandidate = !tab.rowBuffer.isEvicted - && !tab.resultRows.isEmpty - && tab.execution.lastExecutedAt != nil - && !tab.pendingChanges.hasChanges + let isCandidate = !entry.buffer.isEvicted + && !entry.buffer.rows.isEmpty + && entry.tab.execution.lastExecutedAt != nil + && !entry.tab.pendingChanges.hasChanges #expect(isCandidate == false) } @Test("Eviction candidate filter excludes active, evicted, empty, and unsaved tabs") func evictionCandidateFiltering() { + let store = RowDataStore() let activeId = UUID() - let tabA = makeTestTab(id: activeId, rowCount: 10, lastExecutedAt: Date()) - let tabB = makeTestTab(rowCount: 10, lastExecutedAt: Date(), isEvicted: true) - let tabC = makeTestTab(rowCount: 0, lastExecutedAt: Date()) - let tabD = makeTestTab(rowCount: 10, lastExecutedAt: Date(), hasUnsavedChanges: true) - let tabE = makeTestTab(rowCount: 10, lastExecutedAt: Date()) + let entryA = makeTestTab(store: store, id: activeId, rowCount: 10, lastExecutedAt: Date()) + let entryB = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date(), isEvicted: true) + let entryC = makeTestTab(store: store, rowCount: 0, lastExecutedAt: Date()) + let entryD = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date(), hasUnsavedChanges: true) + let entryE = makeTestTab(store: store, rowCount: 10, lastExecutedAt: Date()) let activeTabIds: Set = [activeId] - let allTabs = [tabA, tabB, tabC, tabD, tabE] - - let candidates = allTabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + let allEntries = [entryA, entryB, entryC, entryD, entryE] + + let candidates = allEntries.filter { + !activeTabIds.contains($0.tab.id) + && !$0.buffer.isEvicted + && !$0.buffer.rows.isEmpty + && $0.tab.execution.lastExecutedAt != nil + && !$0.tab.pendingChanges.hasChanges } #expect(candidates.count == 1) - #expect(candidates.first?.id == tabE.id) + #expect(candidates.first?.tab.id == entryE.tab.id) } // MARK: - Budget-Based Eviction @Test("Eviction keeps the 2 most recently executed inactive tabs") func evictionKeepsTwoMostRecent() { + let store = RowDataStore() let now = Date() - let tabs = (0..<5).map { i in + let entries = (0..<5).map { i in makeTestTab( + store: store, rowCount: 10, lastExecutedAt: now.addingTimeInterval(Double(i) * 60) ) } let activeTabIds: Set = [] - let candidates = tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + let candidates = entries.filter { + !activeTabIds.contains($0.tab.id) + && !$0.buffer.isEvicted + && !$0.buffer.rows.isEmpty + && $0.tab.execution.lastExecutedAt != nil + && !$0.tab.pendingChanges.hasChanges } let sorted = candidates.sorted { - ($0.execution.lastExecutedAt ?? .distantFuture) < ($1.execution.lastExecutedAt ?? .distantFuture) + ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) } let maxInactiveLoaded = 2 @@ -177,45 +192,47 @@ struct TabEvictionTests { #expect(toEvict.count == 3) - for tab in toEvict { - tab.rowBuffer.evict() + for entry in toEvict { + entry.buffer.evict() } - let evictedIds = Set(toEvict.map(\.id)) + let evictedIds = Set(toEvict.map(\.tab.id)) // The 2 newest (index 3 and 4) should NOT be evicted - #expect(!evictedIds.contains(tabs[3].id)) - #expect(!evictedIds.contains(tabs[4].id)) + #expect(!evictedIds.contains(entries[3].tab.id)) + #expect(!evictedIds.contains(entries[4].tab.id)) // The 3 oldest (index 0, 1, 2) should be evicted - #expect(tabs[0].rowBuffer.isEvicted == true) - #expect(tabs[1].rowBuffer.isEvicted == true) - #expect(tabs[2].rowBuffer.isEvicted == true) - #expect(tabs[3].rowBuffer.isEvicted == false) - #expect(tabs[4].rowBuffer.isEvicted == false) + #expect(entries[0].buffer.isEvicted == true) + #expect(entries[1].buffer.isEvicted == true) + #expect(entries[2].buffer.isEvicted == true) + #expect(entries[3].buffer.isEvicted == false) + #expect(entries[4].buffer.isEvicted == false) } @Test("No tabs evicted when candidates are within budget") func noEvictionWithinBudget() { + let store = RowDataStore() let now = Date() - let tabs = (0..<2).map { i in + let entries = (0..<2).map { i in makeTestTab( + store: store, rowCount: 10, lastExecutedAt: now.addingTimeInterval(Double(i) * 60) ) } let activeTabIds: Set = [] - let candidates = tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.execution.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges + let candidates = entries.filter { + !activeTabIds.contains($0.tab.id) + && !$0.buffer.isEvicted + && !$0.buffer.rows.isEmpty + && $0.tab.execution.lastExecutedAt != nil + && !$0.tab.pendingChanges.hasChanges } let sorted = candidates.sorted { - ($0.execution.lastExecutedAt ?? .distantFuture) < ($1.execution.lastExecutedAt ?? .distantFuture) + ($0.tab.execution.lastExecutedAt ?? .distantFuture) < ($1.tab.execution.lastExecutedAt ?? .distantFuture) } let maxInactiveLoaded = 2 @@ -223,10 +240,9 @@ struct TabEvictionTests { #expect(shouldEvict == false) - // Verify no tabs were evicted - for tab in tabs { - #expect(tab.rowBuffer.isEvicted == false) - #expect(tab.resultRows.count == 10) + for entry in entries { + #expect(entry.buffer.isEvicted == false) + #expect(entry.buffer.rows.count == 10) } } } From 79191f9f8b2134e474443cadee5f90619f97e3f3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 01:13:36 +0700 Subject: [PATCH 05/10] fix(perf): correct cachedRowCount ordering and remove tabStructureVersion double-bump - DataGridCoordinator.applyInsertedRows / applyRemovedRows / applyFullReplace now call updateCache() before mutating the table view, so numberOfRows(in:) returns the post-mutation count when NSTableView synchronously validates. - QueryTabManager: drop the explicit tabStructureVersion bump from each add* method. The didSet on tabs already bumps when the ID array changes, and the explicit bump made every add count as 2 increments. replaceTabContent keeps its bump because same-id mutation does not change the ID array. --- CHANGELOG.md | 6 ++++++ TablePro/Models/Query/QueryTabManager.swift | 7 ------- TablePro/Views/Results/DataGridCoordinator.swift | 6 +++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5411ff3..637662915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OpenSSL shared as dylib across app and plugins, saving ~15MB in bundle size - Data grid uses single cell reuse identifier with typed stored properties instead of 3 identifiers and viewWithTag - Boolean dropdown menu includes Set NULL option for nullable columns +- Tab persistence triggers on a structural counter, not on every tabs write. Cell edits, row mutations, and per-keystroke query text no longer invoke disk I/O. +- Inspector sidebar edit state runs inside the existing 50ms debounce instead of synchronously per row click. +- Row add, delete, duplicate, undo, redo, and paste drive NSTableView insertRows / removeRows directly through the data grid delegate. SwiftUI no longer re-evaluates the editor view tree on row mutations. +- QueryTab.resultVersion split: schemaVersion (column shape) on QueryTab, row mutations through delegate deltas, sort completion through a single delegate replace call. Pin toggle, sort completion, and applyMultiStatementResults no longer fan out a redundant reload signal. +- Row data lives in a per-coordinator RowDataStore keyed by tab.id rather than on QueryTab itself, so SwiftUI's @Observable tracking on tabManager.tabs no longer fires for row writes. +- DataGridConfiguration is Equatable; DataGridIdentity covers tabType, tableName, and primaryKeyColumns so updateNSView short-circuits when nothing structural changed. DataTabGridDelegate properties are wired in onAppear / onChange instead of in the body. ### Fixed diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 07acc5f91..86e352556 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -92,7 +92,6 @@ final class QueryTabManager { } tabs.append(newTab) selectedTabId = newTab.id - tabStructureVersion += 1 } func addTableTab( @@ -122,7 +121,6 @@ final class QueryTabManager { newTab.tableContext.databaseName = databaseName tabs.append(newTab) selectedTabId = newTab.id - tabStructureVersion += 1 } func addCreateTableTab(databaseName: String = "") { @@ -133,7 +131,6 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id - tabStructureVersion += 1 } func addERDiagramTab(schemaKey: String, databaseName: String = "") { @@ -145,7 +142,6 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id - tabStructureVersion += 1 } func addServerDashboardTab() { @@ -159,7 +155,6 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id - tabStructureVersion += 1 } func addTerminalTab(databaseName: String = "") { @@ -174,7 +169,6 @@ final class QueryTabManager { newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id - tabStructureVersion += 1 } func addPreviewTableTab( @@ -198,7 +192,6 @@ final class QueryTabManager { newTab.isPreview = true tabs.append(newTab) selectedTabId = newTab.id - tabStructureVersion += 1 } /// Replace the currently selected tab's content with a new table. diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 3daa7a86e..e13c0f63b 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -243,24 +243,24 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyInsertedRows(_ indices: IndexSet) { guard let tableView else { return } rebuildVisualStateCache() - tableView.insertRows(at: indices, withAnimation: .slideDown) updateCache() + tableView.insertRows(at: indices, withAnimation: .slideDown) lastIdentity = nil } func applyRemovedRows(_ indices: IndexSet) { guard let tableView else { return } rebuildVisualStateCache() - tableView.removeRows(at: indices, withAnimation: .slideUp) updateCache() + tableView.removeRows(at: indices, withAnimation: .slideUp) lastIdentity = nil } func applyFullReplace() { guard let tableView else { return } rebuildVisualStateCache() - tableView.reloadData() updateCache() + tableView.reloadData() lastIdentity = nil } From d28e6548b44ffbeb4cfb8535b037d2189d9494a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 01:17:14 +0700 Subject: [PATCH 06/10] docs: remove internal datagrid refactor design doc --- docs/development/datagrid-refactor-design.md | 283 ------------------- 1 file changed, 283 deletions(-) delete mode 100644 docs/development/datagrid-refactor-design.md diff --git a/docs/development/datagrid-refactor-design.md b/docs/development/datagrid-refactor-design.md deleted file mode 100644 index 8d2b2c8c0..000000000 --- a/docs/development/datagrid-refactor-design.md +++ /dev/null @@ -1,283 +0,0 @@ -# DataGrid Performance Refactor — Design Document - -Branch: refactor/delegate-dispatch (worktree datagrid-perf-arch) -Scope: Phases A through F, single PR - -## Canonical Signal Taxonomy - -| Signal | Type | Owner | Semantics | -|---|---|---|---| -| schemaVersion | Int on QueryTab | QueryTab | Columns changed: new query result with different column names, types, or count. Bumped by applyPhase1Result. Replaces resultVersion for schema-only concerns. | -| tabStructureVersion | Int on QueryTabManager | QueryTabManager | Tab list changed: add, remove, rename, title update, user-initiated query set. Drives persistence (debounced 300ms). | -| changeManager.reloadVersion | Int on DataChangeManager | DataChangeManager | Cell edit recorded or change state cleared. Drives per-row NSTableView reload. | -| Delegate row-delta calls | DataGridViewDelegate methods | DataGridViewDelegate | Row shape changed: addRow, deleteRows, insertRows. Drives insertRows(at:withAnimation:) / removeRows(at:withAnimation:) directly on NSTableView. No SwiftUI re-eval. | -| selectionState.indices | GridSelectionState | GridSelectionState | Row selection changed. Already isolated. | -| paginationVersion | Int on QueryTab | QueryTab | Page changed. Already correct. | -| metadataVersion | Int on QueryTab | QueryTab | FK / column-type metadata arrived. Already correct. | -| RightPanelState | @Observable class | RightPanelState | Inspector context updated. Already isolated. | - -Signals removed: resultVersion as row-mutation counter; onChange(of: tabManager.tabs) driving persistence; queryTextBinding triggering persistence per keystroke. - -## Ordering Rationale - -A + B in parallel: independent. C requires A (persistence already decoupled from tabs writes) and B (inspectorTrigger already cleaned up). D requires C (DataGridIdentity is now keyed on schemaVersion). E requires D (delegate-delta path wired so RowDataStore can notify NSTableView directly). F always last. - -## Phase A — Persistence Decoupling - -### Goal -Tab persistence triggers only on structural tab changes, not row mutations or per-keystroke text edits. - -### Changes - -**`TablePro/Models/Query/QueryTabManager.swift`** -- Add `var tabStructureVersion: Int = 0` -- In each of `addTab`, `addTableTab` (new-tab path only), `addCreateTableTab`, `addERDiagramTab`, `addServerDashboardTab` (new path only), `addTerminalTab` (new path only), `addPreviewTableTab`, `replaceTabContent`: bump `tabStructureVersion += 1` after `selectedTabId = newTab.id`. -- Add `func markTabRenamed(_ tabId: UUID) { tabStructureVersion += 1 }` -- Do NOT bump in `updateTab` (used by result application paths). -- Audit removeTab/closeTab: must bump. - -**`TablePro/Views/Main/Child/MainEditorContentView.swift`** -- Lines 129–141 `.onChange(of: tabManager.tabIds)` — replace with `.onChange(of: tabManager.tabStructureVersion)`. Same body. -- `queryTextBinding` lines 356–361 — REMOVE the saveLastQuery block. Per-keystroke persistence is eliminated. - -**`TablePro/Views/Main/MainContentView.swift`** -- Line 351 `.onChange(of: tabManager.tabs)` — replace with `.onChange(of: tabManager.tabStructureVersion)` calling new `handleStructureChange()`. - -**`TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift`** -- Remove `handleTabsChange(_:)`. -- Add `handleStructureChange()` body: window title update, preview promotion, `coordinator.persistence.saveNow(...)` with persistableTabs. - -**`TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift`** -- If `saveLastQuery(_:)` only called from queryTextBinding, remove it. Otherwise, keep but ensure callers are intentional (tab switch, window close). -- saveNow itself debounces internally via 300ms; if not, add a `pendingSaveTask: Task?` and 300ms sleep before encoding. - -### Risk Callouts -- Loss-on-crash: bump `tabStructureVersion` inside `runQuery()` so every executed query is a structural event. This catches "user committed work." -- Audit all callers of saveLastQuery before removing. - -## Phase B — Inspector Decoupling - -### Goal -updateSidebarEditState runs inside the 50ms debounce. coordinator.tableMetadata removed from inspectorTrigger. - -### Changes - -**`TablePro/Views/Main/Extensions/MainContentView+Bindings.swift`** -- `inspectorTrigger` (lines 110–117): drop `metadataTableName` field. Drop `coordinator.tableMetadata?.tableName` read. -- `InspectorTrigger` struct (line 125): drop `metadataTableName: String?`. - -**`TablePro/Views/Main/Extensions/MainContentView+Helpers.swift`** -- `scheduleInspectorUpdate(...)` (lines 65–76): move `updateSidebarEditState()` call INSIDE the Task body, after `Task.sleep`. Currently runs synchronously before the debounce. - -**`TablePro/Views/Main/MainContentView.swift`** -- Existing `.task(id: currentTab?.tableContext.tableName)` block: extend to call `scheduleInspectorUpdate()` after `loadTableMetadataIfNeeded()` returns. This replaces the implicit observation of coordinator.tableMetadata via inspectorTrigger. - -### Risk Callouts -- 50ms lag on inspector field updates after row click — already true for inspector context; making sidebar edit state match is correct. - -## Phase C — Version Split + Signal Cleanup - -### Goal -Replace QueryTab.resultVersion with QueryTab.schemaVersion (column shape only). Remove row-mutation counter entirely. Row operations drive NSTableView via insertRows/removeRows through DataGridViewDelegate. - -### Changes - -**`TablePro/Models/Query/QueryTab.swift`** -- Line 67: rename `var resultVersion: Int` → `var schemaVersion: Int`. -- Lines 94, 128: rename in initializers. -- Lines 193–207 `static func ==`: rename comparison. - -**`TablePro/Views/Results/DataGridViewDelegate.swift`** (or wherever the protocol lives) -- Add three optional methods to protocol: - - `func dataGridDidInsertRows(at indices: IndexSet)` - - `func dataGridDidRemoveRows(at indices: IndexSet)` - - `func dataGridDidReplaceAllRows()` -- Provide default empty implementations in extension. - -**TableViewCoordinator (in DataGridView.swift or sibling)** -- Add `applyInsertedRows(_ indices: IndexSet)` calling `tableView.insertRows(at: indices, withAnimation: .slideDown)` -- Add `applyRemovedRows(_ indices: IndexSet)` calling `tableView.removeRows(at: indices, withAnimation: .slideUp)` -- Add `applyFullReplace()` calling `tableView.reloadData()` -- After each delta call, also call existing `updateCache()` so cachedRowCount stays in sync. - -**`TablePro/Views/Main/Child/DataTabGridDelegate.swift`** -- Implement the three new protocol methods, forwarding to coordinator's apply* methods. - -**`TablePro/Views/Main/MainContentCoordinator.swift`** -- Add `@ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate?` -- Wire in MainEditorContentView.onAppear; clear in onTeardown. - -**`TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift`** -- Line 30 addNewRow: remove `resultVersion += 1`. Add `dataTabDelegate?.dataGridDidInsertRows(at: IndexSet(integer: result.rowIndex))`. -- Line 53 deleteSelectedRows: remove bump. Add `dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(indices))`. -- Line 74 duplicateSelectedRow: remove bump. Add insertRows. -- Line 85 undoInsertRow: remove bump. Add removeRows. -- Line 99 undoLastChange: remove bump. Add `dataTabDelegate?.dataGridDidReplaceAllRows()` (conservative). -- Line 113 redoLastChange: remove bump. Add replaceAllRows. -- Line 171 pasteRows: remove bump. Add insertRows with multiple indices. - -**`TablePro/Views/Main/MainContentCoordinator.swift`** -- Lines 1395 and 1401: REMOVE `changeManager.reloadVersion += 1` from sort completion. Sort drives via querySortCache + provider rebuild via sortState mismatch. - -**`TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift`** -- Line 252: rename to `schemaVersion += 1`. -- Line 271: REMOVE the `changeManager.reloadVersion += 1` (double-signal). - -**`TablePro/Views/Main/Child/MainEditorContentView.swift`** -- Line 507 (pin toggle in resultTabBar): REMOVE the `resultVersion += 1`. Add a local `@State var displayRefreshToken: UUID = UUID()` toggled in onPin and use `.id(displayRefreshToken)` on the relevant subview if needed. -- Line 165 `.onChange(of: tabManager.selectedTab?.resultVersion)`: rename to schemaVersion. -- `RowProviderCacheEntry` (lines 23): rename resultVersion → schemaVersion. -- `cacheRowProvider`, `rowProvider(for:)`: rename version checks. -- DataGridView call site (lines 543–547): rename parameter resultVersion → schemaVersion. - -**`TablePro/Views/Results/DataGridView.swift`** -- Line 67 `var resultVersion: Int = 0`: rename to schemaVersion. -- Lines 38–61 `DataGridIdentity` struct: rename field. -- Lines 239–248 identity construction: rename. - -**`TablePro/Views/Main/MainContentCoordinator.swift`** applyPhase1Result -- Find the `updatedTab.resultVersion += 1` call. Rename to schemaVersion. - -### Migration Sequence within Phase C -1. Rename resultVersion → schemaVersion across all files. -2. Add delegate protocol methods + extension defaults. -3. Implement TableViewCoordinator apply* methods. -4. Implement DataTabGridDelegate forwarding. -5. Add coordinator's weak dataTabDelegate reference. -6. Replace each row op's resultVersion bump with the appropriate delegate call. -7. Remove sort-completion reloadVersion bumps. -8. Remove pin-toggle resultVersion bump. -9. Remove double-signal in applyMultiStatementResults. - -### Risk Callouts -- Row index ordering for removeRows: must use pre-deletion indices, sorted descending if mutating in single call. Verify rowOperationsManager returns correct sets. -- After each delta, update coordinator.cachedRowCount. -- Invalidate querySortCache on row add/delete (cache holds stale indices otherwise). -- Phase 2 metadata path still uses metadataVersion — separate from schemaVersion. - -## Phase D — DataGridConfiguration Equatable + Body Cleanup - -### Goal -DataGridConfiguration : Equatable; updateNSView short-circuits on equal config. Remove imperative dataTabDelegate writes from MainEditorContentView body. - -### Changes - -**`TablePro/Views/Results/DataGridConfiguration.swift`** (or wherever defined) -- Add `: Equatable`. Verify all fields are Equatable (DatabaseType is String-based; should already conform). - -**`TablePro/Views/Results/DataGridView.swift`** -- DataGridIdentity: add `tabType: TabType` field if missing. -- updateNSView identity guard: no logic change; now correctly covers all relevant config fields. - -**`TablePro/Views/Main/Child/MainEditorContentView.swift`** -- Lines 530–541: REMOVE the `let _ = { ... }()` imperative block. -- Stable refs (coordinator, columnVisibilityManager, selectionState, editingCell, onSort, onFilterColumn, onRefresh): set once in onAppear. -- Mutable refs that depend on isEditable (onCellEdit, onAddRow, onUndoInsert): set via `.onChange(of: tabManager.selectedTab?.tableContext.isEditable)` and any other dependent properties. -- Delegate computes `shouldShowEmptySpaceMenu` itself from coordinator instead of receiving via closure. - -### Risk Callouts -- DatabaseType Equatable conformance: verify it's a String-based struct (it is per CLAUDE.md). -- Delegate property may be stale for one render frame after isEditable change; use `.onChange(initial: true)` if needed. - -## Phase E — Move Row Data Out of QueryTab into RowDataStore - -### Goal -Row data lives in RowDataStore keyed by tab.id. QueryTab becomes pure metadata. SwiftUI never sees row mutations. - -### New Type - -**New file: `TablePro/Core/Services/Query/RowDataStore.swift`** - -``` -@MainActor @Observable -final class RowDataStore { - private var store: [UUID: RowBuffer] = [:] - func buffer(for tabId: UUID) -> RowBuffer - func setBuffer(_ buffer: RowBuffer, for tabId: UUID) - func removeBuffer(for tabId: UUID) - func evict(for tabId: UUID) - func evictAll(except activeTabId: UUID?) -} -``` - -@ObservationIgnored on `store` so SwiftUI does not observe individual buffer changes through this dictionary. Reads/writes go through methods. - -### Changes - -**`TablePro/Views/Main/MainContentCoordinator.swift`** -- Add `let rowDataStore = RowDataStore()`. -- Pass through to MainEditorContentView as new parameter. - -**`TablePro/Models/Query/QueryTab.swift`** -- Remove `var rowBuffer: RowBuffer`. -- Remove all proxy properties: resultColumns, columnTypes, columnDefaults, columnForeignKeys, columnEnumValues, columnNullable, resultRows. -- Keep schemaVersion, metadataVersion, paginationVersion, content, display, execution, filterState, sortState, columnLayout, tableContext. -- Update init(from persisted:) — no rowBuffer init. -- Update `==`: remove row-related field checks. - -**Replace all `tab.rowBuffer` and proxy reads** across: -- MainContentCoordinator+RowOperations.swift (writes too) -- MainContentCoordinator+MultiStatement.swift (applyMultiStatementResults: write columns/rows to rowDataStore.buffer(for: tabId)) -- MainContentCoordinator+LoadMore.swift (loadMoreRows / performFetchAll: append to buffer in store) -- MainContentView+EventHandlers.swift (updateSidebarEditState) -- MainContentView+Bindings.swift (selectedRowDataForSidebar) -- MainContentView+Helpers.swift (buildQueryResultsSummary) -- MainEditorContentView.swift (makeRowProvider, sortIndicesForTab, resultsSection, JSON view) -- Export paths in MainContentView.swift - -with `coordinator.rowDataStore.buffer(for: tab.id)` accesses. - -**`TablePro/Models/Query/QueryTabManager.swift`** -- replaceTabContent: remove `tab.rowBuffer = RowBuffer()`. Caller must clear via `coordinator.rowDataStore.setBuffer(RowBuffer(), for: selectedId)`. - -**Eviction** -- evictInactiveRowData → delegate to `rowDataStore.evictAll(except: tabManager.selectedTabId)`. -- teardown → `rowDataStore.evictAll(except: nil)`. - -### Migration Sequence -1. Create RowDataStore.swift. -2. Add to coordinator + pass into view. -3. Migrate write paths (applyPhase1Result, applyMultiStatementResults, RowOperations, LoadMore). -4. Migrate read paths (sidebar, json, sort, provider). -5. Remove rowBuffer + proxies from QueryTab. -6. Update teardown / eviction. - -### Risk Callouts -- ResultSet buffers (tab.display.resultSets[i].rowBuffer) — out of scope for Phase E. Keep ResultSet local row data as-is. -- InMemoryRowProvider holds RowBuffer reference — must be invalidated on schemaVersion bump (already covered by RowProviderCacheEntry.schemaVersion check). -- PersistedTab does not reference rowBuffer — verify. -- All row-data access is on @MainActor — coordinator extensions already are. - -## Phase F — Build, Lint, CHANGELOG, Manual Verify - -### Commands -``` -xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation -swiftlint lint --strict -``` - -### CHANGELOG.md (Unreleased > Changed) -- DataGrid persistence triggers only on structural tab changes (add/remove/rename), not on row mutations or keystrokes -- Inspector sidebar edit state updates inside 50ms debounce instead of immediately on every result version change -- DataGrid row operations (add/delete/duplicate/paste/undo/redo) use NSTableView insertRows/removeRows instead of full reloadData -- Row data moved out of QueryTab @Observable array into RowDataStore, eliminating SwiftUI observation cycles for row mutations -- Removed spurious resultVersion bumps from pin toggle, sort completion, and multi-statement double-signal - -### Manual checklist -1. Connect to test DB; open table with 500+ rows. -2. Add/delete/duplicate/paste rows: verify NSTableView animations, no full reload. -3. Cell edit + save + undo + redo. -4. Sort large query result: no flicker. -5. Pin result set: data grid stays stable. -6. Multi-statement query: results appear once. -7. Switch tables rapidly: no stale data. -8. Type 100 chars: zero persistence I/O. -9. Tab restoration after close/reopen. -10. Inspector: 50ms debounced field display. - -## Cross-Cutting - -- No comments in source. No backward-compat shims. Native macOS only. -- New user-facing strings: none in this refactor. -- swiftlint --strict must pass. -- Tests: QueryTabManager tabStructureVersion increments correctly; RowDataStore CRUD; DataGridViewDelegate delta methods called with correct indices. From 2a659e2754b4ec982f1567afef84da8ecfc67bd4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 01:45:12 +0700 Subject: [PATCH 07/10] test(perf): cover refactor with unit tests, extract RowDeltaApplying protocol - New protocol RowDeltaApplying captures the row-delta surface (applyInsertedRows/applyRemovedRows/applyFullReplace) so DataTabGridDelegate can be tested with a fake without standing up an NSTableView. TableViewCoordinator conforms via empty extension. - DataTabGridDelegate.tableViewCoordinator switches to (any RowDeltaApplying)?. - Unit tests added: RowDataStore (10 cases), RowProviderCache (9 cases), QueryTabManager.tabStructureVersion semantics (10 cases), DataTabGridDelegate forwarding (4 cases). - Drop a stale comment in QueryTabManager.init() (CLAUDE.md no-comments rule). - Guard the selectedTabId .onChange against caching a provider over an evicted RowBuffer. --- TablePro/Models/Query/QueryTabManager.swift | 1 - .../Main/Child/DataTabGridDelegate.swift | 2 +- .../Main/Child/MainEditorContentView.swift | 1 + TablePro/Views/Results/RowDeltaApplying.swift | 10 ++ .../Services/Query/RowDataStoreTests.swift | 151 ++++++++++++++++++ .../Query/TabStructureVersionTests.swift | 131 +++++++++++++++ .../Main/Child/DataTabGridDelegateTests.swift | 86 ++++++++++ .../Views/Results/RowProviderCacheTests.swift | 135 ++++++++++++++++ 8 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 TablePro/Views/Results/RowDeltaApplying.swift create mode 100644 TableProTests/Core/Services/Query/RowDataStoreTests.swift create mode 100644 TableProTests/Models/Query/TabStructureVersionTests.swift create mode 100644 TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift create mode 100644 TableProTests/Views/Results/RowProviderCacheTests.swift diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 86e352556..72e40a731 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -46,7 +46,6 @@ final class QueryTabManager { } init() { - // Start with no tabs - shows empty state tabs = [] selectedTabId = nil } diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 79aec94c6..5db4a7248 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -111,7 +111,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { return menu } - weak var tableViewCoordinator: TableViewCoordinator? + weak var tableViewCoordinator: (any RowDeltaApplying)? func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { self.tableViewCoordinator = tableViewCoordinator diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 3eb931bb0..963b063df 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -135,6 +135,7 @@ struct MainEditorContentView: View { updateHasQueryText() guard let tab = tabManager.selectedTab else { return } + guard !coordinator.rowDataStore.buffer(for: tab.id).isEvicted else { return } if providerCache.provider( for: tab.id, schemaVersion: tab.schemaVersion, diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/RowDeltaApplying.swift new file mode 100644 index 000000000..2b3a225d8 --- /dev/null +++ b/TablePro/Views/Results/RowDeltaApplying.swift @@ -0,0 +1,10 @@ +import Foundation + +@MainActor +protocol RowDeltaApplying: AnyObject { + func applyInsertedRows(_ indices: IndexSet) + func applyRemovedRows(_ indices: IndexSet) + func applyFullReplace() +} + +extension TableViewCoordinator: RowDeltaApplying {} diff --git a/TableProTests/Core/Services/Query/RowDataStoreTests.swift b/TableProTests/Core/Services/Query/RowDataStoreTests.swift new file mode 100644 index 000000000..3a7883426 --- /dev/null +++ b/TableProTests/Core/Services/Query/RowDataStoreTests.swift @@ -0,0 +1,151 @@ +// +// RowDataStoreTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("RowDataStore") +@MainActor +struct RowDataStoreTests { + + @Test("buffer(for:) creates an empty RowBuffer on first access and returns the same instance after") + func bufferCreatesAndReturnsSameInstance() { + let store = RowDataStore() + let tabId = UUID() + + let first = store.buffer(for: tabId) + #expect(first.rows.isEmpty) + #expect(first.columns.isEmpty) + #expect(first.isEvicted == false) + + let second = store.buffer(for: tabId) + #expect(ObjectIdentifier(first) == ObjectIdentifier(second)) + } + + @Test("setBuffer(_:for:) replaces the buffer for a tab id") + func setBufferReplacesEntry() { + let store = RowDataStore() + let tabId = UUID() + + let original = store.buffer(for: tabId) + let replacement = RowBuffer(rows: [["a"]], columns: ["c"]) + store.setBuffer(replacement, for: tabId) + + let resolved = store.buffer(for: tabId) + #expect(ObjectIdentifier(resolved) == ObjectIdentifier(replacement)) + #expect(ObjectIdentifier(resolved) != ObjectIdentifier(original)) + } + + @Test("existingBuffer(for:) returns nil before storage and the stored buffer afterwards") + func existingBufferReflectsState() { + let store = RowDataStore() + let tabId = UUID() + + #expect(store.existingBuffer(for: tabId) == nil) + + let buffer = RowBuffer(rows: [["x"]], columns: ["c"]) + store.setBuffer(buffer, for: tabId) + + let resolved = store.existingBuffer(for: tabId) + #expect(resolved != nil) + #expect(resolved.map(ObjectIdentifier.init) == ObjectIdentifier(buffer)) + } + + @Test("removeBuffer(for:) deletes the entry") + func removeBufferDeletes() { + let store = RowDataStore() + let tabId = UUID() + + store.setBuffer(RowBuffer(rows: [["x"]], columns: ["c"]), for: tabId) + #expect(store.existingBuffer(for: tabId) != nil) + + store.removeBuffer(for: tabId) + #expect(store.existingBuffer(for: tabId) == nil) + } + + @Test("evict(for:) calls evict on the stored buffer") + func evictMarksBuffer() { + let store = RowDataStore() + let tabId = UUID() + let buffer = RowBuffer(rows: [["a"], ["b"]], columns: ["c"]) + store.setBuffer(buffer, for: tabId) + + #expect(buffer.isEvicted == false) + store.evict(for: tabId) + + #expect(buffer.isEvicted == true) + #expect(buffer.rows.isEmpty) + } + + @Test("evict(for:) is a no-op for unknown tab ids") + func evictUnknownTabIsNoOp() { + let store = RowDataStore() + store.evict(for: UUID()) + } + + @Test("evictAll(except:) evicts every other tab and spares the active one") + func evictAllSparesActive() { + let store = RowDataStore() + let activeId = UUID() + let otherId1 = UUID() + let otherId2 = UUID() + + let activeBuffer = RowBuffer(rows: [["a"]], columns: ["c"]) + let otherBuffer1 = RowBuffer(rows: [["b"]], columns: ["c"]) + let otherBuffer2 = RowBuffer(rows: [["d"]], columns: ["c"]) + + store.setBuffer(activeBuffer, for: activeId) + store.setBuffer(otherBuffer1, for: otherId1) + store.setBuffer(otherBuffer2, for: otherId2) + + store.evictAll(except: activeId) + + #expect(activeBuffer.isEvicted == false) + #expect(activeBuffer.rows.count == 1) + #expect(otherBuffer1.isEvicted == true) + #expect(otherBuffer1.rows.isEmpty) + #expect(otherBuffer2.isEvicted == true) + #expect(otherBuffer2.rows.isEmpty) + } + + @Test("evictAll(except: nil) evicts every loaded tab") + func evictAllNoActiveEvictsAll() { + let store = RowDataStore() + let buffer1 = RowBuffer(rows: [["a"]], columns: ["c"]) + let buffer2 = RowBuffer(rows: [["b"]], columns: ["c"]) + store.setBuffer(buffer1, for: UUID()) + store.setBuffer(buffer2, for: UUID()) + + store.evictAll(except: nil) + + #expect(buffer1.isEvicted == true) + #expect(buffer2.isEvicted == true) + } + + @Test("evictAll(except:) skips empty buffers") + func evictAllSkipsEmpty() { + let store = RowDataStore() + let emptyBuffer = RowBuffer() + store.setBuffer(emptyBuffer, for: UUID()) + + store.evictAll(except: nil) + #expect(emptyBuffer.isEvicted == false) + } + + @Test("tearDown() clears the store") + func tearDownClearsAll() { + let store = RowDataStore() + let tabId1 = UUID() + let tabId2 = UUID() + store.setBuffer(RowBuffer(rows: [["a"]], columns: ["c"]), for: tabId1) + store.setBuffer(RowBuffer(rows: [["b"]], columns: ["c"]), for: tabId2) + + store.tearDown() + + #expect(store.existingBuffer(for: tabId1) == nil) + #expect(store.existingBuffer(for: tabId2) == nil) + } +} diff --git a/TableProTests/Models/Query/TabStructureVersionTests.swift b/TableProTests/Models/Query/TabStructureVersionTests.swift new file mode 100644 index 000000000..1fef3e3ea --- /dev/null +++ b/TableProTests/Models/Query/TabStructureVersionTests.swift @@ -0,0 +1,131 @@ +// +// TabStructureVersionTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("QueryTabManager.tabStructureVersion") +@MainActor +struct TabStructureVersionTests { + + @Test("New manager starts at version 0") + func initialVersionIsZero() { + let manager = QueryTabManager() + #expect(manager.tabStructureVersion == 0) + } + + @Test("addTab(...) bumps the version once") + func addTabBumpsOnce() { + let manager = QueryTabManager() + let before = manager.tabStructureVersion + + manager.addTab(initialQuery: "SELECT 1", title: "Q") + + #expect(manager.tabStructureVersion == before + 1) + } + + @Test("addTableTab(...) for a new table bumps once; activating an existing table does NOT bump") + func addTableTabBumpsOnceAndIdempotent() { + let manager = QueryTabManager() + + manager.addTableTab(tableName: "users") + let afterFirstAdd = manager.tabStructureVersion + #expect(afterFirstAdd == 1) + + manager.addTableTab(tableName: "users") + + #expect(manager.tabStructureVersion == afterFirstAdd) + } + + @Test("addTerminalTab(...) for a new tab bumps once; activating existing terminal does NOT bump") + func addTerminalTabBumpsOnceAndIdempotent() { + let manager = QueryTabManager() + + manager.addTerminalTab() + let afterFirstAdd = manager.tabStructureVersion + #expect(afterFirstAdd == 1) + + manager.addTerminalTab() + + #expect(manager.tabStructureVersion == afterFirstAdd) + } + + @Test("addServerDashboardTab() for a new tab bumps once; activating existing does NOT bump") + func addServerDashboardBumpsOnceAndIdempotent() { + let manager = QueryTabManager() + + manager.addServerDashboardTab() + let afterFirstAdd = manager.tabStructureVersion + #expect(afterFirstAdd == 1) + + manager.addServerDashboardTab() + + #expect(manager.tabStructureVersion == afterFirstAdd) + } + + @Test("replaceTabContent(...) bumps the version (in-place mutation, same id)") + func replaceTabContentBumps() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + let beforeReplace = manager.tabStructureVersion + + let didReplace = manager.replaceTabContent(tableName: "orders") + + #expect(didReplace) + #expect(manager.tabStructureVersion == beforeReplace + 1) + } + + @Test("markTabRenamed bumps when the tab id exists; no-op when it does not") + func markTabRenamedBumpsOnlyForKnownIds() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + let knownId = manager.tabs[0].id + let before = manager.tabStructureVersion + + manager.markTabRenamed(knownId) + #expect(manager.tabStructureVersion == before + 1) + + let unknownVersion = manager.tabStructureVersion + manager.markTabRenamed(UUID()) + #expect(manager.tabStructureVersion == unknownVersion) + } + + @Test("updateTab(...) does NOT bump the version (content-only update)") + func updateTabDoesNotBump() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + var tab = manager.tabs[0] + let before = manager.tabStructureVersion + + tab.content.query = "SELECT 99" + manager.updateTab(tab) + + #expect(manager.tabStructureVersion == before) + } + + @Test("Mutating a tab's content directly via tabs[i] does NOT bump (id array unchanged)") + func directContentMutationDoesNotBump() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + let before = manager.tabStructureVersion + + manager.tabs[0].content.query = "SELECT * FROM users WHERE id = 1" + + #expect(manager.tabStructureVersion == before) + } + + @Test("Removing a tab via tabs.remove(at:) bumps via the didSet") + func tabsRemovalBumps() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + manager.addTableTab(tableName: "orders") + let before = manager.tabStructureVersion + + manager.tabs.remove(at: 0) + + #expect(manager.tabStructureVersion == before + 1) + } +} diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift new file mode 100644 index 000000000..3fd071b56 --- /dev/null +++ b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift @@ -0,0 +1,86 @@ +// +// DataTabGridDelegateTests.swift +// TableProTests +// + +import AppKit +import Foundation +import Testing +@testable import TablePro + +@MainActor +private final class FakeRowDeltaApplier: RowDeltaApplying { + var insertedCalls: [IndexSet] = [] + var removedCalls: [IndexSet] = [] + var fullReplaceCount: Int = 0 + + func applyInsertedRows(_ indices: IndexSet) { + insertedCalls.append(indices) + } + + func applyRemovedRows(_ indices: IndexSet) { + removedCalls.append(indices) + } + + func applyFullReplace() { + fullReplaceCount += 1 + } +} + +@Suite("DataTabGridDelegate row-delta forwarding") +@MainActor +struct DataTabGridDelegateTests { + + @Test("dataGridDidInsertRows(at:) forwards the IndexSet to applyInsertedRows") + func insertForwardsIndices() { + let delegate = DataTabGridDelegate() + let applier = FakeRowDeltaApplier() + delegate.tableViewCoordinator = applier + + let indices = IndexSet([1, 3, 5]) + delegate.dataGridDidInsertRows(at: indices) + + #expect(applier.insertedCalls.count == 1) + #expect(applier.insertedCalls.first == indices) + #expect(applier.removedCalls.isEmpty) + #expect(applier.fullReplaceCount == 0) + } + + @Test("dataGridDidRemoveRows(at:) forwards the IndexSet to applyRemovedRows") + func removeForwardsIndices() { + let delegate = DataTabGridDelegate() + let applier = FakeRowDeltaApplier() + delegate.tableViewCoordinator = applier + + let indices = IndexSet(integersIn: 4..<7) + delegate.dataGridDidRemoveRows(at: indices) + + #expect(applier.removedCalls.count == 1) + #expect(applier.removedCalls.first == indices) + #expect(applier.insertedCalls.isEmpty) + #expect(applier.fullReplaceCount == 0) + } + + @Test("dataGridDidReplaceAllRows() forwards to applyFullReplace") + func fullReplaceForwards() { + let delegate = DataTabGridDelegate() + let applier = FakeRowDeltaApplier() + delegate.tableViewCoordinator = applier + + delegate.dataGridDidReplaceAllRows() + + #expect(applier.fullReplaceCount == 1) + #expect(applier.insertedCalls.isEmpty) + #expect(applier.removedCalls.isEmpty) + } + + @Test("Calls are no-ops when tableViewCoordinator is nil") + func nilCoordinatorIsNoOp() { + let delegate = DataTabGridDelegate() + #expect(delegate.tableViewCoordinator == nil) + + delegate.dataGridDidInsertRows(at: IndexSet([0])) + delegate.dataGridDidRemoveRows(at: IndexSet([0])) + delegate.dataGridDidReplaceAllRows() + } +} diff --git a/TableProTests/Views/Results/RowProviderCacheTests.swift b/TableProTests/Views/Results/RowProviderCacheTests.swift new file mode 100644 index 000000000..8f57eb913 --- /dev/null +++ b/TableProTests/Views/Results/RowProviderCacheTests.swift @@ -0,0 +1,135 @@ +// +// RowProviderCacheTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("RowProviderCache") +@MainActor +struct RowProviderCacheTests { + + private func makeProvider(rows: [[String?]] = [["a"]]) -> InMemoryRowProvider { + InMemoryRowProvider(rows: rows, columns: ["c"]) + } + + private func makeSortState(columnIndex: Int = 0, direction: SortDirection = .ascending) -> SortState { + var state = SortState() + state.columns = [SortColumn(columnIndex: columnIndex, direction: direction)] + return state + } + + @Test("provider(for:) returns nil when the tab id is unknown") + func providerUnknownReturnsNil() { + let cache = RowProviderCache() + let resolved = cache.provider( + for: UUID(), + schemaVersion: 1, + metadataVersion: 1, + sortState: SortState() + ) + #expect(resolved == nil) + } + + @Test("After store(...), the same key returns the stored provider") + func storeRoundTrips() { + let cache = RowProviderCache() + let tabId = UUID() + let provider = makeProvider() + + cache.store(provider, for: tabId, schemaVersion: 2, metadataVersion: 3, sortState: SortState()) + + let resolved = cache.provider(for: tabId, schemaVersion: 2, metadataVersion: 3, sortState: SortState()) + #expect(resolved != nil) + #expect(resolved.map(ObjectIdentifier.init) == ObjectIdentifier(provider)) + } + + @Test("Different schemaVersion invalidates the cache hit") + func schemaVersionMismatchReturnsNil() { + let cache = RowProviderCache() + let tabId = UUID() + cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + + let resolved = cache.provider(for: tabId, schemaVersion: 2, metadataVersion: 1, sortState: SortState()) + #expect(resolved == nil) + } + + @Test("Different metadataVersion invalidates the cache hit") + func metadataVersionMismatchReturnsNil() { + let cache = RowProviderCache() + let tabId = UUID() + cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + + let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 99, sortState: SortState()) + #expect(resolved == nil) + } + + @Test("Different sortState invalidates the cache hit") + func sortStateMismatchReturnsNil() { + let cache = RowProviderCache() + let tabId = UUID() + let storedSort = makeSortState(columnIndex: 0, direction: .ascending) + cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: storedSort) + + let differentSort = makeSortState(columnIndex: 1, direction: .descending) + let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: differentSort) + #expect(resolved == nil) + } + + @Test("remove(for:) removes the entry") + func removeRemoves() { + let cache = RowProviderCache() + let tabId = UUID() + cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + + cache.remove(for: tabId) + + let resolved = cache.provider(for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + #expect(resolved == nil) + #expect(cache.isEmpty) + } + + @Test("retain(tabIds:) keeps only the listed tabs") + func retainKeepsListedOnly() { + let cache = RowProviderCache() + let keepId = UUID() + let dropId1 = UUID() + let dropId2 = UUID() + + cache.store(makeProvider(), for: keepId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + cache.store(makeProvider(), for: dropId1, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + cache.store(makeProvider(), for: dropId2, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + + cache.retain(tabIds: [keepId]) + + #expect(cache.provider(for: keepId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) != nil) + #expect(cache.provider(for: dropId1, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) == nil) + #expect(cache.provider(for: dropId2, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) == nil) + } + + @Test("removeAll() clears the cache") + func removeAllClears() { + let cache = RowProviderCache() + cache.store(makeProvider(), for: UUID(), schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + cache.store(makeProvider(), for: UUID(), schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + + cache.removeAll() + + #expect(cache.isEmpty) + } + + @Test("isEmpty reflects state across mutations") + func isEmptyReflectsState() { + let cache = RowProviderCache() + #expect(cache.isEmpty) + + let tabId = UUID() + cache.store(makeProvider(), for: tabId, schemaVersion: 1, metadataVersion: 1, sortState: SortState()) + #expect(!cache.isEmpty) + + cache.remove(for: tabId) + #expect(cache.isEmpty) + } +} From 512b38d7ba8bed90d80621c1229493894c443a78 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 02:20:15 +0700 Subject: [PATCH 08/10] fix(perf): use existingBuffer in eviction guard, add reorder test - MainEditorContentView: the selectedTabId onChange and onAppear hooks called rowDataStore.buffer(for:) inside the eviction guard. buffer(for:) creates an empty RowBuffer on miss, so the guard could leak ghost entries (e.g. on first switch to a tab that has not loaded yet, or on view re-appear after teardown). Use existingBuffer(for:) so the guard only runs when a buffer actually exists, and skip cacheRowProvider for fresh / evicted tabs. - TabStructureVersionTests: cover drag-reorder (tabs.swapAt) so the didSet ID-array-change check is regression-tested. --- .../Views/Main/Child/MainEditorContentView.swift | 9 ++++++--- .../Models/Query/TabStructureVersionTests.swift | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 963b063df..ff0d9d1b3 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -134,8 +134,9 @@ struct MainEditorContentView: View { .onChange(of: tabManager.selectedTabId) { _, _ in updateHasQueryText() - guard let tab = tabManager.selectedTab else { return } - guard !coordinator.rowDataStore.buffer(for: tab.id).isEvicted else { return } + guard let tab = tabManager.selectedTab, + let existing = coordinator.rowDataStore.existingBuffer(for: tab.id), + !existing.isEvicted else { return } if providerCache.provider( for: tab.id, schemaVersion: tab.schemaVersion, @@ -148,7 +149,9 @@ struct MainEditorContentView: View { .onAppear { updateHasQueryText() cachedChangeManager = AnyChangeManager(changeManager) - if let tab = tabManager.selectedTab { + if let tab = tabManager.selectedTab, + let existing = coordinator.rowDataStore.existingBuffer(for: tab.id), + !existing.isEvicted { cacheRowProvider(for: tab) } wireDataTabDelegateStableRefs() diff --git a/TableProTests/Models/Query/TabStructureVersionTests.swift b/TableProTests/Models/Query/TabStructureVersionTests.swift index 1fef3e3ea..c01323302 100644 --- a/TableProTests/Models/Query/TabStructureVersionTests.swift +++ b/TableProTests/Models/Query/TabStructureVersionTests.swift @@ -128,4 +128,17 @@ struct TabStructureVersionTests { #expect(manager.tabStructureVersion == before + 1) } + + @Test("Drag-reordering tabs (id array reordered) bumps via the didSet") + func tabsReorderBumps() { + let manager = QueryTabManager() + manager.addTableTab(tableName: "users") + manager.addTableTab(tableName: "orders") + manager.addTableTab(tableName: "products") + let before = manager.tabStructureVersion + + manager.tabs.swapAt(0, 2) + + #expect(manager.tabStructureVersion == before + 1) + } } From c0275e1637cecc555c9229e90647c781c3f19d6e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 02:38:44 +0700 Subject: [PATCH 09/10] refactor: drop dead ResultSet.resultVersion + saveLastQuery feature, fix tests Dead code removal: - ResultSet.resultVersion was write-only (3 sites set it, 0 sites read). Field gone, 5 dead writes removed in QueryHelpers, QueryParameters, MultiStatement, and two LoadMore paths. - TabPersistenceCoordinator.saveLastQuery was deleted in the persistence-decoupling phase but loadLastQuery was left orphaned. Drop loadLastQuery on the coordinator, drop saveLastQuery / loadLastQuery / lastQueryFileURL / lastQueryDirectory on TabDiskActor, drop the legacy lastQuery migration loop and key prefix. Per-keystroke crash recovery is gone; user-dispatched queries still bump tabStructureVersion so committed work persists. Test fixes: - TriggerStructTests: drop metadataTableName, rename resultVersion -> schemaVersion. Old signature was broken by phases B and C. - DataGridIdentityTests: convert hiddenColumns: argument to configuration: DataGridConfiguration. Phase D added tabType / tableName / primaryKeyColumns to the identity through the configuration init. - CommandActionsDispatchTests: drop selectedRowIndices binding, pass coordinator.selectionState. - MainStatusBarLayoutTests: pass StatusBarSnapshot instead of QueryTab. - SaveCompletionTests: drop selectedRowIndices inout from row op calls; assert on coordinator.selectionState.indices instead. - RowOperationsManagerTests: deleteSelectedRows now returns DeleteRowsResult; assert on .nextRowToSelect. - AnyChangeManagerTests: drop dataManager: / structureManager: argument labels (single any ChangeManaging init), and switch the changes property to rowChanges. - TabDiskActorTests + TabPersistenceCoordinatorTests: drop the lastQuery round-trip / nil / empty / whitespace / 500KB-cap tests since the feature is gone. --- .../TabPersistenceCoordinator.swift | 7 -- TablePro/Core/Storage/TabDiskActor.swift | 105 ++---------------- TablePro/Models/Query/ResultSet.swift | 1 - .../MainContentCoordinator+LoadMore.swift | 6 - ...ainContentCoordinator+MultiStatement.swift | 1 - .../MainContentCoordinator+QueryHelpers.swift | 1 - ...inContentCoordinator+QueryParameters.swift | 1 - .../AnyChangeManagerTests.swift | 22 ++-- .../Services/RowOperationsManagerTests.swift | 7 +- .../TabPersistenceCoordinatorTests.swift | 25 ----- .../Core/Storage/TabDiskActorTests.swift | 69 ------------ .../Main/CommandActionsDispatchTests.swift | 3 +- .../Views/Main/MainStatusBarLayoutTests.swift | 7 +- .../Views/Main/SaveCompletionTests.swift | 21 ++-- .../Views/Main/TriggerStructTests.swift | 32 +++--- .../Views/Results/DataGridIdentityTests.swift | 90 ++++++++++----- 16 files changed, 115 insertions(+), 283 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 7402d137d..3af4eb7ee 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -120,13 +120,6 @@ internal final class TabPersistenceCoordinator { ) } - // MARK: - Last Query - - /// Load the editor's last query text for this connection. - internal func loadLastQuery() async -> String? { - await TabDiskActor.shared.loadLastQuery(for: connectionId) - } - // MARK: - Private private func convertToPersistedTab(_ tab: QueryTab) -> PersistedTab { diff --git a/TablePro/Core/Storage/TabDiskActor.swift b/TablePro/Core/Storage/TabDiskActor.swift index 8be6a0c43..8a3a811d2 100644 --- a/TablePro/Core/Storage/TabDiskActor.swift +++ b/TablePro/Core/Storage/TabDiskActor.swift @@ -31,39 +31,26 @@ internal actor TabDiskActor { // MARK: - Legacy UserDefaults Keys (for migration) private static let legacyTabStateKeyPrefix = "com.TablePro.tabs." - private static let legacyLastQueryKeyPrefix = "com.TablePro.lastquery." private static let migrationCompleteKey = "com.TablePro.tabStateMigrationComplete" // MARK: - File Storage private let tabStateDirectory: URL - private let lastQueryDirectory: URL private let encoder: JSONEncoder private let decoder: JSONDecoder private init() { - tabStateDirectory = Self.resolvedTabStateDirectory() - - let baseDirectory = tabStateDirectory.deletingLastPathComponent() - lastQueryDirectory = baseDirectory.appendingPathComponent("LastQuery", isDirectory: true) - + let directory = Self.resolvedTabStateDirectory() + tabStateDirectory = directory encoder = JSONEncoder() decoder = JSONDecoder() - // Directory creation and migration run synchronously at init. - // Safe because init is the only caller and runs before any concurrent access. - let fm = FileManager.default - for directory in [tabStateDirectory, lastQueryDirectory] { - do { - try fm.createDirectory(at: directory, withIntermediateDirectories: true) - } catch { - Self.logger.error("Failed to create directory \(directory.path): \(error.localizedDescription)") - } + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + Self.logger.error("Failed to create directory \(directory.path): \(error.localizedDescription)") } - Self.performMigrationIfNeeded( - tabStateDirectory: tabStateDirectory, - lastQueryDirectory: lastQueryDirectory - ) + Self.performMigrationIfNeeded(tabStateDirectory: directory) } // MARK: - Public API @@ -111,52 +98,6 @@ internal actor TabDiskActor { } } - /// Save the last query text for a connection. Skips if query exceeds 500KB. - internal func saveLastQuery(_ query: String, for connectionId: UUID) { - guard (query as NSString).length < TabQueryContent.maxPersistableQuerySize else { return } - - let fileURL = lastQueryFileURL(for: connectionId) - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.isEmpty { - if FileManager.default.fileExists(atPath: fileURL.path) { - do { - try FileManager.default.removeItem(at: fileURL) - } catch { - Self.logger.error( - "Failed to remove last query for \(connectionId): \(error.localizedDescription)" - ) - } - } - } else { - do { - let data = Data(trimmed.utf8) - try data.write(to: fileURL, options: .atomic) - } catch { - Self.logger.error( - "Failed to save last query for \(connectionId): \(error.localizedDescription)" - ) - } - } - } - - /// Load the last query text for a connection. - internal func loadLastQuery(for connectionId: UUID) -> String? { - let fileURL = lastQueryFileURL(for: connectionId) - - guard FileManager.default.fileExists(atPath: fileURL.path) else { - return nil - } - - do { - let data = try Data(contentsOf: fileURL) - return String(data: data, encoding: .utf8) - } catch { - Self.logger.error("Failed to load last query for \(connectionId): \(error.localizedDescription)") - return nil - } - } - /// List all connection IDs that have saved tab state on disk. internal func connectionIdsWithSavedState() -> [UUID] { let fm = FileManager.default @@ -217,16 +158,12 @@ internal actor TabDiskActor { tabStateDirectory.appendingPathComponent("\(connectionId.uuidString).json") } - private func lastQueryFileURL(for connectionId: UUID) -> URL { - lastQueryDirectory.appendingPathComponent("\(connectionId.uuidString).txt") - } - // MARK: - Migration from UserDefaults - /// One-time migration: reads existing tab state and last-query data from UserDefaults, + /// One-time migration: reads existing tab state from UserDefaults, /// writes it to file storage, then clears the old UserDefaults keys. /// This is a static method to avoid actor-isolation issues during init. - private static func performMigrationIfNeeded(tabStateDirectory: URL, lastQueryDirectory: URL) { + private static func performMigrationIfNeeded(tabStateDirectory: URL) { let defaults = UserDefaults.standard guard !defaults.bool(forKey: migrationCompleteKey) else { return } @@ -234,11 +171,9 @@ internal actor TabDiskActor { logger.trace("Starting one-time migration of tab state from UserDefaults to file storage") var migratedTabStates = 0 - var migratedLastQueries = 0 let allKeys = defaults.dictionaryRepresentation().keys let tabStateKeys = allKeys.filter { $0.hasPrefix(legacyTabStateKeyPrefix) } - let lastQueryKeys = allKeys.filter { $0.hasPrefix(legacyLastQueryKeyPrefix) } for key in tabStateKeys { let uuidString = String(key.dropFirst(legacyTabStateKeyPrefix.count)) @@ -255,28 +190,10 @@ internal actor TabDiskActor { } } - for key in lastQueryKeys { - let uuidString = String(key.dropFirst(legacyLastQueryKeyPrefix.count)) - guard let connectionId = UUID(uuidString: uuidString), - let query = defaults.string(forKey: key) else { continue } - - let fileURL = lastQueryDirectory.appendingPathComponent("\(connectionId.uuidString).txt") - do { - let data = Data(query.utf8) - try data.write(to: fileURL, options: .atomic) - defaults.removeObject(forKey: key) - migratedLastQueries += 1 - } catch { - logger.error("Failed to migrate last query for \(uuidString): \(error.localizedDescription)") - } - } - defaults.set(true, forKey: migrationCompleteKey) - if migratedTabStates > 0 || migratedLastQueries > 0 { - logger.trace( - "Migration complete: \(migratedTabStates) tab states, \(migratedLastQueries) last queries" - ) + if migratedTabStates > 0 { + logger.trace("Migration complete: \(migratedTabStates) tab states") } else { logger.trace("Migration complete: no legacy data found") } diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index e93f2cde5..01997ddb7 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -22,7 +22,6 @@ final class ResultSet: Identifiable { var tableName: String? var isEditable: Bool = false var isPinned: Bool = false - var resultVersion: Int = 0 var metadataVersion: Int = 0 var sortState = SortState() var pagination = PaginationState() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift index e4780cfe0..9107f5361 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadMore.swift @@ -105,9 +105,6 @@ extension MainContentCoordinator { if !pagedResult.hasMore { tab.pagination.baseQueryForMore = nil } - if let rs = tab.display.activeResultSet { - rs.resultVersion = tab.schemaVersion - } tabManager.tabs[idx] = tab toolbarState.setExecuting(false) if capturedGeneration == queryGeneration { @@ -225,9 +222,6 @@ extension MainContentCoordinator { tab.execution.executionTime = result.executionTime tab.schemaVersion += 1 tab.pagination.resetLoadMore() - if let rs = tab.display.activeResultSet { - rs.resultVersion = tab.schemaVersion - } tabManager.tabs[idx] = tab toolbarState.setExecuting(false) toolbarState.lastQueryDuration = result.executionTime diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index d6f5246ed..b3ec4e71b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -94,7 +94,6 @@ extension MainContentCoordinator { rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage rs.tableName = stmtTableName - rs.resultVersion = 1 newResultSets.append(rs) let historySQL = sql.hasSuffix(";") ? sql : sql + ";" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 2aaedb7ae..71cd55be0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -292,7 +292,6 @@ extension MainContentCoordinator { rs.statusMessage = updatedTab.execution.statusMessage rs.tableName = updatedTab.tableContext.tableName rs.isEditable = updatedTab.tableContext.isEditable - rs.resultVersion = updatedTab.schemaVersion rs.metadataVersion = updatedTab.metadataVersion // Keep pinned results, replace unpinned diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift index 993618587..841ecba45 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift @@ -341,7 +341,6 @@ extension MainContentCoordinator { rs.rowsAffected = result.rowsAffected rs.statusMessage = result.statusMessage rs.tableName = stmtTableName - rs.resultVersion = 1 newResultSets.append(rs) let historySQL = stmtSQL.hasSuffix(";") ? stmtSQL : stmtSQL + ";" diff --git a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift index 51a6fae82..3a221296a 100644 --- a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift @@ -18,7 +18,7 @@ struct AnyChangeManagerTests { func dataManagerHasChangesForwards() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) #expect(wrapper.hasChanges == false) @@ -32,7 +32,7 @@ struct AnyChangeManagerTests { func dataManagerReloadVersionForwards() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) let initialVersion = wrapper.reloadVersion dataManager.reloadVersion += 1 @@ -44,7 +44,7 @@ struct AnyChangeManagerTests { func isRowDeletedDelegatesCorrectly() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) #expect(wrapper.isRowDeleted(0) == false) @@ -57,12 +57,12 @@ struct AnyChangeManagerTests { func recordCellChangeForwards() { let dataManager = DataChangeManager() dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"]) - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) wrapper.recordCellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob", originalRow: ["1", "Alice"]) #expect(dataManager.hasChanges == true) - #expect(!wrapper.changes.isEmpty) + #expect(!wrapper.rowChanges.isEmpty) } @Test("No retain cycle — wrapper can be deallocated") @@ -73,7 +73,7 @@ struct AnyChangeManagerTests { weak var weakWrapper: AnyChangeManager? do { - let wrapper = AnyChangeManager(dataManager: dataManager) + let wrapper = AnyChangeManager(dataManager) weakWrapper = wrapper #expect(weakWrapper != nil) } @@ -86,7 +86,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: isRowDeleted always returns false") func structureManagerIsRowDeletedAlwaysFalse() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) #expect(wrapper.isRowDeleted(0) == false) #expect(wrapper.isRowDeleted(100) == false) @@ -95,7 +95,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: consumeChangedRowIndices returns empty set") func structureManagerConsumeChangedRowIndicesEmpty() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) let indices = wrapper.consumeChangedRowIndices() #expect(indices.isEmpty) @@ -104,7 +104,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: hasChanges forwards correctly when false") func structureManagerHasChangesForwardsFalse() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) #expect(wrapper.hasChanges == false) } @@ -112,7 +112,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: hasChanges forwards correctly when true") func structureManagerHasChangesForwardsTrue() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) structureManager.addNewColumn() @@ -122,7 +122,7 @@ struct AnyChangeManagerTests { @Test("StructureChangeManager wrapper: reloadVersion forwards correctly") func structureManagerReloadVersionForwards() { let structureManager = StructureChangeManager() - let wrapper = AnyChangeManager(structureManager: structureManager) + let wrapper = AnyChangeManager(structureManager) let initialVersion = wrapper.reloadVersion structureManager.reloadVersion = 5 diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index 025107eeb..2619b4d14 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -241,14 +241,13 @@ struct RowOperationsManagerTests { _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) #expect(rows.count == 6) - let nextRow = manager.deleteSelectedRows( + let result = manager.deleteSelectedRows( selectedIndices: [5], resultRows: &rows ) - // After removing the last row, should select the new last row - #expect(nextRow >= 0) - #expect(nextRow < rows.count) + #expect(result.nextRowToSelect >= 0) + #expect(result.nextRowToSelect < rows.count) } // MARK: - Integration Tests diff --git a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift index 84d77c372..7d267b654 100644 --- a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift +++ b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift @@ -127,31 +127,6 @@ struct TabPersistenceCoordinatorTests { await sleep() } - @Test("saveLastQuery + loadLastQuery round-trip") - func saveAndLoadLastQueryRoundTrip() async { - let coordinator = makeCoordinator() - let query = "SELECT * FROM products WHERE active = 1" - - coordinator.saveLastQuery(query) - await sleep() - - let loaded = await coordinator.loadLastQuery() - - #expect(loaded == query) - - coordinator.clearSavedState() - await sleep() - } - - @Test("loadLastQuery returns nil when nothing saved") - func loadLastQueryReturnsNilWhenEmpty() async { - let coordinator = makeCoordinator() - - let loaded = await coordinator.loadLastQuery() - - #expect(loaded == nil) - } - @Test("Large query over 500KB is truncated to empty string in persisted tab") func largeQueryIsTruncated() async { let coordinator = makeCoordinator() diff --git a/TableProTests/Core/Storage/TabDiskActorTests.swift b/TableProTests/Core/Storage/TabDiskActorTests.swift index 70208a10c..8310df007 100644 --- a/TableProTests/Core/Storage/TabDiskActorTests.swift +++ b/TableProTests/Core/Storage/TabDiskActorTests.swift @@ -153,75 +153,6 @@ struct TabDiskActorTests { await actor.clear(connectionId: connectionId) } - // MARK: - saveLastQuery / loadLastQuery round-trip - - @Test("saveLastQuery then loadLastQuery round-trips") - func lastQueryRoundTrip() async throws { - let connectionId = UUID() - let query = "SELECT * FROM products WHERE active = true" - - await actor.saveLastQuery(query, for: connectionId) - let loaded = await actor.loadLastQuery(for: connectionId) - - #expect(loaded == query) - - await actor.saveLastQuery("", for: connectionId) - } - - // MARK: - loadLastQuery returns nil for unknown connectionId - - @Test("loadLastQuery returns nil for unknown connectionId") - func loadLastQueryReturnsNilForUnknown() async throws { - let result = await actor.loadLastQuery(for: UUID()) - #expect(result == nil) - } - - // MARK: - saveLastQuery with empty string removes the file - - @Test("saveLastQuery with empty string removes the file") - func saveLastQueryEmptyRemovesFile() async throws { - let connectionId = UUID() - - await actor.saveLastQuery("SELECT 1", for: connectionId) - #expect(await actor.loadLastQuery(for: connectionId) != nil) - - await actor.saveLastQuery("", for: connectionId) - let result = await actor.loadLastQuery(for: connectionId) - #expect(result == nil) - } - - // MARK: - saveLastQuery with whitespace-only string removes the file - - @Test("saveLastQuery with whitespace-only string removes the file") - func saveLastQueryWhitespaceOnlyRemovesFile() async throws { - let connectionId = UUID() - - await actor.saveLastQuery("SELECT 1", for: connectionId) - await actor.saveLastQuery(" \n\t ", for: connectionId) - - let result = await actor.loadLastQuery(for: connectionId) - #expect(result == nil) - } - - // MARK: - saveLastQuery skips queries exceeding 500KB - - @Test("saveLastQuery skips queries exceeding 500KB") - func saveLastQuerySkipsLargeQueries() async throws { - let connectionId = UUID() - let smallQuery = "SELECT 1" - - await actor.saveLastQuery(smallQuery, for: connectionId) - #expect(await actor.loadLastQuery(for: connectionId) == smallQuery) - - let largeQuery = String(repeating: "A", count: 500_001) - await actor.saveLastQuery(largeQuery, for: connectionId) - - let result = await actor.loadLastQuery(for: connectionId) - #expect(result == smallQuery) - - await actor.saveLastQuery("", for: connectionId) - } - // MARK: - Tab with all fields round-trips @Test("Tab with all fields including isView and databaseName round-trips") diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index 14332b4b6..41e048da8 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -20,7 +20,6 @@ struct CommandActionsDispatchTests { let state = SessionStateFactory.create(connection: connection, payload: nil) let coordinator = state.coordinator - var selectedRowIndices: Set = [] var selectedTables: Set = [] var pendingTruncates: Set = [] var pendingDeletes: Set = [] @@ -32,7 +31,7 @@ struct CommandActionsDispatchTests { coordinator: coordinator, filterStateManager: state.filterStateManager, connection: connection, - selectedRowIndices: Binding(get: { selectedRowIndices }, set: { selectedRowIndices = $0 }), + selectionState: coordinator.selectionState, selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), diff --git a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift index 34e32a837..42b1fd9c3 100644 --- a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift @@ -12,12 +12,12 @@ import Testing @Suite("MainStatusBarView Layout") @MainActor struct MainStatusBarLayoutTests { - @Test("Status bar can be instantiated with nil tab") - func instantiateWithNilTab() { + @Test("Status bar can be instantiated with empty snapshot") + func instantiateWithEmptySnapshot() { let filterManager = FilterStateManager() let colVisManager = ColumnVisibilityManager() let view = MainStatusBarView( - tab: nil, + snapshot: StatusBarSnapshot(tab: nil, buffer: nil), filterStateManager: filterManager, columnVisibilityManager: colVisManager, allColumns: [], @@ -31,7 +31,6 @@ struct MainStatusBarLayoutTests { onOffsetChange: { _ in }, onPaginationGo: {} ) - // Smoke test: view constructs without error #expect(type(of: view.body) != Never.self) } } diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index 4bef58523..bfba6774c 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -261,20 +261,19 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var selectedRows: Set = [] var editingCell: CellPosition? - coordinator.addNewRow(selectedRowIndices: &selectedRows, editingCell: &editingCell) - #expect(selectedRows.isEmpty) + coordinator.addNewRow(editingCell: &editingCell) + #expect(coordinator.selectionState.indices.isEmpty) #expect(editingCell == nil) - selectedRows = [0] - coordinator.deleteSelectedRows(indices: Set([0]), selectedRowIndices: &selectedRows) - #expect(selectedRows == [0]) + coordinator.selectionState.indices = [0] + coordinator.deleteSelectedRows(indices: Set([0])) + #expect(coordinator.selectionState.indices == [0]) - selectedRows = [] - coordinator.duplicateSelectedRow(index: 0, selectedRowIndices: &selectedRows, editingCell: &editingCell) - #expect(selectedRows.isEmpty) + coordinator.selectionState.indices = [] + coordinator.duplicateSelectedRow(index: 0, editingCell: &editingCell) + #expect(coordinator.selectionState.indices.isEmpty) #expect(editingCell == nil) } @@ -287,11 +286,9 @@ struct SaveCompletionTests { tabManager.tabs[index].tableContext.tableName = "users" } - var selectedRows: Set = [] var editingCell: CellPosition? - // Alert level doesn't block row staging — only gates at execution time - coordinator.addNewRow(selectedRowIndices: &selectedRows, editingCell: &editingCell) + coordinator.addNewRow(editingCell: &editingCell) #expect(tabManager.tabs.first?.execution.errorMessage == nil) } } diff --git a/TableProTests/Views/Main/TriggerStructTests.swift b/TableProTests/Views/Main/TriggerStructTests.swift index cf35b4d7d..8c11fa59f 100644 --- a/TableProTests/Views/Main/TriggerStructTests.swift +++ b/TableProTests/Views/Main/TriggerStructTests.swift @@ -15,43 +15,43 @@ import Testing struct InspectorTriggerTests { @Test("Same values are equal") func sameValuesAreEqual() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) #expect(a == b) } @Test("Both nil fields are equal") func bothNilFieldsAreEqual() { - let a = InspectorTrigger(tableName: nil, resultVersion: 0, metadataVersion: 0, metadataTableName: nil) - let b = InspectorTrigger(tableName: nil, resultVersion: 0, metadataVersion: 0, metadataTableName: nil) + let a = InspectorTrigger(tableName: nil, schemaVersion: 0, metadataVersion: 0) + let b = InspectorTrigger(tableName: nil, schemaVersion: 0, metadataVersion: 0) #expect(a == b) } @Test("Different tableName produces unequal triggers") func differentTableName() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "orders", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "orders", schemaVersion: 1, metadataVersion: 0) #expect(a != b) } @Test("nil vs non-nil tableName produces unequal triggers") func nilVsNonNilTableName() { - let a = InspectorTrigger(tableName: nil, resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") + let a = InspectorTrigger(tableName: nil, schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) #expect(a != b) } - @Test("Different resultVersion produces unequal triggers") - func differentResultVersion() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 2, metadataVersion: 0, metadataTableName: "users") + @Test("Different schemaVersion produces unequal triggers") + func differentSchemaVersion() { + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 2, metadataVersion: 0) #expect(a != b) } - @Test("Different metadataTableName produces unequal triggers") - func differentMetadataTableName() { - let a = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "users") - let b = InspectorTrigger(tableName: "users", resultVersion: 1, metadataVersion: 0, metadataTableName: "orders") + @Test("Different metadataVersion produces unequal triggers") + func differentMetadataVersion() { + let a = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 0) + let b = InspectorTrigger(tableName: "users", schemaVersion: 1, metadataVersion: 1) #expect(a != b) } } diff --git a/TableProTests/Views/Results/DataGridIdentityTests.swift b/TableProTests/Views/Results/DataGridIdentityTests.swift index 384f3adfb..d57044fdb 100644 --- a/TableProTests/Views/Results/DataGridIdentityTests.swift +++ b/TableProTests/Views/Results/DataGridIdentityTests.swift @@ -11,66 +11,98 @@ import Testing @Suite("DataGridIdentity") struct DataGridIdentityTests { + private func makeIdentity( + reloadVersion: Int = 1, + schemaVersion: Int = 2, + metadataVersion: Int = 3, + paginationVersion: Int = 0, + rowCount: Int = 100, + columnCount: Int = 5, + isEditable: Bool = true, + tabType: TabType? = .table, + tableName: String? = "users", + primaryKeyColumns: [String] = ["id"], + hiddenColumns: Set = [] + ) -> DataGridIdentity { + var config = DataGridConfiguration() + config.tabType = tabType + config.tableName = tableName + config.primaryKeyColumns = primaryKeyColumns + config.hiddenColumns = hiddenColumns + return DataGridIdentity( + reloadVersion: reloadVersion, + schemaVersion: schemaVersion, + metadataVersion: metadataVersion, + paginationVersion: paginationVersion, + rowCount: rowCount, + columnCount: columnCount, + isEditable: isEditable, + configuration: config + ) + } + @Test("Same values produce equal identities") func sameValuesAreEqual() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a == b) + #expect(makeIdentity() == makeIdentity()) } @Test("Different reloadVersion produces unequal identities") func differentReloadVersion() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 2, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(reloadVersion: 1) != makeIdentity(reloadVersion: 2)) } - @Test("Different resultVersion produces unequal identities") - func differentResultVersion() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 3, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + @Test("Different schemaVersion produces unequal identities") + func differentSchemaVersion() { + #expect(makeIdentity(schemaVersion: 2) != makeIdentity(schemaVersion: 3)) } @Test("Different metadataVersion produces unequal identities") func differentMetadataVersion() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 4, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(metadataVersion: 3) != makeIdentity(metadataVersion: 4)) + } + + @Test("Different paginationVersion produces unequal identities") + func differentPaginationVersion() { + #expect(makeIdentity(paginationVersion: 0) != makeIdentity(paginationVersion: 1)) } @Test("Different rowCount produces unequal identities") func differentRowCount() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 200, columnCount: 5, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(rowCount: 100) != makeIdentity(rowCount: 200)) } @Test("Different columnCount produces unequal identities") func differentColumnCount() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 10, isEditable: true, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(columnCount: 5) != makeIdentity(columnCount: 10)) } @Test("Different isEditable produces unequal identities") func differentIsEditable() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: false, hiddenColumns: []) - #expect(a != b) + #expect(makeIdentity(isEditable: true) != makeIdentity(isEditable: false)) + } + + @Test("Different tabType produces unequal identities") + func differentTabType() { + #expect(makeIdentity(tabType: .table) != makeIdentity(tabType: .query)) + } + + @Test("Different tableName produces unequal identities") + func differentTableName() { + #expect(makeIdentity(tableName: "users") != makeIdentity(tableName: "orders")) + } + + @Test("Different primaryKeyColumns produces unequal identities") + func differentPrimaryKeyColumns() { + #expect(makeIdentity(primaryKeyColumns: ["id"]) != makeIdentity(primaryKeyColumns: ["uuid"])) } @Test("Different hiddenColumns produces unequal identities") func differentHiddenColumns() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: []) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: ["name"]) - #expect(a != b) + #expect(makeIdentity(hiddenColumns: []) != makeIdentity(hiddenColumns: ["name"])) } @Test("Same hiddenColumns produces equal identities") func sameHiddenColumns() { - let a = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: ["name", "email"]) - let b = DataGridIdentity(reloadVersion: 1, resultVersion: 2, metadataVersion: 3, paginationVersion: 0, rowCount: 100, columnCount: 5, isEditable: true, hiddenColumns: ["name", "email"]) - #expect(a == b) + #expect(makeIdentity(hiddenColumns: ["name", "email"]) == makeIdentity(hiddenColumns: ["name", "email"])) } } From 36d4c94c9b04cb8c4cae5a8f8b2dc6b2b036cb6e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 28 Apr 2026 02:45:01 +0700 Subject: [PATCH 10/10] test(perf): cover physicallyRemovedIndices, drop stale TabDiskActor doc - Add 4 RowOperationsManagerTests cases for the DeleteRowsResult.physicallyRemovedIndices contract: empty selection returns empty, deleting only existing rows leaves it empty (soft-delete via change manager), deleting inserted rows reports indices descending, mixed selection reports only the inserted indices. - Drop the stale 'Last-query strings are stored in a sibling directory' doc comment on TabDiskActor; the directory and feature are gone. --- TablePro/Core/Storage/TabDiskActor.swift | 3 -- .../Services/RowOperationsManagerTests.swift | 54 ++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Storage/TabDiskActor.swift b/TablePro/Core/Storage/TabDiskActor.swift index 8a3a811d2..8f3382948 100644 --- a/TablePro/Core/Storage/TabDiskActor.swift +++ b/TablePro/Core/Storage/TabDiskActor.swift @@ -20,9 +20,6 @@ internal struct TabDiskState: Codable { /// /// Data is stored as individual JSON files per connection in: /// `~/Library/Application Support/TablePro/TabState/` -/// -/// Last-query strings are stored in a sibling directory: -/// `~/Library/Application Support/TablePro/LastQuery/` internal actor TabDiskActor { internal static let shared = TabDiskActor() diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index 2619b4d14..af362c956 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -237,7 +237,6 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var rows = TestFixtures.makeRows(count: 5) - // Insert a row, then delete it — next selection should be valid _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) #expect(rows.count == 6) @@ -250,6 +249,59 @@ struct RowOperationsManagerTests { #expect(result.nextRowToSelect < rows.count) } + @Test("deleteSelectedRows returns empty physicallyRemovedIndices for empty selection") + func deleteSelectedRowsEmptySelection() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 3) + + let result = manager.deleteSelectedRows(selectedIndices: [], resultRows: &rows) + + #expect(result.physicallyRemovedIndices.isEmpty) + #expect(result.nextRowToSelect == -1) + #expect(rows.count == 3) + } + + @Test("deleteSelectedRows: deleting only existing rows leaves physicallyRemovedIndices empty") + func deleteSelectedRowsExistingOnly() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 5) + + let result = manager.deleteSelectedRows(selectedIndices: [1, 3], resultRows: &rows) + + #expect(result.physicallyRemovedIndices.isEmpty) + #expect(rows.count == 5) + } + + @Test("deleteSelectedRows: deleting only inserted rows reports each in physicallyRemovedIndices") + func deleteSelectedRowsInsertedOnly() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 2) + + _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) + _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) + _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) + #expect(rows.count == 5) + + let result = manager.deleteSelectedRows(selectedIndices: [2, 3, 4], resultRows: &rows) + + #expect(result.physicallyRemovedIndices == [4, 3, 2]) + #expect(rows.count == 2) + } + + @Test("deleteSelectedRows: mixed inserted and existing rows reports only inserted indices") + func deleteSelectedRowsMixed() { + let (manager, _) = makeManager() + var rows = TestFixtures.makeRows(count: 3) + + _ = manager.addNewRow(columns: ["id", "name", "email"], columnDefaults: [:], resultRows: &rows) + #expect(rows.count == 4) + + let result = manager.deleteSelectedRows(selectedIndices: [0, 3], resultRows: &rows) + + #expect(result.physicallyRemovedIndices == [3]) + #expect(rows.count == 3) + } + // MARK: - Integration Tests @Test("addNewRow then edit cell preserves insertion state")