diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index fd75ffc9a..20bed91e6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -504,9 +504,6 @@ struct MainEditorContentView: View { } }, changeManager: currentChangeManager, - schemaVersion: tab.schemaVersion, - metadataVersion: tab.metadataVersion, - paginationVersion: tab.paginationVersion, isEditable: isEditable, configuration: DataGridConfiguration( connectionId: connection.id, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift index 7d6efb065..761f4e493 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Pagination.swift @@ -97,6 +97,7 @@ extension MainContentCoordinator { mutate(&self.tabManager.tabs[idx].pagination) self.tabManager.tabs[idx].paginationVersion += 1 + self.pendingScrollToTopAfterReplace.insert(tabId) self.reloadCurrentPage() } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 2d52d6238..4359ba0f8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -493,6 +493,11 @@ extension MainContentCoordinator { return .columnsReplaced } tabManager.tabs[idx].metadataVersion += 1 + if let activeIdx = tabManager.selectedTabIndex, + activeIdx < tabManager.tabs.count, + tabManager.tabs[activeIdx].id == tabId { + dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns() + } } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index e252f41c6..500d8694b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -45,5 +45,8 @@ extension MainContentCoordinator { idx < tabManager.tabs.count, tabManager.tabs[idx].id == tabId else { return } dataTabDelegate?.tableViewCoordinator?.applyFullReplace() + if pendingScrollToTopAfterReplace.remove(tabId) != nil { + dataTabDelegate?.tableViewCoordinator?.scrollToTop() + } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index cf35d3462..5d7db8ed6 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -147,6 +147,8 @@ final class MainContentCoordinator { /// Cache for async-sorted query tab rows (large datasets sorted on background thread) @ObservationIgnored var querySortCache: [UUID: QuerySortCacheEntry] = [:] + @ObservationIgnored var pendingScrollToTopAfterReplace: Set = [] + // MARK: - Internal State /// Cached column types per table for selective queries (avoids refetching schema). diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 06665fc74..9e568927a 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -60,7 +60,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData @Binding var selectedRowIndices: Set - var lastIdentity: DataGridIdentity? private(set) var cachedRowCount: Int = 0 private(set) var cachedColumnCount: Int = 0 private(set) var enumOrSetColumns: Set = [] @@ -213,7 +212,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData rebuildVisualStateCache() updateCache() tableView.insertRows(at: indices, withAnimation: .slideDown) - lastIdentity = nil } func applyRemovedRows(_ indices: IndexSet) { @@ -221,7 +219,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData rebuildVisualStateCache() updateCache() tableView.removeRows(at: indices, withAnimation: .slideUp) - lastIdentity = nil } func applyFullReplace() { @@ -230,7 +227,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData rebuildVisualStateCache() updateCache() tableView.reloadData() - lastIdentity = nil } func displayRow(at displayIndex: Int) -> Row? { @@ -422,6 +418,32 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData tableView.editColumn(displayCol, row: displayRow, with: nil, select: true) } + func refreshForeignKeyColumns() { + guard let tableView else { return } + let tableRows = tableRowsProvider() + let fkColumnIndices = IndexSet( + tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in + guard tableColumn.identifier.rawValue != "__rowNumber__", + let modelIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier), + modelIndex < tableRows.columns.count else { return nil } + let columnName = tableRows.columns[modelIndex] + return tableRows.columnForeignKeys[columnName] != nil ? displayIndex : nil + } + ) + guard !fkColumnIndices.isEmpty else { return } + let visibleRange = tableView.rows(in: tableView.visibleRect) + guard visibleRange.length > 0 else { return } + let visibleRows = IndexSet( + integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length) + ) + tableView.reloadData(forRowIndexes: visibleRows, columnIndexes: fkColumnIndices) + } + + func scrollToTop() { + guard let tableView, tableView.numberOfRows > 0 else { return } + tableView.scrollRowToVisible(0) + } + func rebuildColumnMetadataCache(from tableRows: TableRows) { var enumSet = Set() var fkSet = Set() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 8eeba0714..6de54f500 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -22,40 +22,10 @@ struct RowVisualState { static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) } -struct DataGridIdentity: Equatable { - let schemaVersion: Int - let metadataVersion: Int - let paginationVersion: Int - let rowCount: Int - let columnCount: Int - let isEditable: Bool - let tabType: TabType? - let tableName: String? - let primaryKeyColumns: [String] - let hiddenColumns: Set - - init(schemaVersion: Int, metadataVersion: Int, paginationVersion: Int, - rowCount: Int, columnCount: Int, isEditable: Bool, configuration: DataGridConfiguration) { - self.schemaVersion = schemaVersion - self.metadataVersion = metadataVersion - self.paginationVersion = paginationVersion - self.rowCount = rowCount - self.columnCount = columnCount - self.isEditable = isEditable - self.tabType = configuration.tabType - self.tableName = configuration.tableName - self.primaryKeyColumns = configuration.primaryKeyColumns - self.hiddenColumns = configuration.hiddenColumns - } -} - struct DataGridView: NSViewRepresentable { var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } var changeManager: AnyChangeManager - var schemaVersion: Int = 0 - var metadataVersion: Int = 0 - var paginationVersion: Int = 0 let isEditable: Bool var configuration: DataGridConfiguration = .init() var sortedIDs: [RowID]? @@ -228,27 +198,6 @@ struct DataGridView: NSViewRepresentable { let rowDisplayCount = sortedIDs?.count ?? latestRows.count let columnCount = latestRows.columns.count - let currentIdentity = DataGridIdentity( - schemaVersion: schemaVersion, - metadataVersion: metadataVersion, - paginationVersion: paginationVersion, - rowCount: rowDisplayCount, - columnCount: columnCount, - isEditable: isEditable, - configuration: configuration - ) - if currentIdentity == coordinator.lastIdentity { - coordinator.delegate = delegate - coordinator.tableRowsProvider = tableRowsProvider - coordinator.tableRowsMutator = tableRowsMutator - coordinator.sortedIDs = sortedIDs - coordinator.syncDisplayFormats(displayFormats) - delegate?.dataGridAttach(tableViewCoordinator: coordinator) - return - } - let previousIdentity = coordinator.lastIdentity - coordinator.lastIdentity = currentIdentity - let settings = AppSettingsManager.shared.dataGrid if tableView.rowHeight != CGFloat(settings.rowHeight.rawValue) { tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) @@ -257,7 +206,6 @@ struct DataGridView: NSViewRepresentable { tableView.usesAlternatingRowBackgroundColors = settings.showAlternateRows } - let metadataChanged = previousIdentity.map { $0.metadataVersion != metadataVersion } ?? false let oldRowCount = coordinator.cachedRowCount let oldColumnCount = coordinator.cachedColumnCount @@ -267,7 +215,7 @@ struct DataGridView: NSViewRepresentable { coordinator.updateCache() coordinator.rebuildColumnMetadataCache(from: latestRows) - if previousIdentity == nil || previousIdentity?.rowCount == 0 { + if oldRowCount == 0, rowDisplayCount > 0 { let rowH = tableView.rowHeight if rowH > 0 { let visibleRows = Int(tableView.visibleRect.height / rowH) + 5 @@ -315,15 +263,10 @@ struct DataGridView: NSViewRepresentable { syncSortDescriptors(tableView: tableView, coordinator: coordinator, columns: latestRows.columns) - let paginationChanged = previousIdentity.map { $0.paginationVersion != paginationVersion } ?? false - reloadAndSyncSelection( tableView: tableView, coordinator: coordinator, - tableRows: latestRows, - needsFullReload: needsFullReload, - metadataChanged: metadataChanged, - paginationChanged: paginationChanged + needsFullReload: needsFullReload ) } @@ -493,36 +436,10 @@ struct DataGridView: NSViewRepresentable { private func reloadAndSyncSelection( tableView: NSTableView, coordinator: TableViewCoordinator, - tableRows: TableRows, - needsFullReload: Bool, - metadataChanged: Bool = false, - paginationChanged: Bool = false + needsFullReload: Bool ) { if needsFullReload { tableView.reloadData() - } else if metadataChanged { - let fkColumnIndices = IndexSet( - tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in - guard tableColumn.identifier.rawValue != "__rowNumber__", - let modelIndex = Self.dataColumnIndex(from: tableColumn.identifier), - modelIndex < tableRows.columns.count else { return nil } - let columnName = tableRows.columns[modelIndex] - return tableRows.columnForeignKeys[columnName] != nil ? displayIndex : nil - } - ) - if !fkColumnIndices.isEmpty { - let visibleRange = tableView.rows(in: tableView.visibleRect) - if visibleRange.length > 0 { - let visibleRows = IndexSet( - integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length) - ) - tableView.reloadData(forRowIndexes: visibleRows, columnIndexes: fkColumnIndices) - } - } - } - - if paginationChanged && tableView.numberOfRows > 0 { - tableView.scrollRowToVisible(0) } let currentSelection = tableView.selectedRowIndexes diff --git a/TablePro/Views/Results/TableViewCoordinating.swift b/TablePro/Views/Results/TableViewCoordinating.swift index 8c4b55bb3..eb526271a 100644 --- a/TablePro/Views/Results/TableViewCoordinating.swift +++ b/TablePro/Views/Results/TableViewCoordinating.swift @@ -9,6 +9,8 @@ protocol TableViewCoordinating: AnyObject { func invalidateCachesForUndoRedo() func commitActiveCellEdit() func beginEditing(displayRow: Int, column: Int) + func refreshForeignKeyColumns() + func scrollToTop() } extension TableViewCoordinator: TableViewCoordinating {} diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 41b867dda..7705b7ce8 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -271,7 +271,6 @@ struct TableStructureView: View { return DataGridView( tableRowsProvider: { tableRows }, changeManager: wrappedChangeManager, - schemaVersion: displayVersion, isEditable: canEdit, configuration: DataGridConfiguration( dropdownColumns: allDropdownColumns, diff --git a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift index 9a8d7d3bb..b89e6d1cc 100644 --- a/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift +++ b/TableProTests/Views/Main/Child/DataTabGridDelegateTests.swift @@ -46,6 +46,12 @@ private final class FakeTableViewCoordinator: TableViewCoordinating { func beginEditing(displayRow: Int, column: Int) { beginEditingCalls.append((row: displayRow, column: column)) } + + var refreshFKCount: Int = 0 + var scrollToTopCount: Int = 0 + + func refreshForeignKeyColumns() { refreshFKCount += 1 } + func scrollToTop() { scrollToTopCount += 1 } } @Suite("DataTabGridDelegate row-delta forwarding") diff --git a/TableProTests/Views/Main/TableRowsMutationTests.swift b/TableProTests/Views/Main/TableRowsMutationTests.swift index 3e1476bec..1816bba88 100644 --- a/TableProTests/Views/Main/TableRowsMutationTests.swift +++ b/TableProTests/Views/Main/TableRowsMutationTests.swift @@ -32,6 +32,11 @@ private final class FakeTableViewCoordinator: TableViewCoordinating { func beginEditing(displayRow: Int, column: Int) { beginEditingCalls.append((row: displayRow, column: column)) } + + var refreshFKCount = 0 + var scrollToTopCount = 0 + func refreshForeignKeyColumns() { refreshFKCount += 1 } + func scrollToTop() { scrollToTopCount += 1 } } @Suite("setActiveTableRows dispatch") @@ -106,6 +111,45 @@ struct TableRowsMutationTests { #expect(f.fake.fullReplaceCount == 2) } + @Test("setActiveTableRows dispatches scrollToTop when pendingScrollToTopAfterReplace contains tabId") + func scrollToTopFiresOnPendingFlag() { + let f = makeFixture() + f.tabManager.addTableTab(tableName: "users") + let activeTabId = f.tabManager.tabs[0].id + + f.coordinator.pendingScrollToTopAfterReplace.insert(activeTabId) + f.coordinator.setActiveTableRows(makeTableRows(rowCount: 3), for: activeTabId) + + #expect(f.fake.scrollToTopCount == 1) + #expect(f.coordinator.pendingScrollToTopAfterReplace.contains(activeTabId) == false) + } + + @Test("scrollToTop pending flag for tab A does not fire when tab B is replaced") + func scrollToTopFlagIsScopedPerTab() { + let f = makeFixture() + f.tabManager.addTableTab(tableName: "users") + let firstTabId = f.tabManager.tabs[0].id + f.tabManager.addTableTab(tableName: "orders") + let secondTabId = f.tabManager.tabs[1].id + + f.coordinator.pendingScrollToTopAfterReplace.insert(firstTabId) + f.coordinator.setActiveTableRows(makeTableRows(rowCount: 3), for: secondTabId) + + #expect(f.fake.scrollToTopCount == 0) + #expect(f.coordinator.pendingScrollToTopAfterReplace.contains(firstTabId) == true) + } + + @Test("setActiveTableRows without pending flag does not scroll to top") + func scrollToTopSkippedWhenFlagAbsent() { + let f = makeFixture() + f.tabManager.addTableTab(tableName: "users") + let activeTabId = f.tabManager.tabs[0].id + + f.coordinator.setActiveTableRows(makeTableRows(rowCount: 3), for: activeTabId) + + #expect(f.fake.scrollToTopCount == 0) + } + @Test("setActiveTableRows is a no-op when delegate is unwired") func unwiredDelegateIsNoOp() { let tabManager = QueryTabManager() diff --git a/TableProTests/Views/Results/DataGridIdentityTests.swift b/TableProTests/Views/Results/DataGridIdentityTests.swift deleted file mode 100644 index 3d6da157a..000000000 --- a/TableProTests/Views/Results/DataGridIdentityTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// DataGridIdentityTests.swift -// TableProTests -// -// Tests for DataGridIdentity equality used to skip redundant updateNSView calls. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("DataGridIdentity") -struct DataGridIdentityTests { - private func makeIdentity( - 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( - schemaVersion: schemaVersion, - metadataVersion: metadataVersion, - paginationVersion: paginationVersion, - rowCount: rowCount, - columnCount: columnCount, - isEditable: isEditable, - configuration: config - ) - } - - @Test("Same values produce equal identities") - func sameValuesAreEqual() { - #expect(makeIdentity() == makeIdentity()) - } - - @Test("Different schemaVersion produces unequal identities") - func differentSchemaVersion() { - #expect(makeIdentity(schemaVersion: 2) != makeIdentity(schemaVersion: 3)) - } - - @Test("Different metadataVersion produces unequal identities") - func differentMetadataVersion() { - #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() { - #expect(makeIdentity(rowCount: 100) != makeIdentity(rowCount: 200)) - } - - @Test("Different columnCount produces unequal identities") - func differentColumnCount() { - #expect(makeIdentity(columnCount: 5) != makeIdentity(columnCount: 10)) - } - - @Test("Different isEditable produces unequal identities") - func differentIsEditable() { - #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() { - #expect(makeIdentity(hiddenColumns: []) != makeIdentity(hiddenColumns: ["name"])) - } - - @Test("Same hiddenColumns produces equal identities") - func sameHiddenColumns() { - #expect(makeIdentity(hiddenColumns: ["name", "email"]) == makeIdentity(hiddenColumns: ["name", "email"])) - } -}