Skip to content

Commit 9ef7f19

Browse files
authored
refactor(datagrid): migrate callers to TableRows + delete legacy stack (#931)
* refactor(datagrid): introduce TableRowsStore alongside RowDataStore * refactor(datagrid): introduce TableRowsController to drive NSTableView from Delta * refactor(datagrid): mirror result delivery writes into TableRowsStore * refactor(datagrid): switch JSON view and export to read TableRows * refactor(datagrid): switch sidebar reads to TableRows * refactor(datagrid): switch NSTableView delegate reads to TableRows * refactor(datagrid): route cell edits through TableRows.edit and Delta * refactor(datagrid): RowOperationsManager mutates TableRows and returns Delta * refactor(datagrid): undo replay returns Delta and supports non-tail insert * refactor(datagrid): sort moves to controller keyed by Row.id * refactor(datagrid): display cache moves to coordinator keyed by Row.id * refactor(datagrid): replace RowBuffer with TableRows; delete legacy row stack * test(datagrid): update fixtures for RowDeltaApplying.applyDelta and StatusBarSnapshot rename * fix(favorites): qualify ORDER BY columns in FTS-joined search query * fix(datagrid): guard commitCellEdit against re-entry from reload-driven resignFirstResponder * fix(datagrid): dispatch rowsInserted delta from loadMoreRows and read offset inside mutation * fix(datagrid): dispatch fullReplace delta from fetchAllRows * fix(datagrid): dispatch cellChanged delta from updateCellInTab * fix(datagrid): remove TableRows entry on individual tab close * refactor(datagrid): rebuildColumnMetadataCache reads live TableRows * style(datagrid): drop doc comments per no-comments rule * fix(datagrid): sync ResultSet snapshot with store on every mutation Load More appended rows to the store but ResultSet.tableRows stayed frozen at applyPhase1Result time. The data grid read through the active ResultSet, so newly fetched rows showed empty cells. Route every TableRows mutation through mutateActiveTableRows on MainContentCoordinator. The helper updates the store, then writes the new value back into the active ResultSet. Result-set switches go through switchActiveResultSet so the store tracks the new active snapshot. Read paths simplify to reading the store directly. * fix(datagrid): unwrap optional result from addNewRow/duplicateRow * fix(datagrid): clear modified cell highlight on undo, keep FK metadata across reloads Two related visual regressions: 1. Edit cell -> yellow modified background -> undo -> value reverts but highlight persists. applyDataUndo updated pending state but did not bump reloadVersion, so the visual state cache was gated and the stale modifiedColumns set survived. Bumping reloadVersion forces a rebuild on the next render. 2. FK column arrow and dropdown chevron toggle visible/hidden on each reload of a table tab. applyPhase1Result rebuilt TableRows from scratch and only populated columnDefaults / columnForeignKeys / columnNullable / columnEnumValues when a fresh schema fetch ran. When isMetadataCached returned true (because metadata was in the previous TableRows), no fetch ran and the new TableRows wiped the metadata, which then caused the next reload to refetch -- so every other reload had FK info and every other one didn't. Carry the existing metadata over when the schema fetch is skipped. * perf(datagrid): stop syncing ResultSet snapshot on every mutation Inserting or undoing a row pegged the CPU at 100%. Each mutation went through mutateActiveTableRows, which wrote the live TableRows back into the active ResultSet's @observable tableRows property. That triggered a full SwiftUI re-render of MainEditorContentView (which reads rs.resultColumns / rs.errorMessage / etc), on top of the existing observation triggers from changeManager.reloadVersion and tabManager.tabs. Fast key-repeat undo or paste cascaded re-renders. Move the per-ResultSet snapshot into a save-on-switch model: mutateActiveTableRows now only writes the store, and switchActiveResultSet saves the outgoing snapshot then loads the incoming one. Edits in a pinned result set still survive switching back, but routine inserts / undos / cell edits no longer cross the @observable boundary on the ResultSet. Also short-circuit the inserted-rows scan in DataGridCoordinator.rebuildVisualStateCache: when the grid is unsorted, read changeManager.insertedRowIndices directly instead of iterating every row in TableRows. The full scan only runs in the sorted case where display indices differ from storage indices. * test(datagrid): fix EvictionTests — selected tab is intentionally not evicted `evictInactiveRowData` deliberately skips the currently selected tab ("kept in memory so the user sees no refresh flicker"), but the migrated tests added a tab and immediately called eviction — the new tab was the selected tab, so eviction was a no-op and the assertions failed. Fix: add a second tab so the first becomes background, then assert eviction on the background tab. * perf(datagrid): updateCache reads live tableRowsProvider so post-Delta count is fresh * refactor(datagrid): replace editingCell binding with direct beginEditing call addNewRow / duplicateSelectedRow now call coordinator.beginEditing(displayRow:column:) synchronously after applyDelta. The editingCell SwiftUI binding plumbing across 7 files is removed since no caller sets it to non-nil anymore. Each row-add press fully completes (commit prior edit, mutate model, apply delta, focus new cell) before the next press fires, so rapid Cmd+Shift+N keeps focus on the latest appended row instead of getting trapped in queued Tasks. * chore: drop DATAGRID_REFACTOR.md handoff doc * fix(datagrid): clear cell display cache when row data is replaced setActiveTableRows now dispatches applyFullReplace to the active grid after every full row swap, routing through a single mutation surface instead of seven direct tableRowsStore.setTableRows callers across navigation, query helpers, multi-statement, FK navigation, and sidebar actions. Without the dispatch, the coordinator's RowID-keyed displayCache survived table switches and returned the previous table's formatted cell values for matching RowIDs, even though the cell views themselves had rebuilt with the new column set. * refactor(datagrid): rename RowDeltaApplying to TableViewCoordinating, add dispatch regression tests The protocol now exposes commitActiveCellEdit and beginEditing alongside the row-delta methods, so its name no longer matches its scope. Renaming to TableViewCoordinating tracks the conforming class TableViewCoordinator and the field DataTabGridDelegate.tableViewCoordinator. TableRowsMutationTests verifies that setActiveTableRows dispatches applyFullReplace exactly once for the active tab and skips background tabs, locking in the displayCache invalidation contract that was missing before commit 0e967c2.
1 parent 0129afb commit 9ef7f19

85 files changed

Lines changed: 2289 additions & 3420 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
### Changed
2424

25-
- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. No callers migrated yet (Phase C.1 of the DataGrid refactor).
25+
- Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id.
26+
- Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. The snapshot now refreshes only when the user switches result sets (saving the outgoing tab, loading the incoming one), so each insert / undo / paste no longer triggers an `@Observable` re-render of the whole editor. Fixes empty cells on Load More and CPU spikes when adding or undoing rows.
27+
- Undo of a cell edit clears the modified-cell highlight: `DataChangeManager.applyDataUndo` now bumps `reloadVersion` so the data grid rebuilds its visual state cache.
28+
- Reloading a table tab keeps cached column metadata (defaults, foreign keys, nullability, enum values) when no fresh schema fetch was needed, so the FK arrow and dropdown chevron stay visible across reloads instead of toggling.
2629
- DataChangeManager extracted a PendingChanges value type that owns cross-collection invariants for cell edits, row insertions, and deletions. DataChangeManager kept undo/redo registration, plugin SQL generation, and the `@Observable` boundary, dropping from ~960 to ~190 lines. The serialization DTO `TabPendingChanges` is renamed to `TabChangeSnapshot` to distinguish it from the live tracker.
2730
- AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts
2831
- Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click

TablePro/Core/ChangeTracking/AnyChangeManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ protocol ChangeManaging: AnyObject {
77
var reloadVersion: Int { get }
88
var canRedo: Bool { get }
99
var rowChanges: [RowChange] { get }
10+
var insertedRowIndices: Set<Int> { get }
1011
func isRowDeleted(_ rowIndex: Int) -> Bool
1112
func recordCellChange(
1213
rowIndex: Int,
@@ -30,6 +31,7 @@ final class AnyChangeManager {
3031
var reloadVersion: Int { wrapped.reloadVersion }
3132
var canRedo: Bool { wrapped.canRedo }
3233
var rowChanges: [RowChange] { wrapped.rowChanges }
34+
var insertedRowIndices: Set<Int> { wrapped.insertedRowIndices }
3335

3436
func isRowDeleted(_ rowIndex: Int) -> Bool {
3537
wrapped.isRowDeleted(rowIndex)

TablePro/Core/ChangeTracking/DataChangeManager.swift

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ struct UndoResult {
1717
let needsRowRemoval: Bool
1818
let needsRowRestore: Bool
1919
let restoreRow: [String?]?
20+
let delta: Delta
21+
22+
init(
23+
action: UndoAction,
24+
needsRowRemoval: Bool,
25+
needsRowRestore: Bool,
26+
restoreRow: [String?]?,
27+
delta: Delta = .none
28+
) {
29+
self.action = action
30+
self.needsRowRemoval = needsRowRemoval
31+
self.needsRowRestore = needsRowRestore
32+
self.restoreRow = restoreRow
33+
self.delta = delta
34+
}
2035
}
2136

2237
/// Manager for tracking and applying data changes
@@ -259,7 +274,10 @@ final class DataChangeManager: ChangeManaging {
259274
originalDBValue: newValue, newValue: previousValue, originalRow: originalRow
260275
)
261276
}
262-
lastUndoResult = UndoResult(action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil)
277+
lastUndoResult = UndoResult(
278+
action: action, needsRowRemoval: false, needsRowRestore: false, restoreRow: nil,
279+
delta: .cellChanged(row: rowIndex, column: columnIndex)
280+
)
263281
}
264282

265283
private func applyRowInsertionUndo(rowIndex: Int, action: UndoAction) {
@@ -274,12 +292,14 @@ final class DataChangeManager: ChangeManaging {
274292
if pending.isRowInserted(rowIndex) {
275293
_ = pending.undoRowInsertion(rowIndex: rowIndex)
276294
lastUndoResult = UndoResult(
277-
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil
295+
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil,
296+
delta: .rowsRemoved(IndexSet(integer: rowIndex))
278297
)
279298
} else {
280299
pending.reinsertRow(rowIndex: rowIndex, columns: columns, savedValues: savedValues)
281300
lastUndoResult = UndoResult(
282-
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues
301+
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: savedValues,
302+
delta: .rowsInserted(IndexSet(integer: rowIndex))
283303
)
284304
}
285305
}
@@ -292,12 +312,14 @@ final class DataChangeManager: ChangeManaging {
292312
if pending.isRowDeleted(rowIndex) {
293313
_ = pending.undoRowDeletion(rowIndex: rowIndex)
294314
lastUndoResult = UndoResult(
295-
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow
315+
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: originalRow,
316+
delta: .fullReplace
296317
)
297318
} else {
298319
pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow)
299320
lastUndoResult = UndoResult(
300-
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil
321+
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil,
322+
delta: .fullReplace
301323
)
302324
}
303325
}
@@ -315,14 +337,16 @@ final class DataChangeManager: ChangeManaging {
315337
_ = pending.undoRowDeletion(rowIndex: rowIndex)
316338
}
317339
lastUndoResult = UndoResult(
318-
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil
340+
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil,
341+
delta: .fullReplace
319342
)
320343
} else {
321344
for (rowIndex, originalRow) in rows {
322345
pending.reapplyRowDeletion(rowIndex: rowIndex, originalRow: originalRow)
323346
}
324347
lastUndoResult = UndoResult(
325-
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil
348+
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil,
349+
delta: .fullReplace
326350
)
327351
}
328352
}
@@ -335,15 +359,18 @@ final class DataChangeManager: ChangeManaging {
335359
}
336360

337361
let firstInserted = rowIndices.first.map { pending.isRowInserted($0) } ?? false
362+
let indices = IndexSet(rowIndices)
338363
if firstInserted {
339364
_ = pending.undoBatchRowInsertion(rowIndices: rowIndices, columnCount: columns.count)
340365
lastUndoResult = UndoResult(
341-
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil
366+
action: action, needsRowRemoval: true, needsRowRestore: false, restoreRow: nil,
367+
delta: .rowsRemoved(indices)
342368
)
343369
} else {
344370
pending.reinsertBatch(rowIndices: rowIndices, rowValues: rowValues, columns: columns)
345371
lastUndoResult = UndoResult(
346-
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil
372+
action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil,
373+
delta: .rowsInserted(indices)
347374
)
348375
}
349376
}

TablePro/Core/Plugins/QueryResultExportDataSource.swift

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ import Foundation
77
import os
88
import TableProPluginKit
99

10-
/// In-memory `PluginExportDataSource` backed by a RowBuffer snapshot.
11-
/// Allows export plugins (CSV, JSON, SQL, XLSX, MQL) to export query results
12-
/// without modification to the plugins themselves.
1310
final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Sendable {
1411
let databaseTypeId: String
1512

@@ -20,14 +17,12 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send
2017

2118
private static let logger = Logger(subsystem: "com.TablePro", category: "QueryResultExportDataSource")
2219

23-
init(rowBuffer: RowBuffer, databaseType: DatabaseType, driver: DatabaseDriver?) {
20+
init(tableRows: TableRows, databaseType: DatabaseType, driver: DatabaseDriver?) {
2421
self.databaseTypeId = databaseType.rawValue
2522
self.driver = driver
26-
27-
// Snapshot data at init time for thread safety
28-
self.columns = rowBuffer.columns
29-
self.columnTypeNames = rowBuffer.columnTypes.map { $0.rawType ?? "" }
30-
self.rows = rowBuffer.rows
23+
self.columns = tableRows.columns
24+
self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" }
25+
self.rows = tableRows.rows.map(\.values)
3126
}
3227

3328
func streamRows(table: String, databaseName: String) -> AsyncThrowingStream<PluginStreamElement, Error> {

TablePro/Core/Plugins/StreamingQueryExportDataSource.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//
55
// Streaming export data source for query results.
66
// Re-executes the query and streams rows directly from the database to the export plugin,
7-
// bypassing RowBuffer. Allows exporting large result sets without loading all rows into memory.
7+
// bypassing in-memory storage. Allows exporting large result sets without loading all rows into memory.
88
//
99

1010
import Foundation

TablePro/Core/SchemaTracking/StructureChangeManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,8 @@ final class StructureChangeManager: ChangeManaging {
887887

888888
var rowChanges: [RowChange] { [] }
889889

890+
var insertedRowIndices: Set<Int> { [] }
891+
890892
func isRowDeleted(_ rowIndex: Int) -> Bool { false }
891893

892894
func recordCellChange(

TablePro/Core/Services/Export/ExportService.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,15 @@ final class ExportService {
181181
// MARK: - Query Results Export
182182

183183
func exportQueryResults(
184-
rowBuffer: RowBuffer,
184+
tableRows: TableRows,
185185
config: ExportConfiguration,
186186
to url: URL
187187
) async throws {
188188
guard let plugin = PluginManager.shared.exportPlugins[config.formatId] else {
189189
throw ExportError.formatNotFound(config.formatId)
190190
}
191191

192-
let totalRows = rowBuffer.rows.count
192+
let totalRows = tableRows.count
193193
state = ExportState(isExporting: true, totalTables: 1, totalRows: totalRows)
194194
isCancelled = false
195195

@@ -201,7 +201,7 @@ final class ExportService {
201201
}
202202

203203
let dataSource = QueryResultExportDataSource(
204-
rowBuffer: rowBuffer,
204+
tableRows: tableRows,
205205
databaseType: databaseType,
206206
driver: driver
207207
)

TablePro/Core/Services/Formatting/CellDisplayFormatter.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// TablePro
44
//
55
// Pure formatter that transforms raw cell values into display-ready strings.
6-
// Used by InMemoryRowProvider's display cache to compute values once per cell.
6+
// Used by the data grid coordinator's display cache to compute values once per cell.
77
//
88

99
import Foundation

TablePro/Core/Services/Query/RowDataStore.swift

Lines changed: 0 additions & 44 deletions
This file was deleted.

0 commit comments

Comments
 (0)