Skip to content

Commit 0b7ea83

Browse files
committed
refactor(datagrid): sort moves to controller keyed by Row.id
1 parent 45c5181 commit 0b7ea83

10 files changed

Lines changed: 265 additions & 92 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ 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. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. Row operations (add, duplicate, delete, paste, undo) mutate TableRows and apply the returned Delta through TableViewCoordinator.applyDelta. RowBuffer still backs sorting and the display cache pending later phases (Phase C.2 of the DataGrid refactor).
25+
- Introduced TableRows, Row, and Delta value types in TablePro/Models/Query/ as the foundation for the data grid row model rewrite. NSTableView delegate reads cell values and row count from TableRows; sidebar, JSON view, and exports now read from TableRows. Cell edits route through TableRows.edit and apply NSTableView updates via the Delta-driven TableRowsController. Row operations (add, duplicate, delete, paste, undo) mutate TableRows and apply the returned Delta through TableViewCoordinator.applyDelta. Sort state moved from InMemoryRowProvider's positional sortIndices to a TableViewCoordinator.sortedIDs permutation keyed by Row.id, so cell edits under sort hit the correct storage row and inserted rows survive at the end of the sorted view without re-sorting. RowBuffer still backs the display cache pending later phases (Phase C.2 of the DataGrid refactor).
2626
- 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.
2727
- AnyChangeManager uses ChangeManaging protocol instead of closure-based type erasure, removing all runtime `[Any]` downcasts
2828
- Row selection state moved from MainContentView @State to GridSelectionState @Observable class, preventing full view tree invalidation on every row click

TablePro/Models/Query/TableRows.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ struct TableRows: Sendable {
3939
return rows[row][column]
4040
}
4141

42+
func index(of id: RowID) -> Int? {
43+
for (index, row) in rows.enumerated() where row.id == id {
44+
return index
45+
}
46+
return nil
47+
}
48+
49+
func row(withID id: RowID) -> Row? {
50+
guard let index = index(of: id) else { return nil }
51+
return rows[index]
52+
}
53+
4254
@discardableResult
4355
mutating func edit(row: Int, column: Int, value: String?) -> Delta {
4456
guard row >= 0, row < rows.count else { return .none }

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import AppKit
1010
import CodeEditSourceEditor
1111
import SwiftUI
1212

13-
/// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation
13+
/// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation.
14+
/// Stores a permutation of `RowID` so the grid keeps the same display order even after
15+
/// inserts and deletes mutate the underlying TableRows storage.
1416
private struct SortedRowsCache {
15-
let sortedIndices: [Int]
17+
let sortedIDs: [RowID]
1618
let columnIndex: Int
1719
let direction: SortDirection
1820
let schemaVersion: Int
@@ -578,6 +580,7 @@ struct MainEditorContentView: View {
578580
showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers,
579581
hiddenColumns: columnVisibilityManager.hiddenColumns
580582
),
583+
sortedIDs: sortedIDsForTab(tab),
581584
delegate: dataTabDelegate,
582585
selectedRowIndices: Binding(
583586
get: { selectionState.indices },
@@ -633,7 +636,6 @@ struct MainEditorContentView: View {
633636
if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty {
634637
provider = InMemoryRowProvider(
635638
rowBuffer: rs.rowBuffer,
636-
sortIndices: sortIndicesForTab(tab),
637639
columns: rs.resultColumns,
638640
columnDefaults: rs.columnDefaults,
639641
columnTypes: rs.columnTypes,
@@ -645,7 +647,6 @@ struct MainEditorContentView: View {
645647
let buffer = coordinator.rowDataStore.buffer(for: tab.id)
646648
provider = InMemoryRowProvider(
647649
rowBuffer: buffer,
648-
sortIndices: sortIndicesForTab(tab),
649650
columns: buffer.columns,
650651
columnDefaults: buffer.columnDefaults,
651652
columnTypes: buffer.columnTypes,
@@ -715,73 +716,66 @@ struct MainEditorContentView: View {
715716
}
716717
}
717718

718-
/// Returns sort index permutation for a tab, or nil if no sorting is needed.
719+
/// Returns the display order as a permutation of `RowID`, or nil when no sort applies.
719720
/// For table tabs, sorting is handled server-side via SQL ORDER BY.
720-
private func sortIndicesForTab(_ tab: QueryTab) -> [Int]? {
721-
// Resolve data source: active ResultSet or tab-level fallback
721+
private func sortedIDsForTab(_ tab: QueryTab) -> [RowID]? {
722722
let rowBuffer: RowBuffer
723-
let rows: [[String?]]
724723
let colTypes: [ColumnType]
725724
if let rs = tab.display.activeResultSet, !rs.resultColumns.isEmpty {
726725
rowBuffer = rs.rowBuffer
727-
rows = rs.resultRows
728726
colTypes = rs.columnTypes
729727
} else {
730728
let buffer = coordinator.rowDataStore.buffer(for: tab.id)
731729
rowBuffer = buffer
732-
rows = buffer.rows
733730
colTypes = buffer.columnTypes
734731
}
735732

736733
guard !rowBuffer.isEvicted else { return nil }
737734

738-
// Table tabs: no client-side sorting
739735
if tab.tabType == .table {
740736
return nil
741737
}
742738

743-
// Query tabs: apply client-side sorting
744739
guard tab.sortState.isSorting else {
745740
return nil
746741
}
747742

748-
// Check coordinator's async sort cache (for large datasets sorted on background thread)
743+
guard let tableRows = coordinator.tableRowsStore.existingTableRows(for: tab.id),
744+
!tableRows.rows.isEmpty else {
745+
return nil
746+
}
747+
749748
if let cached = coordinator.querySortCache[tab.id],
750749
cached.columnIndex == (tab.sortState.columnIndex ?? -1),
751750
cached.direction == tab.sortState.direction,
752751
cached.schemaVersion == tab.schemaVersion
753752
{
754-
return cached.sortedIndices
753+
return cached.sortedIDs
755754
}
756755

757-
// For datasets sorted async, return nil (unsorted) until cache is ready
758-
if rows.count > 1_000 {
756+
if tableRows.rows.count > 1_000 {
759757
return nil
760758
}
761759

762-
// Small dataset: sort synchronously with view-level cache
763760
if let cached = sortCache[tab.id],
764761
cached.columnIndex == (tab.sortState.columnIndex ?? -1),
765762
cached.direction == tab.sortState.direction,
766763
cached.schemaVersion == tab.schemaVersion
767764
{
768-
return cached.sortedIndices
765+
return cached.sortedIDs
769766
}
770767

771768
let sortColumns = tab.sortState.columns
772-
let indices = Array(rows.indices)
773-
let sortedIndices = indices.sorted { idx1, idx2 in
774-
let row1 = rows[idx1]
775-
let row2 = rows[idx2]
769+
let storageRows = tableRows.rows
770+
let sortedIndices = Array(storageRows.indices).sorted { idx1, idx2 in
771+
let row1 = storageRows[idx1].values
772+
let row2 = storageRows[idx2].values
776773
for sortCol in sortColumns {
777-
let val1 =
778-
sortCol.columnIndex < row1.count
774+
let val1 = sortCol.columnIndex < row1.count
779775
? (row1[sortCol.columnIndex] ?? "") : ""
780-
let val2 =
781-
sortCol.columnIndex < row2.count
776+
let val2 = sortCol.columnIndex < row2.count
782777
? (row2[sortCol.columnIndex] ?? "") : ""
783-
let colType =
784-
sortCol.columnIndex < colTypes.count
778+
let colType = sortCol.columnIndex < colTypes.count
785779
? colTypes[sortCol.columnIndex] : nil
786780
let result = RowSortComparator.compare(val1, val2, columnType: colType)
787781
if result == .orderedSame { continue }
@@ -791,16 +785,16 @@ struct MainEditorContentView: View {
791785
}
792786
return false
793787
}
788+
let sortedIDs = sortedIndices.map { storageRows[$0].id }
794789

795-
// Cache the result
796790
sortCache[tab.id] = SortedRowsCache(
797-
sortedIndices: sortedIndices,
791+
sortedIDs: sortedIDs,
798792
columnIndex: tab.sortState.columnIndex ?? -1,
799793
direction: tab.sortState.direction,
800794
schemaVersion: tab.schemaVersion
801795
)
802796

803-
return sortedIndices
797+
return sortedIDs
804798
}
805799

806800
private func sortStateBinding(for tab: QueryTab) -> Binding<SortState> {

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ enum DiscardAction {
2121
case filter
2222
}
2323

24-
/// Cache entry for async-sorted query tab rows (stores index permutation, not row copies)
24+
/// Cache entry for async-sorted query tab rows. Stores a permutation of `RowID` so the
25+
/// sort survives mutations: inserted rows append to the end of the sorted view, and
26+
/// removed rows are dropped from the permutation without re-sorting.
2527
struct QuerySortCacheEntry {
26-
let sortedIndices: [Int]
28+
let sortedIDs: [RowID]
2729
let columnIndex: Int
2830
let direction: SortDirection
2931
let schemaVersion: Int
@@ -1357,13 +1359,14 @@ final class MainContentCoordinator {
13571359
tabManager.tabs[tabIndex].sortState = currentSort
13581360
tabManager.tabs[tabIndex].hasUserInteraction = true
13591361
tabManager.tabs[tabIndex].pagination.reset()
1360-
let rows = buffer.rows
13611362
let tabId = tab.id
13621363
let schemaVersion = tab.schemaVersion
13631364
let sortColumns = currentSort.columns
13641365
let colTypes = buffer.columnTypes
1366+
let storageRows = tableRowsStore.existingTableRows(for: tabId)?.rows ?? []
1367+
let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, $0.values) }
13651368

1366-
if rows.count > 1_000 {
1369+
if storageRows.count > 1_000 {
13671370
// Sort on background thread to avoid UI freeze
13681371
activeSortTasks[tabId]?.cancel()
13691372
activeSortTasks.removeValue(forKey: tabId)
@@ -1373,8 +1376,8 @@ final class MainContentCoordinator {
13731376

13741377
let sortStartTime = Date()
13751378
let task = Task.detached { [weak self] in
1376-
let sortedIndices = Self.multiColumnSortIndices(
1377-
rows: rows,
1379+
let sortedIDs = Self.multiColumnSortedIDs(
1380+
rows: snapshotRows,
13781381
sortColumns: sortColumns,
13791382
columnTypes: colTypes
13801383
)
@@ -1388,7 +1391,7 @@ final class MainContentCoordinator {
13881391
return
13891392
}
13901393
self.querySortCache[tabId] = QuerySortCacheEntry(
1391-
sortedIndices: sortedIndices,
1394+
sortedIDs: sortedIDs,
13921395
columnIndex: sortColumns.first?.columnIndex ?? 0,
13931396
direction: sortColumns.first?.direction ?? .ascending,
13941397
schemaVersion: schemaVersion
@@ -1430,13 +1433,12 @@ final class MainContentCoordinator {
14301433
}
14311434
}
14321435

1433-
/// Multi-column sort returning index permutation (nonisolated for background thread).
1434-
/// Returns an array of indices into the original `rows` array, sorted by the given columns.
1435-
nonisolated private static func multiColumnSortIndices(
1436-
rows: [[String?]],
1436+
/// Multi-column sort returning a permutation of `RowID` (nonisolated for background thread).
1437+
nonisolated private static func multiColumnSortedIDs(
1438+
rows: [(id: RowID, values: [String?])],
14371439
sortColumns: [SortColumn],
14381440
columnTypes: [ColumnType] = []
1439-
) -> [Int] {
1441+
) -> [RowID] {
14401442
// Fast path: single-column sort avoids intermediate key array allocation
14411443
if sortColumns.count == 1 {
14421444
let col = sortColumns[0]
@@ -1445,18 +1447,20 @@ final class MainContentCoordinator {
14451447
let colType = colIndex < columnTypes.count ? columnTypes[colIndex] : nil
14461448
var indices = Array(0..<rows.count)
14471449
indices.sort { i1, i2 in
1448-
let v1 = colIndex < rows[i1].count ? (rows[i1][colIndex] ?? "") : ""
1449-
let v2 = colIndex < rows[i2].count ? (rows[i2][colIndex] ?? "") : ""
1450+
let row1 = rows[i1].values
1451+
let row2 = rows[i2].values
1452+
let v1 = colIndex < row1.count ? (row1[colIndex] ?? "") : ""
1453+
let v2 = colIndex < row2.count ? (row2[colIndex] ?? "") : ""
14501454
let cmp = RowSortComparator.compare(v1, v2, columnType: colType)
14511455
return ascending ? cmp == .orderedAscending : cmp == .orderedDescending
14521456
}
1453-
return indices
1457+
return indices.map { rows[$0].id }
14541458
}
14551459

14561460
var indices = Array(0..<rows.count)
14571461
indices.sort { i1, i2 in
1458-
let row1 = rows[i1]
1459-
let row2 = rows[i2]
1462+
let row1 = rows[i1].values
1463+
let row2 = rows[i2].values
14601464
for sortCol in sortColumns {
14611465
let v1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex] ?? "") : ""
14621466
let v2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex] ?? "") : ""
@@ -1470,6 +1474,6 @@ final class MainContentCoordinator {
14701474
}
14711475
return false
14721476
}
1473-
return indices
1477+
return indices.map { rows[$0].id }
14741478
}
14751479
}

TablePro/Views/Results/DataGridCoordinator.swift

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
2020
var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in }
2121
var changeManager: AnyChangeManager
2222
var isEditable: Bool
23+
var sortedIDs: [RowID]?
2324
weak var delegate: (any DataGridViewDelegate)?
2425
weak var activeFKPreviewPopover: NSPopover?
2526
var dropdownColumns: Set<Int>?
@@ -201,6 +202,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
201202
rowVisualStateCache.removeAll()
202203
cachedRowCount = 0
203204
cachedColumnCount = 0
205+
sortedIDs = nil
204206
// Remove columns and reload to release cell views
205207
if let tableView {
206208
while let col = tableView.tableColumns.last {
@@ -259,6 +261,26 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
259261
lastIdentity = nil
260262
}
261263

264+
func displayRow(at displayIndex: Int) -> Row? {
265+
let tableRows = tableRowsProvider()
266+
if let sorted = sortedIDs {
267+
guard displayIndex >= 0, displayIndex < sorted.count else { return nil }
268+
return tableRows.row(withID: sorted[displayIndex])
269+
}
270+
guard displayIndex >= 0, displayIndex < tableRows.count else { return nil }
271+
return tableRows.rows[displayIndex]
272+
}
273+
274+
func tableRowsIndex(forDisplayRow displayIndex: Int) -> Int? {
275+
if let sorted = sortedIDs {
276+
guard displayIndex >= 0, displayIndex < sorted.count else { return nil }
277+
return tableRowsProvider().index(of: sorted[displayIndex])
278+
}
279+
let count = tableRowsProvider().count
280+
guard displayIndex >= 0, displayIndex < count else { return nil }
281+
return displayIndex
282+
}
283+
262284
func applyDelta(_ delta: Delta) {
263285
switch delta {
264286
case .cellChanged(let row, let column):
@@ -287,15 +309,37 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
287309
tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet)
288310
case .rowsInserted(let indices):
289311
guard !indices.isEmpty else { return }
312+
appendInsertedIDsToSortedIDs(at: indices)
290313
applyInsertedRows(indices)
291314
case .rowsRemoved(let indices):
292315
guard !indices.isEmpty else { return }
316+
removeMissingIDsFromSortedIDs()
293317
applyRemovedRows(indices)
294318
case .columnsReplaced, .fullReplace:
319+
sortedIDs = nil
295320
applyFullReplace()
296321
}
297322
}
298323

324+
private func appendInsertedIDsToSortedIDs(at indices: IndexSet) {
325+
guard sortedIDs != nil else { return }
326+
let tableRows = tableRowsProvider()
327+
for index in indices where index >= 0 && index < tableRows.count {
328+
sortedIDs?.append(tableRows.rows[index].id)
329+
}
330+
}
331+
332+
private func removeMissingIDsFromSortedIDs() {
333+
guard sortedIDs != nil else { return }
334+
let tableRows = tableRowsProvider()
335+
var survivingIDs = Set<RowID>()
336+
survivingIDs.reserveCapacity(tableRows.count)
337+
for row in tableRows.rows {
338+
survivingIDs.insert(row.id)
339+
}
340+
sortedIDs?.removeAll { !survivingIDs.contains($0) }
341+
}
342+
299343
func invalidateCachesForUndoRedo() {
300344
rowProvider.invalidateDisplayCache()
301345
rebuildVisualStateCache()
@@ -369,8 +413,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
369413

370414
let tableRows = tableRowsProvider()
371415
var insertedRowIndices = Set<Int>()
372-
for (index, row) in tableRows.rows.enumerated() where row.id.isInserted {
373-
insertedRowIndices.insert(index)
416+
if let sorted = sortedIDs {
417+
for (displayIndex, id) in sorted.enumerated() where id.isInserted {
418+
insertedRowIndices.insert(displayIndex)
419+
}
420+
} else {
421+
for (index, row) in tableRows.rows.enumerated() where row.id.isInserted {
422+
insertedRowIndices.insert(index)
423+
}
374424
}
375425

376426
if !changeManager.hasChanges && insertedRowIndices.isEmpty {
@@ -413,6 +463,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
413463
// MARK: - NSTableViewDataSource
414464

415465
func numberOfRows(in tableView: NSTableView) -> Int {
416-
tableRowsProvider().count
466+
sortedIDs?.count ?? tableRowsProvider().count
417467
}
418468
}

0 commit comments

Comments
 (0)