Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8c272c6
refactor(datagrid): native sort indicators, FK button reuse fix, drop…
datlechin Apr 29, 2026
3922ad5
refactor(datagrid): name-based column identifiers, protocol-based lay…
datlechin Apr 29, 2026
e7daf8d
refactor(datagrid): address review — split persister protocol, drop r…
datlechin Apr 29, 2026
f8438f0
fix(datagrid): compare column sets not arrays so user reorder does no…
datlechin Apr 29, 2026
0f97a36
diag(datagrid): trace sort dispatch + SF Symbol fallback for sort ind…
datlechin Apr 29, 2026
e266472
fix(datagrid): read live shift modifier from NSEvent for multi-sort d…
datlechin Apr 29, 2026
f573150
diag(datagrid): expand sort logging — modifier flags raw, indicator p…
datlechin Apr 29, 2026
c693a79
fix(datagrid): intercept shift+click in custom NSTableHeaderView for …
datlechin Apr 29, 2026
4c25c34
fix(datagrid): set all sort descriptors so multi-sort indicators rend…
datlechin Apr 29, 2026
ecf0017
fix(datagrid): custom NSTableHeaderCell renders indicators on every m…
datlechin Apr 29, 2026
99308fc
fix(datagrid): use native NSAscendingSortIndicator at natural size, d…
datlechin Apr 29, 2026
d91541a
fix(datagrid): clear AppKit's auto sort indicator so custom cell does…
datlechin Apr 29, 2026
90a1722
fix(datagrid): override drawSortIndicator to no-op so AppKit stops dr…
datlechin Apr 29, 2026
a955be9
fix(datagrid): tint native sort indicator template image with seconda…
datlechin Apr 29, 2026
b295cfe
fix(datagrid): tint sort indicator template image in offscreen contex…
datlechin Apr 29, 2026
02f81f5
fix(datagrid): tint sort indicator with tertiaryLabelColor for native…
datlechin Apr 29, 2026
bab57ac
fix(datagrid): use SF Symbol chevron with light weight for sort indic…
datlechin Apr 29, 2026
6223708
fix(datagrid): tweak sort indicator alpha to 0.4 between secondary an…
datlechin Apr 29, 2026
c265258
refactor(datagrid): use AppKit's native NSTableHeaderCell.drawSortInd…
datlechin Apr 29, 2026
c202227
fix(datagrid): SF Symbol chevron with paletteColors config for native…
datlechin Apr 29, 2026
9c57e2b
fix(datagrid): reduce sort indicator pointSize to 9 for smaller chevron
datlechin Apr 29, 2026
f4d9c9e
feat(datagrid): 3-state sort cycle + 'Don't Sort' context menu — nati…
datlechin Apr 29, 2026
fd88b23
refactor(datagrid): split sort handling — AppKit drives single-click …
datlechin Apr 29, 2026
09f5222
i18n: add 'Don't Sort' string to Localizable catalog
datlechin Apr 29, 2026
cce7b95
fix(datagrid): override init(coder:) + copy(with:) on MultiSortHeader…
datlechin Apr 29, 2026
dabb7cc
fix(datagrid): drop SF Symbol paletteColors config, tint at draw-time…
datlechin Apr 29, 2026
cbc5611
fix(datagrid): rip out MultiSortHeaderCell, use pure native NSTableVi…
datlechin Apr 29, 2026
b20e8a8
refactor(datagrid): NSImageView overlays on header for multi-sort ind…
datlechin Apr 29, 2026
6964084
fix(datagrid): clear highlightedTableColumn so AppKit does not auto-d…
datlechin Apr 29, 2026
e0918b3
fix(datagrid): explicit setIndicatorImage nil to clear AppKit auto-in…
datlechin Apr 29, 2026
cd222f2
fix(datagrid): stateless NSTableHeaderCell subclass overrides drawSor…
datlechin Apr 29, 2026
5c01de2
Merge branch 'main' into refactor/datagrid-column-architecture
datlechin Apr 29, 2026
ffe9fe5
refactor(datagrid): inject ColumnLayoutPersisting per view, drop shar…
datlechin Apr 29, 2026
7ea29f5
fix(datagrid): preserve native sort cycle on column header clicks
datlechin Apr 29, 2026
a337b90
fix(a11y): announce sort direction on data grid column headers
datlechin Apr 29, 2026
a67bcd4
refactor(datagrid): drop redundant doc comment on visibility helper
datlechin Apr 29, 2026
ace2733
test(datagrid): cover sort transitions, persister corruption, and lit…
datlechin Apr 29, 2026
6acf667
docs: correct sort indicator implementation note in CHANGELOG
datlechin Apr 29, 2026
d6b212a
fix(datagrid): restore three-state sort cycle (asc, desc, none)
datlechin Apr 29, 2026
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Data grid column identifiers are now the column name (with positional fallback for duplicate names), so saved widths follow the column across schema changes that shift its position. Identifier resolution moved from static `DataGridView` helpers to a `ColumnIdentitySchema` value type owned by the coordinator.
- `ColumnLayoutStorage` singleton replaced by a `ColumnLayoutPersisting` protocol with an injectable `FileColumnLayoutPersister` default. The coordinator depends on the protocol, not the concrete class, so tests can substitute a fake.
- Column layout save/restore on table-switch (`saveColumnLayoutForTable` / `restoreColumnLayoutForTable`) folded into the data grid coordinator's lifecycle (load on column build, persist on resize/move/dismantle). The standalone `MainContentCoordinator+ColumnLayout` extension is gone; only the visibility orchestration remains. Removes the redundant `hasUserResizedColumns` flag and the external save trigger from the binding setter.
- Data grid header sort indicators are `NSImageView` overlays drawn on a custom `NSTableHeaderView`, replacing Unicode arrows that were embedded in the column title string. The primary sorted column also gets the system header tint via `highlightedTableColumn`. VoiceOver announces the column name and sort direction separately.
- Data grid column layout persistence routes through a coordinator callback fired from outside SwiftUI's update cycle, removing the `Task`-based `@Binding` mutation inside `updateNSView` and the `isWritingColumnLayout` re-entry guard.
- Data grid cell reuse resets foreign-key arrow and dropdown chevron button context (target, action, row, column) when the button hides, preventing a stale handler from firing the wrong row if the column toggles between FK-eligible and not.
- `applyColumnOrder` scans only the unsettled tail of the column array per move, halving the constant cost on reorders with many columns.
- Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id.
- Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. The snapshot now refreshes only when the user switches result sets (saving the outgoing tab, loading the incoming one), so each insert / undo / paste no longer triggers an `@Observable` re-render of the whole editor. Fixes empty cells on Load More and CPU spikes when adding or undoing rows.
- Undo of a cell edit clears the modified-cell highlight: `DataChangeManager.applyDataUndo` now bumps `reloadVersion` so the data grid rebuilds its visual state cache.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
//
// ColumnLayoutStorage.swift
// ColumnLayoutPersister.swift
// TablePro
//

import Foundation
import os

@MainActor
internal final class ColumnLayoutStorage {
static let shared = ColumnLayoutStorage()

private static let logger = Logger(subsystem: "com.TablePro", category: "ColumnLayoutStorage")
final class FileColumnLayoutPersister: ColumnLayoutPersisting {
private static let logger = Logger(subsystem: "com.TablePro", category: "ColumnLayoutPersister")
private static let legacyKeyPrefix = "com.TablePro.columns.layout."
private static let migrationCompleteKey = "com.TablePro.columnLayoutMigrationComplete"

Expand All @@ -25,19 +23,19 @@ internal final class ColumnLayoutStorage {

private var cache: [UUID: [String: PersistedColumnLayout]] = [:]

private init() {
storageDirectory = Self.resolvedStorageDirectory()
init(storageDirectory: URL? = nil) {
self.storageDirectory = storageDirectory ?? Self.resolvedStorageDirectory()

do {
try FileManager.default.createDirectory(
at: storageDirectory,
at: self.storageDirectory,
withIntermediateDirectories: true
)
} catch {
Self.logger.error("Failed to create storage directory: \(error.localizedDescription)")
}

Self.performMigrationIfNeeded(storageDirectory: storageDirectory)
Self.performMigrationIfNeeded(storageDirectory: self.storageDirectory)
}

func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/Storage/ColumnLayoutPersisting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// ColumnLayoutPersisting.swift
// TablePro
//

import Foundation

@MainActor
protocol ColumnLayoutPersisting: AnyObject {
func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState?
func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID)
func clear(for tableName: String, connectionId: UUID)
}
3 changes: 0 additions & 3 deletions TablePro/Core/Storage/ValueDisplayFormatStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
// ValueDisplayFormatStorage.swift
// TablePro
//
// Persists per-column display format overrides to UserDefaults.
// Follows the same pattern as ColumnLayoutStorage.
//

import Foundation

Expand Down
8 changes: 0 additions & 8 deletions TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,10 @@ struct TabChangeSnapshot: Equatable {
}
}

/// Sort direction for column sorting
enum SortDirection: Equatable {
case ascending
case descending

var indicator: String {
switch self {
case .ascending: return "▲"
case .descending: return "▼"
}
}

mutating func toggle() {
self = self == .ascending ? .descending : .ascending
}
Expand Down
47 changes: 47 additions & 0 deletions TablePro/Models/UI/ColumnIdentitySchema.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// ColumnIdentitySchema.swift
// TablePro
//

import AppKit

struct ColumnIdentitySchema: Equatable {
static let rowNumberIdentifier = NSUserInterfaceItemIdentifier("__rowNumber__")

let identifiers: [NSUserInterfaceItemIdentifier]
let isNameBased: Bool
private let indexByRawIdentifier: [String: Int]

init(columns: [String]) {
let canUseNames = Set(columns).count == columns.count
&& !columns.contains(Self.rowNumberIdentifier.rawValue)

if canUseNames {
self.identifiers = columns.map { NSUserInterfaceItemIdentifier($0) }
self.isNameBased = true
} else {
self.identifiers = columns.indices.map {
NSUserInterfaceItemIdentifier("col_\($0)")
}
self.isNameBased = false
}

var map: [String: Int] = [:]
map.reserveCapacity(self.identifiers.count)
for (index, identifier) in self.identifiers.enumerated() {
map[identifier.rawValue] = index
}
self.indexByRawIdentifier = map
}

static let empty = ColumnIdentitySchema(columns: [])

func identifier(for dataIndex: Int) -> NSUserInterfaceItemIdentifier? {
guard dataIndex >= 0, dataIndex < identifiers.count else { return nil }
return identifiers[dataIndex]
}

func dataIndex(from identifier: NSUserInterfaceItemIdentifier) -> Int? {
indexByRawIdentifier[identifier.rawValue]
}
}
3 changes: 3 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -14085,6 +14085,9 @@
}
}
}
},
"Don't Sort" : {

},
"Done" : {

Expand Down
5 changes: 5 additions & 0 deletions TablePro/Views/Main/Child/DataTabGridDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class DataTabGridDelegate: DataGridViewDelegate {

var onCellEdit: ((Int, Int, String?) -> Void)?
var onSort: ((Int, Bool, Bool) -> Void)?
var onClearSort: (() -> Void)?
var onAddRow: (() -> Void)?
var onUndoInsert: ((Int) -> Void)?
var onFilterColumn: ((String) -> Void)?
Expand All @@ -32,6 +33,10 @@ final class DataTabGridDelegate: DataGridViewDelegate {
onSort?(column, ascending, isMultiSort)
}

func dataGridClearSort() {
onClearSort?()
}

func dataGridAddRow() {
onAddRow?()
}
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct MainEditorContentView: View {

let onCellEdit: (Int, Int, String?) -> Void
let onSort: (Int, Bool, Bool) -> Void
let onClearSort: () -> Void
let onAddRow: () -> Void
let onUndoInsert: (Int) -> Void
let onSelectionChange: (Set<Int>) -> Void
Expand Down Expand Up @@ -145,6 +146,7 @@ struct MainEditorContentView: View {
dataTabDelegate.selectionState = selectionState
dataTabDelegate.onCellEdit = onCellEdit
dataTabDelegate.onSort = onSort
dataTabDelegate.onClearSort = onClearSort
dataTabDelegate.onUndoInsert = onUndoInsert
dataTabDelegate.onFilterColumn = onFilterColumn
dataTabDelegate.onRefresh = onRefresh
Expand Down Expand Up @@ -663,7 +665,6 @@ struct MainEditorContentView: View {
}
Task { @MainActor in
coordinator.isUpdatingColumnLayout = false
coordinator.saveColumnLayoutForTable()
}
}
)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ extension MainContentCoordinator {
columnVisibilityManager.restoreLastHiddenColumns(for: tableName, connectionId: connectionId)
}

func saveColumnVisibilityForActiveTable() {
guard let tab = tabManager.selectedTab,
tab.tabType == .table,
let tableName = tab.tableContext.tableName,
!tableName.isEmpty else { return }
columnVisibilityManager.saveLastHiddenColumns(for: tableName, connectionId: connectionId)
}

/// Prune hidden columns that no longer exist in the current result set
func pruneHiddenColumns(currentColumns: [String]) {
columnVisibilityManager.pruneStaleColumns(currentColumns)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ extension MainContentCoordinator {
}
// In-place navigation needs selectRedisDatabaseAndQuery to ensure the correct
// database is SELECTed and session state is updated before querying.
restoreColumnLayoutForTable(tableName)
restoreLastHiddenColumnsForTable(tableName)
restoreFiltersForTable(tableName)
if navigationModel == .inPlace, let dbIndex = Int(currentDatabase) {
selectRedisDatabaseAndQuery(dbIndex)
Expand Down Expand Up @@ -128,7 +128,7 @@ extension MainContentCoordinator {
tabManager.tabs[tabIndex].pagination.reset()
toolbarState.isTableTab = true
}
restoreColumnLayoutForTable(tableName)
restoreLastHiddenColumnsForTable(tableName)
restoreFiltersForTable(tableName)
if let dbIndex = Int(currentDatabase) {
selectRedisDatabaseAndQuery(dbIndex)
Expand Down Expand Up @@ -212,7 +212,7 @@ extension MainContentCoordinator {
previewCoordinator.toolbarState.isTableTab = true
}
preview.window.makeKeyAndOrderFront(nil)
previewCoordinator.restoreColumnLayoutForTable(tableName)
previewCoordinator.restoreLastHiddenColumnsForTable(tableName)
previewCoordinator.restoreFiltersForTable(tableName)
previewCoordinator.runQuery()
return
Expand Down Expand Up @@ -282,7 +282,7 @@ extension MainContentCoordinator {
tabManager.tabs[tabIndex].pagination.reset()
toolbarState.isTableTab = true
}
restoreColumnLayoutForTable(tableName)
restoreLastHiddenColumnsForTable(tableName)
restoreFiltersForTable(tableName)
runQuery()
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ extension MainContentCoordinator {
filterStateManager.saveLastFilters(for: tableName)
}
saveColumnVisibilityToTab()
saveColumnLayoutForTable()
saveColumnVisibilityForActiveTable()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist outgoing table layout before tab context changes

Switching tabs no longer calls saveColumnLayoutForTable(), so layout writes now depend only on the debounced scheduleLayoutPersist() path in the grid coordinator. If the user resizes/reorders a column and switches tabs within the 500ms debounce window, the coordinator is rebound to the new tab before the task fires, so the outgoing tab’s latest layout can be lost (and the delayed write can target the wrong table context). This is a user-visible data-loss regression for column widths/order on quick tab switches.

Useful? React with 👍 / 👎.

}
let saveMs = Int(Date().timeIntervalSince(saveStart) * 1_000)

Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ extension MainContentView {
tabManager.tabs[tabIndex].content.query = filteredQuery
}
if let tableName = selectedTab.tableContext.tableName {
coordinator.restoreColumnLayoutForTable(tableName)
coordinator.restoreLastHiddenColumnsForTable(tableName)
}
coordinator.executeTableTabQueryDirectly()
}
Expand Down Expand Up @@ -161,7 +161,7 @@ extension MainContentView {
Task { await coordinator.switchDatabase(to: firstTab.tableContext.databaseName) }
} else {
if let tableName = firstTab.tableContext.tableName {
coordinator.restoreColumnLayoutForTable(tableName)
coordinator.restoreLastHiddenColumnsForTable(tableName)
}
coordinator.executeTableTabQueryDirectly()
}
Expand Down
27 changes: 27 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,33 @@ final class MainContentCoordinator {
}
}

func clearSort() {
guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return }
guard tab.sortState.isSorting else { return }

let emptySort = SortState()

if tab.tabType == .query {
tabManager.tabs[tabIndex].sortState = emptySort
tabManager.tabs[tabIndex].hasUserInteraction = true
querySortCache.removeValue(forKey: tab.id)
dataTabDelegate?.dataGridDidReplaceAllRows()
return
}

let tabId = tab.id
let capturedQuery = tab.content.query
confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in
guard let self, confirmed,
let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
self.tabManager.tabs[idx].sortState = emptySort
self.tabManager.tabs[idx].hasUserInteraction = true
self.tabManager.tabs[idx].pagination.reset()
self.tabManager.tabs[idx].content.query = Self.stripTrailingOrderBy(from: capturedQuery)
self.runQuery()
}
}

/// Multi-column sort returning a permutation of `RowID` (nonisolated for background thread).
nonisolated private static func multiColumnSortedIDs(
rows: [(id: RowID, values: [String?])],
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ struct MainContentView: View {
columnIndex: columnIndex, ascending: ascending,
isMultiSort: isMultiSort)
},
onClearSort: {
coordinator.clearSort()
},
onAddRow: {
coordinator.addNewRow()
},
Expand Down
Loading
Loading