Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ 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

- Update preferences overwritten by the app at every launch instead of following the setting.
- Architecture error shown when a plugin actually needs a newer version of TablePro.
- 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

Expand Down
8 changes: 4 additions & 4 deletions TablePro/Core/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ final class FilterCoordinator {
$0.filterState.executedFilters = filters
}
saveLastFilters(for: tableName)
parent.runQuery()
parent.runQuery(viewport: .firstRow)
}

func clearFiltersAndReload() {
Expand Down Expand Up @@ -114,7 +114,7 @@ final class FilterCoordinator {
$0.filterState.executedFilters = []
}
clearLastFilters(for: capturedTableName)
parent.runQuery()
parent.runQuery(viewport: .firstRow)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}

Expand Down
3 changes: 1 addition & 2 deletions TablePro/Core/Coordinators/PaginationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ final class PaginationCoordinator {
mutate(&tab.pagination)
tab.paginationVersion += 1
}) else { return }
parent.pendingScrollToTopAfterReplace.insert(tabId)
reloadCurrentPage()
}
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,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)
}

/// A tab is closed only when the object it is showing is one of the objects that went.
Expand Down
141 changes: 141 additions & 0 deletions TablePro/Core/DataGrid/GridViewportResolver.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 12 additions & 0 deletions TablePro/Models/Query/GridReloadIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// GridReloadIntent.swift
// TablePro
//

import Foundation

internal enum GridReloadIntent: Equatable, Sendable {
case firstRow
case keepPlace
case restoreRow([String: String])
}
1 change: 1 addition & 0 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
1 change: 1 addition & 0 deletions TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,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
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Models/Query/TabSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Models/Query/TabSessionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension MainContentCoordinator {
action: DiscardAction,
completion: @escaping (Bool) -> Void
) {
dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit()
guard changeManager.hasChanges else {
completion(true)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ extension MainContentCoordinator {
guard !Task.isCancelled else { return }
}
guard await self.rebuildSelectedTableColumnScopedQuery() else { return }
self.runQuery()
self.runQuery(viewport: .keepPlace)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> 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)
Expand Down Expand Up @@ -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()
}
Expand Down
Loading