diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2d6ca54..29c6ecc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updates download in the background and install when you quit, instead of asking each time. - New versions roll out over 36 hours instead of reaching everyone at once. - The update window shows the release highlights, with the full changelog one click away. +- Data grid top row held across a refresh, matched by primary key. +- Data grid scroll reset to the first row on sort, filter and page change. ### Fixed @@ -38,6 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unchecked and soloed filter rows dropped from a table's saved filters after switching tabs. - Table opened in another database from a link, MCP or AppleScript bound to the current schema. - Recent table opened before connecting reopening a same-named table in another schema, and listed twice. +- Inline cell editor left open over a different row after a refresh. +- Row selected by Back or Forward dropped once the table finished loading. +- Data grid jumping to the top on a later reload after a page change failed. ## [0.74.0] - 2026-09-13 diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 8bc43fddf..c3b41d8ba 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -82,7 +82,7 @@ final class FilterCoordinator { $0.filterState.executedFilters = filters } saveLastFilters(of: parent.tabManager.tabs[tabIndex]) - parent.runQuery() + parent.runQuery(viewport: .firstRow) } func clearFiltersAndReload() { @@ -114,7 +114,7 @@ final class FilterCoordinator { $0.filterState.executedFilters = [] } clearLastFilters(for: capturedTableName) - parent.runQuery() + parent.runQuery(viewport: .firstRow) } } @@ -149,7 +149,7 @@ final class FilterCoordinator { parent.tabManager.mutate(at: capturedTabIndex) { $0.pagination.reset() } rebuildTableQuery(at: capturedTabIndex) saveBrowseSearch(for: capturedTableName) - parent.runQuery() + parent.runQuery(viewport: .firstRow) } } @@ -169,7 +169,7 @@ final class FilterCoordinator { parent.tabManager.mutate(at: capturedTabIndex) { $0.pagination.reset() } rebuildTableQuery(at: capturedTabIndex) saveBrowseSearch(for: capturedTableName) - parent.runQuery() + parent.runQuery(viewport: .firstRow) } } diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index d17c6b363..f41a748f3 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -135,7 +135,6 @@ final class PaginationCoordinator { mutate(&tab.pagination) tab.paginationVersion += 1 }) else { return } - parent.pendingScrollToTopAfterReplace.insert(tabId) reloadCurrentPage() } } @@ -145,7 +144,7 @@ final class PaginationCoordinator { tabIndex < parent.tabManager.tabs.count else { return } parent.rebuildTableQuery(at: tabIndex) - parent.runQuery() + parent.runQuery(viewport: .firstRow) } // MARK: - Cancel Current Query diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 195c25039..ca1050694 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -185,7 +185,8 @@ extension QueryExecutionCoordinator { queryParameterValues: [QueryParameter]? = nil, historySQL: String? = nil, anchor: StatementAnchor? = nil, - timing: PluginQueryTiming? = nil + timing: PluginQueryTiming? = nil, + viewport: GridReloadIntent = .firstRow ) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } @@ -238,7 +239,7 @@ extension QueryExecutionCoordinator { ) let previousTableName = parent.tabManager.tabs[idx].tableContext.tableName parent.flushBufferToActiveResult(tabId: existingTabId, pinnedOnly: true) - parent.setActiveTableRows(newTableRows, for: existingTabId) + parent.setActiveTableRows(newTableRows, for: existingTabId, viewport: viewport) parent.tabManager.mutate(at: idx) { tab in tab.schemaVersion += 1 diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift index 9dad2bed9..68ce12e14 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift @@ -333,7 +333,7 @@ extension RowEditingCoordinator { /// retiring it the reload's automatic count refuses to replace it, and the bar /// keeps the pre-save total with no `Count Exactly` offered to correct it. parent.tabManager.mutate(at: savedTabIndex) { $0.pagination.retireDerivedRowCount() } - parent.runQuery() + parent.runQuery(viewport: .keepPlace) } /// MySQL, MariaDB and Oracle commit each DROP as it runs, so a save that failed part way can diff --git a/TablePro/Core/DataGrid/GridViewportResolver.swift b/TablePro/Core/DataGrid/GridViewportResolver.swift new file mode 100644 index 000000000..8f31a1628 --- /dev/null +++ b/TablePro/Core/DataGrid/GridViewportResolver.swift @@ -0,0 +1,141 @@ +// +// GridViewportResolver.swift +// TablePro +// + +import CoreGraphics +import Foundation +import TableProPluginKit + +internal struct GridViewportSnapshot: Equatable { + let firstVisiblePosition: Int + let firstVisibleOffset: CGFloat + let firstVisibleKey: [String: String]? + + static let top = GridViewportSnapshot(firstVisiblePosition: 0, firstVisibleOffset: 0, firstVisibleKey: nil) +} + +internal struct GridViewportPlacement: Equatable { + let firstVisibleRow: RowID? + let firstVisibleOffset: CGFloat + let selectedRows: [RowID] + let revealsSelection: Bool + + static let firstRow = GridViewportPlacement( + firstVisibleRow: nil, + firstVisibleOffset: 0, + selectedRows: [], + revealsSelection: false + ) +} + +internal struct GridViewportStage: Equatable { + let bufferEpoch: Int + let placement: GridViewportPlacement +} + +internal enum GridViewportResolver { + static let keyedRowLimit = 50_000 + + static func snapshot( + of tableRows: TableRows, + displayIDs: [RowID]?, + firstVisibleDisplayRow: Int, + firstVisibleOffset: CGFloat, + keyColumns: [String], + isCellModified: (RowID, Int) -> Bool = { _, _ in false } + ) -> GridViewportSnapshot { + let isKeyed = !keyColumns.isEmpty && tableRows.count <= keyedRowLimit + let firstVisibleKey = isKeyed + ? DisplayRowMapping.row(forDisplay: firstVisibleDisplayRow, displayIDs: displayIDs, in: tableRows) + .flatMap { savedKey(of: $0, in: tableRows, keyColumns: keyColumns, isCellModified: isCellModified) } + : nil + return GridViewportSnapshot( + firstVisiblePosition: max(0, firstVisibleDisplayRow), + firstVisibleOffset: max(0, firstVisibleOffset), + firstVisibleKey: firstVisibleKey + ) + } + + static func placement( + for intent: GridReloadIntent, + from snapshot: GridViewportSnapshot, + in incoming: TableRows, + keyColumns: [String] + ) -> GridViewportPlacement { + guard !incoming.rows.isEmpty else { return .firstRow } + switch intent { + case .firstRow: + return .firstRow + case .keepPlace: + return keptPlace(from: snapshot, in: incoming, keyColumns: keyColumns) + case .restoreRow(let anchor): + guard let row = uniqueRow(matching: anchor, in: incoming) else { return .firstRow } + return GridViewportPlacement( + firstVisibleRow: nil, + firstVisibleOffset: 0, + selectedRows: [row], + revealsSelection: true + ) + } + } + + private static func savedKey( + of row: Row, + in tableRows: TableRows, + keyColumns: [String], + isCellModified: (RowID, Int) -> Bool + ) -> [String: String]? { + guard !row.id.isInserted else { return nil } + return NavigationRowAnchor.build( + keyColumns: keyColumns, + columns: tableRows.columns, + values: row.values, + isModified: { isCellModified(row.id, $0) } + ) + } + + private static func keptPlace( + from snapshot: GridViewportSnapshot, + in incoming: TableRows, + keyColumns: [String] + ) -> GridViewportPlacement { + guard snapshot.firstVisiblePosition > 0 || snapshot.firstVisibleOffset > 0 else { return .firstRow } + + if let firstVisibleKey = snapshot.firstVisibleKey, + !keyColumns.isEmpty, + incoming.count <= keyedRowLimit, + let anchoredRow = uniqueRow(matching: firstVisibleKey, in: incoming) { + return GridViewportPlacement( + firstVisibleRow: anchoredRow, + firstVisibleOffset: snapshot.firstVisibleOffset, + selectedRows: [], + revealsSelection: false + ) + } + + let position = min(snapshot.firstVisiblePosition, incoming.rows.count - 1) + return GridViewportPlacement( + firstVisibleRow: incoming.rows[position].id, + firstVisibleOffset: position == snapshot.firstVisiblePosition ? snapshot.firstVisibleOffset : 0, + selectedRows: [], + revealsSelection: false + ) + } + + private static func uniqueRow(matching values: [String: String], in incoming: TableRows) -> RowID? { + guard !values.isEmpty else { return nil } + var columnValues: [(index: Int, value: String)] = [] + for (column, value) in values { + guard let index = incoming.columns.firstIndex(of: column) else { return nil } + columnValues.append((index, value)) + } + + var match: RowID? + for row in incoming.rows where columnValues.allSatisfy({ row[$0.index].asText == $0.value }) { + guard match == nil else { return nil } + match = row.id + } + return match + } +} diff --git a/TablePro/Models/Query/GridReloadIntent.swift b/TablePro/Models/Query/GridReloadIntent.swift new file mode 100644 index 000000000..a3a79776d --- /dev/null +++ b/TablePro/Models/Query/GridReloadIntent.swift @@ -0,0 +1,12 @@ +// +// GridReloadIntent.swift +// TablePro +// + +import Foundation + +internal enum GridReloadIntent: Equatable, Sendable { + case firstRow + case keepPlace + case restoreRow([String: String]) +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 11fbf0ed8..88eb15be8 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -111,6 +111,7 @@ struct QueryTab: Identifiable, Equatable { /// offset is recomputed as `(page - 1) * pageSize`, so reading the index in a different size /// lands the tab on rows it was never showing. var restoredPageSize: Int? + var restoredRowAnchor: [String: String]? var restoredCursorOffset: Int? var restoredCursorLength: Int? diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 76464fd89..6a6eba9fc 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -512,6 +512,7 @@ final class QueryTabManager { tab.pendingRestoredSort = nil tab.restoredPage = nil tab.restoredPageSize = nil + tab.restoredRowAnchor = nil tab.tableContext.databaseName = databaseName tab.tableContext.schemaName = schemaName tab.isPreview = isPreview diff --git a/TablePro/Models/Query/TabSession.swift b/TablePro/Models/Query/TabSession.swift index dfb70d3aa..f3042969b 100644 --- a/TablePro/Models/Query/TabSession.swift +++ b/TablePro/Models/Query/TabSession.swift @@ -45,6 +45,8 @@ final class TabSession: Identifiable { /// edit leaves the rows where they are and the grid goes on showing them there. var rowSetRevision: Int + @ObservationIgnored var viewportStage: GridViewportStage? + init(id: UUID = UUID()) { self.id = id self.tableRows = TableRows() diff --git a/TablePro/Models/Query/TabSessionRegistry.swift b/TablePro/Models/Query/TabSessionRegistry.swift index c73ccac59..9ca86b4b6 100644 --- a/TablePro/Models/Query/TabSessionRegistry.swift +++ b/TablePro/Models/Query/TabSessionRegistry.swift @@ -98,6 +98,18 @@ final class TabSessionRegistry { session.rowSetRevision &+= 1 } + func stageViewportPlacement(_ placement: GridViewportPlacement, for tabId: UUID) { + guard let session = sessions[tabId] else { return } + session.viewportStage = GridViewportStage(bufferEpoch: session.bufferEpoch, placement: placement) + } + + func takeViewportPlacement(for tabId: UUID) -> GridViewportPlacement? { + guard let session = sessions[tabId], let stage = session.viewportStage else { return nil } + session.viewportStage = nil + guard stage.bufferEpoch == session.bufferEpoch else { return nil } + return stage.placement + } + private func ensureSession(for tabId: UUID) -> TabSession { if let existing = sessions[tabId] { return existing diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bd64b2ddc..233922931 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -403,7 +403,7 @@ struct MainEditorContentView: View { tabManager.markTabRenamed(tabId) SchemaProviderRegistry.shared.reclaimUnheldProviders(for: connectionId) guard tabManager.selectedTabId == tabId else { return } - coordinator.runQuery() + coordinator.runQuery(viewport: .firstRow) } // MARK: - Query Tab Content @@ -437,8 +437,8 @@ struct MainEditorContentView: View { cursorPositions: $bindableCoordinator.cursorPositions, parameters: parameterBinding(for: tab), isParameterPanelVisible: parameterVisibilityBinding(for: tab), - onExecute: { coordinator.runQuery() }, - onExecuteWithoutLimit: { coordinator.runQuery(bypassRowLimit: true) }, + onExecute: { coordinator.runQuery(viewport: .firstRow) }, + onExecuteWithoutLimit: { coordinator.runQuery(viewport: .firstRow, bypassRowLimit: true) }, onExecuteAllStatements: { coordinator.runAllStatements() }, schemaProvider: queryScope.map { SchemaProviderRegistry.shared.getOrCreate(for: $0) }, databaseType: coordinator.connection.type, @@ -462,7 +462,7 @@ struct MainEditorContentView: View { onCloseTab: { coordinator.commandActions?.closeTab() }, - onExecuteQuery: { coordinator.runQuery() }, + onExecuteQuery: { coordinator.runQuery(viewport: .firstRow) }, onRunStatement: { sql, offset in coordinator.runStatement(sql, sourceOffset: offset) }, isExecuting: coordinator.tabExecution.isExecuting(tab.id), showsHistoryTip: showsHistoryTip, @@ -961,6 +961,9 @@ struct MainEditorContentView: View { restoredCellSelection: tab.cellSelection, onSelectionTeardown: { [coordinator] rows, cells in coordinator.storeGridSelectionOnTeardown(rows: rows, cells: cells, forTab: tabId) + }, + viewportPlacementProvider: { [coordinator] in + coordinator.takeViewportPlacement(forTab: tabId) } ) .id(tabId) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift index 100193ec2..b1260ffbc 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift @@ -16,6 +16,7 @@ extension MainContentCoordinator { action: DiscardAction, completion: @escaping (Bool) -> Void ) { + dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() guard changeManager.hasChanges else { completion(true) return diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index 998854947..09b2bdd1e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -32,7 +32,7 @@ extension MainContentCoordinator { guard !Task.isCancelled else { return } } guard await self.rebuildSelectedTableColumnScopedQuery() else { return } - self.runQuery() + self.runQuery(viewport: .keepPlace) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index 3371b8de6..73af1a1ae 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -53,7 +53,7 @@ extension MainContentCoordinator { /// well as the edits. Only the first change in a run prompts: confirming clears the changes, so /// every later toggle finds nothing to lose and passes straight through. private func changeColumnScope(_ mutate: @escaping (inout Set) -> Void) { - confirmDiscardChangesIfNeeded(action: .columnVisibility) { [weak self] confirmed in + confirmDiscardRestoringRowsIfNeeded(action: .columnVisibility) { [weak self] confirmed in guard confirmed, let self else { return } self.mutateSelectedTabHiddenColumns(mutate) self.requeryWithColumnScope(debounced: true) @@ -110,7 +110,7 @@ extension MainContentCoordinator { /// confirmation. The width half is not undone by declining, because nothing about a width can /// invalidate an edit; only the refetch can. func resetColumns() { - confirmDiscardChangesIfNeeded(action: .columnVisibility) { [weak self] confirmed in + confirmDiscardRestoringRowsIfNeeded(action: .columnVisibility) { [weak self] confirmed in guard confirmed, let self else { return } self.applyColumnReset() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+GridViewport.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridViewport.swift new file mode 100644 index 000000000..619dcb2d6 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+GridViewport.swift @@ -0,0 +1,55 @@ +// +// MainContentCoordinator+GridViewport.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + func takeViewportPlacement(forTab tabId: UUID) -> GridViewportPlacement? { + tabSessionRegistry.takeViewportPlacement(for: tabId) + } + + func restoredRowAnchor(forTab tabId: UUID) -> [String: String]? { + tabManager.tabs.first(where: { $0.id == tabId })?.restoredRowAnchor + } + + func clearRestoredRowAnchor(forTab tabId: UUID) { + guard restoredRowAnchor(forTab: tabId) != nil else { return } + tabManager.mutate(tabId: tabId) { $0.restoredRowAnchor = nil } + } + + func isGridMounted(forTab tabId: UUID) -> Bool { + guard tabManager.selectedTabId == tabId, + let tab = tabManager.selectedTab, + tab.tabType == .table, + tab.display.resultsViewMode == .data else { return false } + return dataTabDelegate?.tableViewCoordinator != nil + } + + func viewportKeyColumns(forTab tabId: UUID) -> [String] { + tabManager.tabs.first(where: { $0.id == tabId })?.tableContext.primaryKeyColumns ?? [] + } + + func viewportSnapshot( + forTab tabId: UUID, + intent: GridReloadIntent, + keyColumns: [String] + ) -> GridViewportSnapshot { + guard intent == .keepPlace, + let tableRows = tabSessionRegistry.existingTableRows(for: tabId), + !tableRows.rows.isEmpty, + let sample = dataTabDelegate?.tableViewCoordinator?.viewportSample() else { return .top } + + return GridViewportResolver.snapshot( + of: tableRows, + displayIDs: displayIDs(forTab: tabId), + firstVisibleDisplayRow: sample.firstVisibleRow, + firstVisibleOffset: sample.offset, + keyColumns: keyColumns, + isCellModified: { [changeManager] rowID, column in + changeManager.isCellModified(rowID: rowID, columnIndex: column) + } + ) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 04c1a4099..56359845b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -439,14 +439,14 @@ extension MainContentCoordinator { initialQuery: "db.runCommand({\"listCollections\": 1, \"nameOnly\": false})", databaseName: browseDatabaseName ) - runQuery() + runQuery(viewport: .firstRow) return nil } else if editorLang == .bash { tabManager.addTab( initialQuery: "SCAN 0 MATCH * COUNT 100", databaseName: browseDatabaseName ) - runQuery() + runQuery(viewport: .firstRow) return nil } @@ -735,7 +735,7 @@ extension MainContentCoordinator { } guard !Task.isCancelled else { return } toolbarState.currentDatabase = database - executeTableTabQueryDirectly() + executeTableTabQueryDirectly(viewport: .firstRow) let separator = connection.additionalFields["redisSeparator"] ?? ":" if sidebarViewModel?.redisKeyTreeViewModel == nil { @@ -795,6 +795,6 @@ extension MainContentCoordinator { query = "GET \"\(escapedKey)\"" } tabManager.addTab(initialQuery: query, title: keyName) - runQuery() + runQuery(viewport: .firstRow) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift index aee86b9c0..8024f3cb2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift @@ -197,14 +197,12 @@ extension MainContentCoordinator { } tab.restoredPage = max(1, entry.page) tab.restoredPageSize = entry.pageSize + tab.restoredRowAnchor = entry.anchorRowKey } restoreLastHiddenColumnsForTable() filterCoordinator.rebuildTableQuery(at: tabIndex) cancelTableLoad(for: tabId) - /// Keyed by tab because one `TableViewCoordinator` serves every tab in the window. An - /// anchor with no tab attached would be spent by whichever tab's rows landed next. - pendingRowAnchors[tabId] = entry.anchorRowKey lazyLoadCurrentTabIfNeeded() return true } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index a920f28fb..077f74ed3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -130,7 +130,8 @@ extension MainContentCoordinator { isTruncated: Bool = false, queryParameterValues: [QueryParameter]? = nil, anchor: StatementAnchor? = nil, - timing: PluginQueryTiming? = nil + timing: PluginQueryTiming? = nil, + viewport: GridReloadIntent = .firstRow ) { queryExecutionCoordinator.applyPhase1Result( tabId: tabId, @@ -149,7 +150,8 @@ extension MainContentCoordinator { isTruncated: isTruncated, queryParameterValues: queryParameterValues, anchor: anchor, - timing: timing + timing: timing, + viewport: viewport ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index 1582b1182..746b7073b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -56,6 +56,7 @@ extension MainContentCoordinator { tab.tabType == .table, tab.display.resultsViewMode != .structure else { return } + dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() guard changeManager.hasChanges || hasPendingTableOps else { reloadTableTab(at: tabIndex) return @@ -65,6 +66,7 @@ extension MainContentCoordinator { let confirmed = await confirmDiscardChanges(action: .refresh, window: contentWindow) guard confirmed else { return } onDiscard() + rowEditingCoordinator.restoreRowBufferToOriginals() changeManager.clearChangesAndUndoHistory() guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.tabType == .table else { return } @@ -80,6 +82,6 @@ extension MainContentCoordinator { /// count to an estimate. tabManager.mutate(at: tabIndex) { $0.pagination.retireDerivedRowCount() } rebuildTableQuery(at: tabIndex) - runQuery() + runQuery(viewport: .keepPlace) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Rewind.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rewind.swift index a5016452f..2a15f8e19 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Rewind.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rewind.swift @@ -163,7 +163,7 @@ extension MainContentCoordinator { Self.rewindLogger.info("Rewind restored \(result.restoredRows, privacy: .public) rows") rewindPlan = nil if tabManager.selectedTab?.tableContext.tableName == plan.record.target.table { - runQuery() + runQuery(viewport: .keepPlace) } } catch { let writeError = error as? DataWriteError diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index eafcfe332..72ca2f385 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -18,7 +18,7 @@ extension MainContentCoordinator { guard !statements.isEmpty else { return } try await executeSidebarChanges(statements: statements) - runQuery() + runQuery(viewport: .keepPlace) } func sidebarEditStatements( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift index 9f2b44ad3..c952aef4d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift @@ -52,7 +52,6 @@ extension MainContentCoordinator { prune(&tableMetadataCache, keeping: openTabIds) prune(&createTableDrafts, keeping: openTabIds) prune(&navigationHistories, keeping: openTabIds) - prune(&pendingRowAnchors, keeping: openTabIds) toolbarState.forgetQueryTimings(keeping: openTabIds) for (tabId, session) in structureSessions where !openTabIds.contains(tabId) { session.releaseViewWiring() @@ -74,7 +73,6 @@ extension MainContentCoordinator { /// them. `selectedTabHoldsProtectedContent` is what stops a tab holding real work being /// retargeted at all; this is what keeps the caches honest once one without work has been. func releaseRetargetedTabState(for tabId: UUID) { - pendingRowAnchors.removeValue(forKey: tabId) displayStateCache.removeValue(forKey: tabId) tableMetadataCache.removeValue(forKey: tabId) structureSessions.removeValue(forKey: tabId)?.releaseViewWiring() @@ -102,7 +100,6 @@ extension MainContentCoordinator { structureSessions.removeValue(forKey: tab.id)?.releaseViewWiring() createTableDrafts.removeValue(forKey: tab.id) navigationHistories.removeValue(forKey: tab.id) - pendingRowAnchors.removeValue(forKey: tab.id) displayStateCache.removeValue(forKey: tab.id) tableMetadataCache.removeValue(forKey: tab.id) guard isSelectedTab(tab) else { return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index 74e08481d..bc9f8df30 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -23,7 +23,8 @@ extension MainContentCoordinator { } return } - executeTableTabQueryDirectly(trigger: trigger) + let viewport = restoredRowAnchor(forTab: tabId).map(GridReloadIntent.restoreRow) ?? .keepPlace + executeTableTabQueryDirectly(trigger: trigger, viewport: viewport) } @discardableResult diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index f77641486..73a0bdc5f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -23,9 +23,24 @@ extension MainContentCoordinator { return delta } - func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID) { + func setActiveTableRows(_ tableRows: TableRows, for tabId: UUID, viewport intent: GridReloadIntent = .firstRow) { + let keyColumns = viewportKeyColumns(forTab: tabId) + let gridIsMounted = isGridMounted(forTab: tabId) + let snapshot = gridIsMounted ? viewportSnapshot(forTab: tabId, intent: intent, keyColumns: keyColumns) : .top installTableRows(tableRows, for: tabId) resetSelectionForNewResult(tabId: tabId) + if gridIsMounted { + let placement = GridViewportResolver.placement( + for: intent, + from: snapshot, + in: tableRows, + keyColumns: keyColumns + ) + tabSessionRegistry.stageViewportPlacement(placement, for: tabId) + } + if !tableRows.rows.isEmpty { + clearRestoredRowAnchor(forTab: tabId) + } notifyFullReplaceIfActive(tabId: tabId) } @@ -229,17 +244,5 @@ extension MainContentCoordinator { } dataTabDelegate?.tableViewCoordinator?.applyFullReplace() if let token { tracer.stage(.gridReloadEnd, token: token) } - - if pendingScrollToTopAfterReplace.remove(tabId) != nil { - dataTabDelegate?.tableViewCoordinator?.scrollToTop() - } - - /// Only once there are rows to find it in. The retarget that starts a navigation replaces - /// the buffer with an empty one first, and consuming the anchor there would spend it before - /// the rows it names have arrived. - if !tabSessionRegistry.tableRows(for: tabId).rows.isEmpty, - let anchor = pendingRowAnchors.removeValue(forKey: tabId) { - dataTabDelegate?.tableViewCoordinator?.selectRow(matchingKey: anchor) - } } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index cb4c9b1a0..fa81b07f0 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -48,7 +48,7 @@ extension MainContentView { if tabManager.selectedTab?.tabType == .table { coordinator.lazyLoadCurrentTabIfNeeded(trigger: trigger) } else { - coordinator.runQuery(trigger: trigger) + coordinator.runQuery(viewport: .firstRow, trigger: trigger) } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index f0203f892..820535fa0 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -1195,11 +1195,11 @@ final class MainContentCommandActions { } func runQuery() { - coordinator?.runQuery() + coordinator?.runQuery(viewport: .keepPlace) } func runQueryWithoutLimit() { - coordinator?.runQuery(bypassRowLimit: true) + coordinator?.runQuery(viewport: .keepPlace, bypassRowLimit: true) } func runAllStatements() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 5f9986e53..bbbb43937 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -288,8 +288,6 @@ final class MainContentCoordinator { @ObservationIgnored let schemaColumns = SchemaColumnStore() @ObservationIgnored var columnScopeRequeryTask: Task? - @ObservationIgnored var pendingScrollToTopAfterReplace: Set = [] - @ObservationIgnored var openTabInNewWindow: (EditorTabPayload) -> Void = { WindowManager.shared.openTab(payload: $0) } @@ -337,11 +335,6 @@ final class MainContentCoordinator { /// describes rows that may be gone by the next launch. Keeping it out of the struct also keeps /// it out of the hand-written `Equatable`, so a push never re-publishes the tab list. @ObservationIgnored internal var navigationHistories: [UUID: TabNavigationHistory] = [:] - - /// The row a restored tab should land on, keyed by tab because one grid coordinator serves - /// every tab in the window. Set when a navigation starts and consumed by the first draw that - /// has the rows, or dropped with the tab. - @ObservationIgnored internal var pendingRowAnchors: [UUID: [String: String]] = [:] @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var periodicSaveTask: Task? @ObservationIgnored private var draftSaveTask: Task? @@ -1032,7 +1025,7 @@ final class MainContentCoordinator { // MARK: - Query Execution - func runQuery(trigger: TableLoadTrigger = .userInitiated, bypassRowLimit: Bool = false) { + func runQuery(viewport: GridReloadIntent, trigger: TableLoadTrigger = .userInitiated, bypassRowLimit: Bool = false) { guard let (tab, index) = tabManager.selectedTabAndIndex else { return } guard !tabExecution.isExecuting(tab.id) else { traceExecutionBlocked(tabId: tab.id, site: "runQuery") @@ -1040,7 +1033,7 @@ final class MainContentCoordinator { } if tab.tabType == .table { - executeTableTabQueryDirectly(trigger: trigger) + executeTableTabQueryDirectly(trigger: trigger, viewport: viewport) return } @@ -1165,7 +1158,7 @@ final class MainContentCoordinator { /// Execute table tab query directly. /// Table tab queries are always app-generated SELECTs, so they skip dangerous-query /// checks but still respect safe mode levels that apply to all queries. - func executeTableTabQueryDirectly(trigger: TableLoadTrigger = .userInitiated) { + func executeTableTabQueryDirectly(trigger: TableLoadTrigger = .userInitiated, viewport: GridReloadIntent) { guard let (tab, index) = tabManager.selectedTabAndIndex else { return } TableLoadTracer.shared.stage(.executeRequested, tabId: tab.id) @@ -1199,14 +1192,14 @@ final class MainContentCoordinator { ) switch decision { case .authorized: - executeQueryInternal(sql, isAutoLoad: true, trigger: trigger) + executeQueryInternal(sql, isAutoLoad: true, trigger: trigger, viewport: viewport) case .denied(let reason): traceNavigationAbandoned(tabId: tab.id, outcome: .safeModeDenied) tabManager.mutate(at: index) { $0.execution.errorMessage = reason } } } } else { - executeQueryInternal(sql, isAutoLoad: true, trigger: trigger) + executeQueryInternal(sql, isAutoLoad: true, trigger: trigger, viewport: viewport) } } @@ -1274,7 +1267,8 @@ final class MainContentCoordinator { isAutoLoad: Bool = false, trigger: TableLoadTrigger = .userInitiated, bypassRowLimit: Bool = false, - anchor: StatementAnchor? = nil + anchor: StatementAnchor? = nil, + viewport: GridReloadIntent = .firstRow ) { guard let (selectedTab, index) = tabManager.selectedTabAndIndex else { return } @@ -1413,7 +1407,8 @@ final class MainContentCoordinator { connection: conn, isTruncated: fetchResult.isTruncated, anchor: anchor, - timing: fetchResult.resolvedTiming + timing: fetchResult.resolvedTiming, + viewport: viewport ) scheduleTraceCompletion(traceToken, outcome: .completed) @@ -1623,7 +1618,7 @@ final class MainContentCoordinator { tab.pagination.resetLoadMore() tab.pagination.sortExecutionOverride = orderQuery }) else { return } - self.runQuery() + self.runQuery(viewport: .firstRow) } return } @@ -1639,7 +1634,7 @@ final class MainContentCoordinator { }) else { return } guard let tabIndex = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } self.rebuildTableQuery(at: tabIndex) - self.runQuery() + self.runQuery(viewport: .firstRow) } } } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 171601b1c..085f61b76 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -796,6 +796,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func applyFullReplace() { + overlayEditor?.dismiss(commit: false) + overlayViewer?.dismiss() dismissPopoversBoundToDisplayPositions() pruneStaleValueFilters() guard let tableView else { return } @@ -817,35 +819,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData startBackgroundPrewarm() } - /// Selects and reveals the row a navigation asked this grid to land on. - /// - /// Pushed in when the rows land rather than polled from the update pass, the way - /// `applyFindMatch(_:)` is. A miss is silent and final: the row may have been deleted, moved to - /// another page, or hidden by a value filter, and none of those is worth telling the reader - /// about when the view they asked for is otherwise back. - func selectRow(matchingKey key: [String: String]) { - guard let tableView else { return } - let tableRows = tableRowsProvider() - - let keyColumns = key.compactMap { name, value in - tableRows.columns.firstIndex(of: name).map { (column: $0, value: value) } - } - guard keyColumns.count == key.count else { return } - - guard let match = tableRows.rows.first(where: { row in - keyColumns.allSatisfy { row[$0.column].asText == $0.value } - }) else { return } - - guard let displayIndex = DisplayRowMapping.displayIndex( - forRowID: match.id, - displayIDs: displayIDs, - in: tableRows - ), displayIndex < tableView.numberOfRows else { return } - - selectRowsProgrammatically(IndexSet(integer: displayIndex), in: tableView) - tableView.scrollRowToVisible(displayIndex) - } - /// A selection the app made, not the reader. The flag is what keeps the selection delegate from /// reading it back as a gesture. func selectRowsProgrammatically(_ indexes: IndexSet, in tableView: NSTableView) { @@ -1111,8 +1084,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData dismissPopoversBoundToDisplayPositions() applyRemovedRows(indices) case .columnsReplaced, .fullReplace: - overlayEditor?.dismiss(commit: false) - overlayViewer?.dismiss() applyFullReplace() } } @@ -1313,11 +1284,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData refreshCellPresentations() } - func scrollToTop() { - guard let tableView, tableView.numberOfRows > 0 else { return } - tableView.scrollRowToVisible(0) - } - @discardableResult func rebuildColumnMetadataCache(from tableRows: TableRows) -> Bool { let columns = tableRows.columns diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e18b8cfb0..a5f943a98 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -107,6 +107,7 @@ struct DataGridView: NSViewRepresentable { var restoredCellSelection: GridSelection? /// Handed this grid's selection on the way out, for the owner to keep until the next mount. var onSelectionTeardown: (@MainActor (Set, GridSelection) -> Void)? + var viewportPlacementProvider: (@MainActor () -> GridViewportPlacement?)? var contentRevision: Int = 0 // MARK: - NSViewRepresentable @@ -300,6 +301,9 @@ struct DataGridView: NSViewRepresentable { syncSortState(tableView: tableView, coordinator: coordinator) restoreSelection(tableView: tableView, coordinator: coordinator) syncSelection(tableView: tableView, coordinator: coordinator) + if let placement = viewportPlacementProvider?() { + coordinator.applyViewportPlacement(placement) + } coordinator.schedulePendingColumnJump(contentReplaced: contentReplaced) } @@ -315,6 +319,8 @@ struct DataGridView: NSViewRepresentable { contentChanged: Bool, columnComments: [String: String] ) { + let rowsKeptAcrossReload = contentChanged ? [] : coordinator.selectedRowIDs() + if let rowNumCol = tableView.tableColumns.first(where: { $0.identifier == ColumnIdentitySchema.rowNumberIdentifier }) { let shouldHide = !configuration.showRowNumbers if rowNumCol.isHidden != shouldHide { @@ -420,6 +426,7 @@ struct DataGridView: NSViewRepresentable { coordinator.selectionController.clear() tableView.reloadData() coordinator.restoreScrollAnchor() + coordinator.reselectRows(rowsKeptAcrossReload) coordinator.startBackgroundPrewarm() } else if displayFormatsChanged { coordinator.reloadAfterDisplayFormatChange() diff --git a/TablePro/Views/Results/Extensions/DataGridView+Viewport.swift b/TablePro/Views/Results/Extensions/DataGridView+Viewport.swift new file mode 100644 index 000000000..4d561c120 --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+Viewport.swift @@ -0,0 +1,92 @@ +// +// DataGridView+Viewport.swift +// TablePro +// + +import AppKit +import Foundation + +internal struct GridViewportSample: Equatable { + let firstVisibleRow: Int + let offset: CGFloat +} + +extension TableViewCoordinator { + func viewportSample() -> GridViewportSample? { + guard let tableView, tableView.numberOfRows > 0 else { return nil } + let visibleRect = unobscuredVisibleRect(of: tableView) + let visibleRows = tableView.rows(in: visibleRect) + guard visibleRows.length > 0, visibleRows.location < tableView.numberOfRows else { return nil } + let offset = visibleRect.minY - tableView.rect(ofRow: visibleRows.location).minY + return GridViewportSample(firstVisibleRow: visibleRows.location, offset: max(0, offset)) + } + + func applyViewportPlacement(_ placement: GridViewportPlacement) { + guard let tableView, tableView.numberOfRows > 0 else { return } + let tableRows = tableRowsProvider() + scrollFirstVisibleRow(of: placement, in: tableView, tableRows: tableRows) + + let selectedRows = displayRows(for: placement.selectedRows, in: tableRows, rowLimit: tableView.numberOfRows) + guard !selectedRows.isEmpty else { return } + selectDisplayRows(selectedRows, in: tableView) + + guard placement.revealsSelection, let firstSelectedRow = selectedRows.first else { return } + tableView.scrollRowToVisible(firstSelectedRow) + } + + func selectedRowIDs() -> [RowID] { + guard let tableView else { return [] } + let tableRows = tableRowsProvider() + return tableView.selectedRowIndexes.compactMap { displayRow(at: $0, in: tableRows)?.id } + } + + func reselectRows(_ rowIDs: [RowID]) { + guard let tableView, !rowIDs.isEmpty else { return } + let rows = displayRows(for: rowIDs, in: tableRowsProvider(), rowLimit: tableView.numberOfRows) + guard !rows.isEmpty else { return } + selectDisplayRows(rows, in: tableView) + } + + private func scrollFirstVisibleRow( + of placement: GridViewportPlacement, + in tableView: NSTableView, + tableRows: TableRows + ) { + let topInset = headerInset(of: tableView) + let horizontalOffset = tableView.enclosingScrollView?.contentView.bounds.origin.x ?? 0 + guard let rowID = placement.firstVisibleRow, + let displayRow = DisplayRowMapping.displayIndex(forRowID: rowID, displayIDs: displayIDs, in: tableRows), + displayRow < tableView.numberOfRows else { + tableView.scroll(NSPoint(x: horizontalOffset, y: -topInset)) + return + } + let rowOrigin = tableView.rect(ofRow: displayRow).minY + tableView.scroll(NSPoint(x: horizontalOffset, y: rowOrigin + placement.firstVisibleOffset - topInset)) + } + + private func headerInset(of tableView: NSTableView) -> CGFloat { + tableView.enclosingScrollView?.contentView.contentInsets.top ?? 0 + } + + private func unobscuredVisibleRect(of tableView: NSTableView) -> NSRect { + let topInset = headerInset(of: tableView) + var rect = tableView.visibleRect + rect.origin.y += topInset + rect.size.height = max(0, rect.size.height - topInset) + return rect + } + + private func displayRows(for rowIDs: [RowID], in tableRows: TableRows, rowLimit: Int) -> IndexSet { + IndexSet( + rowIDs + .compactMap { DisplayRowMapping.displayIndex(forRowID: $0, displayIDs: displayIDs, in: tableRows) } + .filter { $0 < rowLimit } + ) + } + + private func selectDisplayRows(_ rows: IndexSet, in tableView: NSTableView) { + selectionController.clear() + selectRowsProgrammatically(rows, in: tableView) + publishRowSelection(rowSelection: Set(rows)) + } +} diff --git a/TableProTests/Core/DataGrid/GridViewportResolverTests.swift b/TableProTests/Core/DataGrid/GridViewportResolverTests.swift new file mode 100644 index 000000000..bcea87c64 --- /dev/null +++ b/TableProTests/Core/DataGrid/GridViewportResolverTests.swift @@ -0,0 +1,217 @@ +// +// GridViewportResolverTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Grid viewport resolver") +struct GridViewportResolverTests { + private static let keyColumns = ["id"] + + private static func rows(ids: [Int]) -> TableRows { + TableRows.from( + queryRows: ids.map { [.text("\($0)"), .text("name-\($0)")] }, + columns: ["id", "name"], + columnTypes: [.text(rawType: "INTEGER"), .text(rawType: "TEXT")] + ) + } + + private static func snapshot( + of tableRows: TableRows, + firstVisibleRow: Int, + offset: CGFloat = 0, + displayIDs: [RowID]? = nil, + keyColumns: [String] = keyColumns, + isCellModified: (RowID, Int) -> Bool = { _, _ in false } + ) -> GridViewportSnapshot { + GridViewportResolver.snapshot( + of: tableRows, + displayIDs: displayIDs, + firstVisibleDisplayRow: firstVisibleRow, + firstVisibleOffset: offset, + keyColumns: keyColumns, + isCellModified: isCellModified + ) + } + + private static func placement( + _ intent: GridReloadIntent, + from snapshot: GridViewportSnapshot, + in incoming: TableRows, + keyColumns: [String] = keyColumns + ) -> GridViewportPlacement { + GridViewportResolver.placement(for: intent, from: snapshot, in: incoming, keyColumns: keyColumns) + } + + @Test("A new view lands on the first row whatever the reader was looking at") + func firstRowIgnoresThePreviousViewport() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array(1 ... 100)), firstVisibleRow: 40, offset: 6) + + #expect(Self.placement(.firstRow, from: snapshot, in: Self.rows(ids: Array(1 ... 100))) == .firstRow) + } + + @Test("Any intent lands on the first row of an empty result") + func emptyResultLandsOnTheFirstRow() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array(1 ... 100)), firstVisibleRow: 40) + + #expect(Self.placement(.keepPlace, from: snapshot, in: TableRows()) == .firstRow) + } + + @Test("Keeping place follows the top row by key when rows arrive above it") + func keepPlaceFollowsTheTopRowByKey() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array((1 ... 100).reversed())), firstVisibleRow: 40, offset: 7) + + let placement = Self.placement(.keepPlace, from: snapshot, in: Self.rows(ids: Array((1 ... 101).reversed()))) + + #expect(placement.firstVisibleRow == .existing(41)) + #expect(placement.firstVisibleOffset == 7) + #expect(placement.selectedRows.isEmpty) + #expect(!placement.revealsSelection) + } + + @Test("A reader at the very top stays there, so a row added above comes into view") + func keepPlaceAtTheTopStaysAtTheTop() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array((1 ... 100).reversed())), firstVisibleRow: 0) + + let placement = Self.placement(.keepPlace, from: snapshot, in: Self.rows(ids: Array((1 ... 101).reversed()))) + + #expect(placement == .firstRow) + } + + @Test("Keeping place without a key holds the position") + func keepPlaceWithoutKeysHoldsThePosition() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array(1 ... 100)), firstVisibleRow: 40, offset: 3, keyColumns: []) + + let placement = Self.placement(.keepPlace, from: snapshot, in: Self.rows(ids: Array(0 ... 100)), keyColumns: []) + + #expect(placement.firstVisibleRow == .existing(40)) + #expect(placement.firstVisibleOffset == 3) + } + + @Test("Keeping place holds the position when the top row is gone") + func keepPlaceHoldsThePositionWhenTheTopRowWasDeleted() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array(1 ... 100)), firstVisibleRow: 40, offset: 5) + + let placement = Self.placement(.keepPlace, from: snapshot, in: Self.rows(ids: Array(1 ... 100).filter { $0 != 41 })) + + #expect(placement.firstVisibleRow == .existing(40)) + #expect(placement.firstVisibleOffset == 5) + } + + @Test("Keeping place clamps to the last row of a result that shrank") + func keepPlaceClampsToAShorterResult() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array(1 ... 100)), firstVisibleRow: 40, offset: 5, keyColumns: []) + + let placement = Self.placement(.keepPlace, from: snapshot, in: Self.rows(ids: Array(1 ... 10)), keyColumns: []) + + #expect(placement.firstVisibleRow == .existing(9)) + #expect(placement.firstVisibleOffset == 0) + } + + @Test("A key that repeats in the new rows is not an identity, so the position holds") + func duplicatedKeyFallsBackToPosition() { + let rows = TableRows.from( + queryRows: (0 ..< 50).map { [.text("2024-01-05"), .text("\($0)")] }, + columns: ["event_date", "value"], + columnTypes: [.text(rawType: "Date"), .text(rawType: "Int64")] + ) + let snapshot = Self.snapshot(of: rows, firstVisibleRow: 30, offset: 2, keyColumns: ["event_date"]) + + let placement = Self.placement(.keepPlace, from: snapshot, in: rows, keyColumns: ["event_date"]) + + #expect(placement.firstVisibleRow == .existing(30)) + #expect(placement.firstVisibleOffset == 2) + } + + @Test("A composite key finds its row only when every part matches") + func compositeKeysMatchEveryColumn() { + let columns = ["tenant", "id", "name"] + let types: [ColumnType] = [.text(rawType: "INTEGER"), .text(rawType: "INTEGER"), .text(rawType: "TEXT")] + let outgoing = TableRows.from( + queryRows: [[.text("1"), .text("7"), .text("a")], [.text("2"), .text("7"), .text("b")]], + columns: columns, + columnTypes: types + ) + let incoming = TableRows.from( + queryRows: [ + [.text("1"), .text("8"), .text("new")], + [.text("1"), .text("7"), .text("a")], + [.text("2"), .text("7"), .text("b")] + ], + columns: columns, + columnTypes: types + ) + let snapshot = Self.snapshot(of: outgoing, firstVisibleRow: 1, keyColumns: ["tenant", "id"]) + + #expect(Self.placement(.keepPlace, from: snapshot, in: incoming, keyColumns: ["tenant", "id"]).firstVisibleRow == .existing(2)) + } + + @Test("A snapshot reads the top row through the display order of a value filter") + func snapshotResolvesDisplayPositions() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array(1 ... 20)), firstVisibleRow: 1, displayIDs: [.existing(4), .existing(9)]) + + #expect(snapshot.firstVisibleKey == ["id": "10"]) + } + + @Test("A key with a NULL value is no key, so the position is kept instead") + func nullKeyFallsBackToPosition() { + let rows = TableRows.from( + queryRows: [[.text("1"), .text("a")], [.null, .text("b")], [.text("3"), .text("c")]], + columns: ["id", "name"], + columnTypes: [.text(rawType: "INTEGER"), .text(rawType: "TEXT")] + ) + let snapshot = Self.snapshot(of: rows, firstVisibleRow: 1) + + #expect(snapshot.firstVisibleKey == nil) + #expect(Self.placement(.keepPlace, from: snapshot, in: rows).firstVisibleRow == .existing(1)) + } + + @Test("An unsaved row is never an anchor, even when its typed key names a saved row") + func unsavedRowIsNotAnAnchor() { + var outgoing = Self.rows(ids: Array(1 ... 10)) + _ = outgoing.appendInsertedRow(values: [.text("3"), .text("typed")]) + + #expect(Self.snapshot(of: outgoing, firstVisibleRow: 10).firstVisibleKey == nil) + } + + @Test("A key cell with an unsaved edit is never an anchor") + func editedKeyIsNotAnAnchor() { + let snapshot = Self.snapshot( + of: Self.rows(ids: Array(1 ... 10)), + firstVisibleRow: 4, + isCellModified: { rowID, column in rowID == .existing(4) && column == 0 } + ) + + #expect(snapshot.firstVisibleKey == nil) + } + + @Test("A result above the keyed row limit keeps place by position only") + func largeResultsKeepPlaceByPosition() { + let snapshot = Self.snapshot(of: Self.rows(ids: Array(1 ... (GridViewportResolver.keyedRowLimit + 1))), firstVisibleRow: 10) + + #expect(snapshot.firstVisibleKey == nil) + } + + @Test("Back and Forward select the recorded row and reveal it") + func restoreRowSelectsTheAnchor() { + let placement = Self.placement(.restoreRow(["id": "30"]), from: .top, in: Self.rows(ids: Array(1 ... 100)), keyColumns: []) + + #expect(placement.firstVisibleRow == nil) + #expect(placement.selectedRows == [.existing(29)]) + #expect(placement.revealsSelection) + } + + @Test("A recorded row that is gone, or no longer unique, lands on the first row") + func restoreRowNeedsOneMatch() { + let missing = Self.placement(.restoreRow(["id": "999"]), from: .top, in: Self.rows(ids: Array(1 ... 100)), keyColumns: []) + let duplicated = Self.placement(.restoreRow(["id": "7"]), from: .top, in: Self.rows(ids: [7, 7, 8]), keyColumns: []) + + #expect(missing == .firstRow) + #expect(duplicated == .firstRow) + } +} diff --git a/TableProTests/Views/Main/FKNavigationTests.swift b/TableProTests/Views/Main/FKNavigationTests.swift index 1537ee216..b182f240c 100644 --- a/TableProTests/Views/Main/FKNavigationTests.swift +++ b/TableProTests/Views/Main/FKNavigationTests.swift @@ -966,9 +966,9 @@ struct FKNavigationTests { #expect(tabManager.selectedTab?.restoredPageSize == 500) } - @Test("A pending row anchor belongs to one tab and no other tab can take it") + @Test("A restored row anchor belongs to its tab and lasts until the rows it names are installed") @MainActor - func rowAnchorIsKeyedByTab() throws { + func restoredRowAnchorLastsUntilRowsInstall() throws { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let coordinator = MainContentCoordinator( @@ -979,13 +979,64 @@ struct FKNavigationTests { ) defer { coordinator.teardown() } - let owning = UUID() - let other = UUID() - coordinator.pendingRowAnchors[owning] = ["id": "4021"] + try tabManager.addTableTab(tableName: "orders", databaseType: connection.type, databaseName: "db_a") + try tabManager.addTableTab(tableName: "users", databaseType: connection.type, databaseName: "db_a") + let owning = tabManager.tabs[0].id + let other = tabManager.tabs[1].id + tabManager.mutate(tabId: owning) { $0.restoredRowAnchor = ["id": "4021"] } - #expect(coordinator.pendingRowAnchors[other] == nil) - #expect(coordinator.pendingRowAnchors.removeValue(forKey: owning) == ["id": "4021"]) - #expect(coordinator.pendingRowAnchors[owning] == nil) + #expect(coordinator.restoredRowAnchor(forTab: other) == nil) + #expect(coordinator.restoredRowAnchor(forTab: owning) == ["id": "4021"]) + #expect(coordinator.restoredRowAnchor(forTab: owning) == ["id": "4021"]) + + coordinator.setActiveTableRows( + TableRows.from(queryRows: [[.text("4021")]], columns: ["id"], columnTypes: [.text(rawType: nil)]), + for: owning, + viewport: .restoreRow(["id": "4021"]) + ) + + #expect(coordinator.restoredRowAnchor(forTab: owning) == nil) + } + + @Test("Rows landing for any other reason spend a restored row anchor, so it cannot replay later") + @MainActor + func restoredRowAnchorIsSpentByAnyLanding() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try tabManager.addTableTab(tableName: "orders", databaseType: connection.type, databaseName: "db_a") + let tabId = try #require(tabManager.selectedTabId) + tabManager.mutate(tabId: tabId) { $0.restoredRowAnchor = ["id": "4021"] } + + coordinator.setActiveTableRows(TableRows(), for: tabId) + #expect(coordinator.restoredRowAnchor(forTab: tabId) == ["id": "4021"]) + + coordinator.setActiveTableRows( + TableRows.from(queryRows: [[.text("1")]], columns: ["id"], columnTypes: [.text(rawType: nil)]), + for: tabId, + viewport: .keepPlace + ) + #expect(coordinator.restoredRowAnchor(forTab: tabId) == nil) + } + + @Test("Retargeting a tab drops a restored row anchor its load never took") + @MainActor + func retargetDropsRestoredRowAnchor() throws { + let tabManager = QueryTabManager() + try tabManager.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_a") + let tabId = try #require(tabManager.selectedTabId) + tabManager.mutate(tabId: tabId) { $0.restoredRowAnchor = ["id": "4021"] } + + _ = try tabManager.replaceTabContent(tableName: "users", databaseType: .mysql, databaseName: "db_a") + + #expect(tabManager.tabs.first?.restoredRowAnchor == nil) } @Test("Metadata is not cached until foreign keys were fetched") diff --git a/TableProTests/Views/Main/GridReloadViewportTests.swift b/TableProTests/Views/Main/GridReloadViewportTests.swift new file mode 100644 index 000000000..a089e18b7 --- /dev/null +++ b/TableProTests/Views/Main/GridReloadViewportTests.swift @@ -0,0 +1,135 @@ +// +// GridReloadViewportTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class NoopReloadLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@Suite("Grid viewport across a reload") +@MainActor +struct GridReloadViewportTests { + private struct Fixture { + let coordinator: MainContentCoordinator + let tabId: UUID + let delegate: DataTabGridDelegate + let grid: TableViewCoordinator + let tableView: NSTableView + } + + private static func rows(ids: [Int]) -> TableRows { + TableRows.from( + queryRows: ids.map { [.text("\($0)"), .text("name-\($0)")] }, + columns: ["id", "name"], + columnTypes: [.text(rawType: "INTEGER"), .text(rawType: "TEXT")], + hasAuthoritativeSchema: true + ) + } + + private func makeFixture(tabType: TabType = .table, mode: ResultsViewMode = .data) -> Fixture { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = QueryTab(title: "users", query: "SELECT * FROM users", tabType: tabType, tableName: "users") + tab.tableContext.primaryKeyColumns = ["id"] + tab.display.resultsViewMode = mode + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + let delegate = DataTabGridDelegate() + let tableView = NSTableView() + let grid = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: NoopReloadLayoutPersister() + ) + grid.tableView = tableView + delegate.dataGridAttach(tableViewCoordinator: grid) + coordinator.dataTabDelegate = delegate + + coordinator.setActiveTableRows(Self.rows(ids: Array(1 ... 10)), for: tab.id) + _ = coordinator.takeViewportPlacement(forTab: tab.id) + return Fixture(coordinator: coordinator, tabId: tab.id, delegate: delegate, grid: grid, tableView: tableView) + } + + @Test("A table tab's grid on screen is handed its placement once") + func mountedGridTakesItsPlacementOnce() { + let fixture = makeFixture() + defer { fixture.coordinator.teardown() } + + fixture.coordinator.setActiveTableRows(Self.rows(ids: Array(1 ... 10)), for: fixture.tabId) + + #expect(fixture.coordinator.takeViewportPlacement(forTab: fixture.tabId) == .firstRow) + #expect(fixture.coordinator.takeViewportPlacement(forTab: fixture.tabId) == nil) + withExtendedLifetime(fixture) {} + } + + @Test("A grid that is not on screen is handed no placement") + func unmountedGridStagesNothing() { + let fixture = makeFixture(mode: .json) + defer { fixture.coordinator.teardown() } + + fixture.coordinator.setActiveTableRows(Self.rows(ids: Array(0 ... 10)), for: fixture.tabId, viewport: .keepPlace) + + #expect(fixture.coordinator.takeViewportPlacement(forTab: fixture.tabId) == nil) + withExtendedLifetime(fixture) {} + } + + @Test("A query tab's result keeps the grid where AppKit leaves it") + func queryTabStagesNothing() { + let fixture = makeFixture(tabType: .query) + defer { fixture.coordinator.teardown() } + + fixture.coordinator.setActiveTableRows(Self.rows(ids: Array(0 ... 10)), for: fixture.tabId, viewport: .firstRow) + + #expect(fixture.coordinator.takeViewportPlacement(forTab: fixture.tabId) == nil) + withExtendedLifetime(fixture) {} + } + + @Test("A placement staged for rows that were replaced since is never applied") + func placementForReplacedRowsIsDropped() { + let fixture = makeFixture() + defer { fixture.coordinator.teardown() } + fixture.coordinator.setActiveTableRows(Self.rows(ids: Array(1 ... 10)), for: fixture.tabId) + + fixture.coordinator.tabSessionRegistry.setTableRows(Self.rows(ids: [42]), for: fixture.tabId) + + #expect(fixture.coordinator.takeViewportPlacement(forTab: fixture.tabId) == nil) + withExtendedLifetime(fixture) {} + } + + @Test("Switching results in a query tab stages no placement") + func resultSwitchStagesNothing() { + let fixture = makeFixture(tabType: .query) + defer { fixture.coordinator.teardown() } + let first = ResultSet(label: "first", tableRows: Self.rows(ids: Array(1 ... 5))) + let second = ResultSet(label: "second", tableRows: Self.rows(ids: Array(6 ... 9))) + fixture.coordinator.tabManager.mutate(tabId: fixture.tabId) { tab in + tab.display.resultSets = [first, second] + tab.display.activeResultSetId = first.id + } + + fixture.coordinator.applyResultSetSwitch(to: second.id, in: fixture.tabId) + + #expect(fixture.coordinator.takeViewportPlacement(forTab: fixture.tabId) == nil) + withExtendedLifetime(fixture) {} + } +} diff --git a/TableProTests/Views/Results/DataGridViewportPlacementTests.swift b/TableProTests/Views/Results/DataGridViewportPlacementTests.swift new file mode 100644 index 000000000..43c1b7744 --- /dev/null +++ b/TableProTests/Views/Results/DataGridViewportPlacementTests.swift @@ -0,0 +1,202 @@ +// +// DataGridViewportPlacementTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class NoopViewportLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@Suite("Data grid viewport placement", .serialized) +@MainActor +struct DataGridViewportPlacementTests { + private struct Grid { + let window: NSWindow + let scrollView: NSScrollView + let tableView: KeyHandlingTableView + let coordinator: TableViewCoordinator + + var headerInset: CGFloat { scrollView.contentView.contentInsets.top } + } + + private static let rowCount = 300 + + private static func rows() -> TableRows { + TableRows.from( + queryRows: (0 ..< rowCount).map { [.text("\($0)"), .text("name-\($0)")] }, + columns: ["id", "name"], + columnTypes: [.text(rawType: "INTEGER"), .text(rawType: "TEXT")] + ) + } + + private func makeGrid() -> Grid { + let tableRows = Self.rows() + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: NoopViewportLayoutPersister() + ) + coordinator.tableRowsProvider = { tableRows } + coordinator.rebuildColumnMetadataCache(from: tableRows) + coordinator.updateCache() + + let tableView = KeyHandlingTableView(frame: NSRect(x: 0, y: 0, width: 900, height: 300)) + tableView.columnAutoresizingStyle = .noColumnAutoresizing + tableView.rowHeight = 21 + tableView.coordinator = coordinator + tableView.dataSource = coordinator + tableView.delegate = coordinator + tableView.addTableColumn(DataGridView.makeRowNumberColumn()) + coordinator.tableView = tableView + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: tableRows.columnTypes, + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 400 } + ) + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 300, height: 300)) + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.documentView = tableView + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = scrollView + tableView.reloadData() + window.layoutIfNeeded() + return Grid(window: window, scrollView: scrollView, tableView: tableView, coordinator: coordinator) + } + + private func scroll(_ grid: Grid, toRow row: Int, offset: CGFloat = 0, horizontal: CGFloat = 0) { + let rowOrigin = grid.tableView.rect(ofRow: row).minY + grid.tableView.scroll(NSPoint(x: horizontal, y: rowOrigin + offset - grid.headerInset)) + } + + @Test("The grid under test carries the header inset AppKit puts on the clip view") + func harnessHasAHeaderInset() { + let grid = makeGrid() + + #expect(grid.tableView.headerView != nil) + #expect(grid.headerInset > 0) + } + + @Test("The sample names the first row the header does not cover and how far it is scrolled past") + func sampleReadsTheFirstVisibleRow() throws { + let grid = makeGrid() + scroll(grid, toRow: 50, offset: 5) + + let sample = try #require(grid.coordinator.viewportSample()) + + #expect(sample.firstVisibleRow == 50) + #expect(sample.offset == 5) + } + + @Test("The first row placement puts row 0 below the header and keeps the horizontal position") + func firstRowSitsBelowTheHeader() throws { + let grid = makeGrid() + scroll(grid, toRow: 80, horizontal: 120) + let horizontal = grid.scrollView.contentView.bounds.origin.x + #expect(horizontal > 0) + + grid.coordinator.applyViewportPlacement(.firstRow) + + #expect(grid.scrollView.contentView.bounds.origin.y == -grid.headerInset) + #expect(try #require(grid.coordinator.viewportSample()).firstVisibleRow == 0) + #expect(grid.scrollView.contentView.bounds.origin.x == horizontal) + } + + @Test("An anchored placement puts its row back where the reader had it") + func anchoredPlacementRestoresTheRowAndOffset() throws { + let grid = makeGrid() + + grid.coordinator.applyViewportPlacement( + GridViewportPlacement( + firstVisibleRow: .existing(120), + firstVisibleOffset: 4, + selectedRows: [], + revealsSelection: false + ) + ) + + let sample = try #require(grid.coordinator.viewportSample()) + #expect(sample.firstVisibleRow == 120) + #expect(sample.offset == 4) + } + + @Test("A taller inset, as a header with column comments has, is honoured the same way") + func tallerInsetIsHonoured() throws { + let grid = makeGrid() + grid.scrollView.automaticallyAdjustsContentInsets = false + grid.scrollView.contentInsets = NSEdgeInsets(top: 14, left: 0, bottom: 0, right: 0) + grid.window.layoutIfNeeded() + let inset = grid.headerInset + #expect(inset > 0) + + grid.coordinator.applyViewportPlacement( + GridViewportPlacement(firstVisibleRow: .existing(60), firstVisibleOffset: 0, selectedRows: [], revealsSelection: false) + ) + #expect(try #require(grid.coordinator.viewportSample()).firstVisibleRow == 60) + + grid.coordinator.applyViewportPlacement(.firstRow) + #expect(grid.scrollView.contentView.bounds.origin.y == -inset) + } + + @Test("A revealed row is selected and scrolled into view") + func revealedRowIsSelectedAndVisible() { + let grid = makeGrid() + + grid.coordinator.applyViewportPlacement( + GridViewportPlacement(firstVisibleRow: nil, firstVisibleOffset: 0, selectedRows: [.existing(250)], revealsSelection: true) + ) + + #expect(grid.tableView.selectedRowIndexes == IndexSet(integer: 250)) + #expect(NSLocationInRange(250, grid.tableView.rows(in: grid.tableView.visibleRect))) + } + + @Test("Reselecting by row identity puts back a selection a reload dropped") + func reselectRestoresTheSelectionAfterAReload() { + let grid = makeGrid() + grid.coordinator.selectRowsProgrammatically(IndexSet([3, 7]), in: grid.tableView) + let kept = grid.coordinator.selectedRowIDs() + + grid.tableView.reloadData() + #expect(grid.tableView.selectedRowIndexes.isEmpty) + + grid.coordinator.reselectRows(kept) + #expect(grid.tableView.selectedRowIndexes == IndexSet([3, 7])) + } + + @Test("Replacing the rows closes an inline editor instead of leaving it over another record") + func fullReplaceDismissesTheInlineEditor() { + let grid = makeGrid() + let editor = CellOverlayEditor() + grid.coordinator.overlayEditor = editor + editor.show(in: grid.tableView, row: 0, column: 1, columnIndex: 0, value: "0") + #expect(editor.isActive) + + grid.coordinator.applyFullReplace() + + #expect(!editor.isActive) + } +} diff --git a/TableProUITests/DataGridReloadViewportUITests.swift b/TableProUITests/DataGridReloadViewportUITests.swift new file mode 100644 index 000000000..38ab86739 --- /dev/null +++ b/TableProUITests/DataGridReloadViewportUITests.swift @@ -0,0 +1,92 @@ +// +// DataGridReloadViewportUITests.swift +// TableProUITests +// + +import XCTest + +final class DataGridReloadViewportUITests: UITestCase { + private static let table = "Artist" + + func testRefreshKeepsThePlaceAndSortingStartsAtTheFirstRow() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try openTable(in: window) + XCTAssertTrue(waitForPredicate(timeout: 20) { self.firstRowValue(column: 1, in: grid) == "1" }) + + scrollToBottom(grid) + XCTAssertTrue( + waitForPredicate(timeout: 10) { !self.isFirstRowOnScreen(in: grid) }, + "Scrolling to the bottom must take the first row out of view" + ) + + app.typeKey("r", modifierFlags: [.command]) + XCTAssertFalse( + waitForPredicate(timeout: 6) { self.isFirstRowOnScreen(in: grid) }, + "Refresh must leave the grid where the reader had scrolled it" + ) + + try clickHeader("ArtistId", in: grid) + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.isFirstRowOnScreen(in: grid) }, + "Sorting must start at the first row" + ) + } + + // MARK: - Helpers + + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + return window + } + + private func openTable(in window: XCUIElement) throws -> XCUIElement { + let row = objectBrowserRow(Self.table, in: window) + XCTAssertTrue(row.waitToExist(timeout: 30), "The object browser must list \(Self.table)") + clickAtCenter(row) + + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "\(Self.table) produced no data grid") + XCTAssertTrue(waitForClickableRows(in: grid), "\(Self.table) must load its rows") + return grid + } + + private func clickHeader(_ column: String, in grid: XCUIElement) throws { + let header = grid.buttons + .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(column)")) + .firstMatch + XCTAssertTrue(header.waitToExist(timeout: 30), "The grid must publish a \(column) header") + let frame = header.frame + let origin = grid.frame.origin + grid.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) + .click() + } + + private func scrollToBottom(_ grid: XCUIElement) { + grid.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).scroll(byDeltaX: 0, deltaY: -20_000) + } + + private func firstRowValue(column: Int, in grid: XCUIElement) -> String? { + let row = grid.tableRows.firstMatch + guard row.exists else { return nil } + let cell = row.staticTexts + .matching(NSPredicate(format: "label BEGINSWITH %@", "Row 1, column \(column): ")) + .firstMatch + guard cell.exists else { return nil } + return cell.value as? String + } + + private func isFirstRowOnScreen(in grid: XCUIElement) -> Bool { + let row = grid.tableRows.firstMatch + guard row.exists else { return false } + let rowFrame = row.frame + let gridFrame = grid.frame + return rowFrame.midY > gridFrame.minY + 30 && rowFrame.midY < gridFrame.maxY + } +} diff --git a/docs/features/change-tracking.mdx b/docs/features/change-tracking.mdx index 9bf540fb9..d5cc88a0f 100644 --- a/docs/features/change-tracking.mdx +++ b/docs/features/change-tracking.mdx @@ -79,7 +79,7 @@ Press `Cmd+S`, or click the toolbar checkmark. Values go out as bound parameters A table without a primary key is matched on every original column value instead, with `IS NULL` for the nulls. On MySQL, MariaDB, TiDB and OceanBase a `FLOAT`, `DOUBLE` or `JSON` column is compared through `CONCAT()`, because the text those types read back as does not compare equal to the value it came from. -On an engine with transactions, the statements run inside one, so a failure rolls the whole save back and the table is left as it was. Without them, the statements that ran before the failure stand. Either way a failed save reports **Save Failed** with the server's message and keeps the queue intact, so correct the value and save again. A save that succeeds clears the queue, clears undo, and reloads the grid. +On an engine with transactions, the statements run inside one, so a failure rolls the whole save back and the table is left as it was. Without them, the statements that ran before the failure stand. Either way a failed save reports **Save Failed** with the server's message and keeps the queue intact, so correct the value and save again. A save that succeeds clears the queue, clears undo, and reloads the grid at the same place. Each statement is held to the number of rows it was written for. On a table with no primary key, two identical rows cannot be told apart, so a statement meant for one of them matches both; the save stops there and reports what happened instead of rewriting the other row. diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index f27ee33a7..4e16cf072 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -138,6 +138,12 @@ A query tab does not page. It stops at the [row cap](/customization/data-setting Some engines cannot skip rows and return a fixed maximum from one query. On those, a table tab shows its leading rows only: First, Previous, Next, Last and **All rows…** are gone, the rows-per-page menu stops at the engine's maximum, and the total is counted only when you click **Count Exactly**. Filter or sort to decide which rows load. [Cloudflare R2 SQL](/databases/cloudflare-r2-sql#pagination) works this way. +## Reloading + +`Cmd+R` reads the rows again and leaves the grid where it was. Scrolled down, the row at the top stays at the top, matched by primary key, so a row another session added takes its sorted place without moving what you are reading. At the top of the grid, the grid stays at the top and rows added above come into view. On a table without a primary key, or with a key that repeats, the scroll position holds instead. The selection clears. + +On a table tab, sorting, filtering, and changing page or page size start at the first row. The horizontal scroll position stays. + ## Copying Click a cell to select it, drag or `Shift`-click for a range, and click a row number for a whole row. The row-number gutter stays at the left edge on a table wider than the window, so whole rows are still selectable when the columns have scrolled past it. `Shift+Space` widens whatever is selected to every row it touches. Copy acts on the whole selection.