From 8c272c6144407c4b30198c99b92f223c49be485f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 13:49:48 +0700 Subject: [PATCH 01/38] refactor(datagrid): native sort indicators, FK button reuse fix, drop binding-mutation Task pattern --- CHANGELOG.md | 4 + TablePro/Models/Query/QueryTabState.swift | 8 -- .../Views/Results/DataGridCellFactory.swift | 25 ++-- .../Views/Results/DataGridCoordinator.swift | 12 +- TablePro/Views/Results/DataGridView.swift | 110 +++++++----------- .../Extensions/DataGridView+Columns.swift | 1 - .../Extensions/DataGridView+Selection.swift | 2 +- TableProTests/Models/SortStateTests.swift | 5 - 8 files changed, 76 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6214585e4..4cdc0a423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Data grid header sort indicators use NSTableView's native `highlightedTableColumn` and `setIndicatorImage(_:in:)`, replacing Unicode arrows that were embedded in the column title string. Screen readers now announce 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. diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 8f18d4059..e13b336b6 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -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 } diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index c3c2bcc99..c1aeaf0f2 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -12,11 +12,10 @@ import QuartzCore /// Custom button that stores FK row/column context for the click handler @MainActor final class FKArrowButton: NSButton { - var fkRow: Int = 0 - var fkColumnIndex: Int = 0 + var fkRow: Int = -1 + var fkColumnIndex: Int = -1 } -/// Custom button that stores cell row/column context for the chevron click handler @MainActor final class CellChevronButton: NSButton { var cellRow: Int = -1 @@ -184,22 +183,34 @@ final class DataGridCellFactory { let showChevron = isDropdown if let fkButton = gridCellView.fkArrowButton { - fkButton.isHidden = !showFK if showFK { + fkButton.isHidden = false fkButton.target = fkArrowTarget fkButton.action = fkArrowAction fkButton.fkRow = row fkButton.fkColumnIndex = columnIndex + } else { + fkButton.isHidden = true + fkButton.target = nil + fkButton.action = nil + fkButton.fkRow = -1 + fkButton.fkColumnIndex = -1 } } if let chevron = gridCellView.chevronButton { - chevron.isHidden = !showChevron if showChevron { - chevron.cellRow = row - chevron.cellColumnIndex = columnIndex + chevron.isHidden = false chevron.target = chevronTarget chevron.action = chevronAction + chevron.cellRow = row + chevron.cellColumnIndex = columnIndex + } else { + chevron.isHidden = true + chevron.target = nil + chevron.action = nil + chevron.cellRow = -1 + chevron.cellColumnIndex = -1 } } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index b2c73c212..aa018c2a7 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -25,10 +25,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var primaryKeyColumns: [String] = [] var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? + var onColumnLayoutDidChange: ((ColumnLayoutState) -> Void)? func persistColumnLayoutToStorage() { - guard tabType == .table else { return } - guard let tableView, let connectionId, let tableName, !tableName.isEmpty else { return } + guard let tableView else { return } let tableRows = tableRowsProvider() guard !tableRows.columns.isEmpty else { return } @@ -46,7 +46,12 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var layout = ColumnLayoutState() layout.columnWidths = widths layout.columnOrder = order - ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId) + + onColumnLayoutDidChange?(layout) + + if tabType == .table, let connectionId, let tableName, !tableName.isEmpty { + ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId) + } } weak var tableView: NSTableView? @@ -68,7 +73,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isSyncingSelection = false var isRebuildingColumns: Bool = false var hasUserResizedColumns: Bool = false - var isWritingColumnLayout: Bool = false var isEscapeCancelling = false var isCommittingCellEdit = false var layoutPersistTask: Task? diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 6de54f500..8cd041aa9 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -144,6 +144,12 @@ struct DataGridView: NSViewRepresentable { context.coordinator.sortedIDs = sortedIDs context.coordinator.syncDisplayFormats(displayFormats) context.coordinator.delegate = delegate + let columnLayoutBinding = $columnLayout + context.coordinator.onColumnLayoutDidChange = { layout in + if columnLayoutBinding.wrappedValue != layout { + columnLayoutBinding.wrappedValue = layout + } + } delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns context.coordinator.typePickerColumns = configuration.typePickerColumns @@ -230,6 +236,12 @@ struct DataGridView: NSViewRepresentable { coordinator.sortedIDs = sortedIDs coordinator.syncDisplayFormats(displayFormats) coordinator.delegate = delegate + let columnLayoutBinding = $columnLayout + coordinator.onColumnLayoutDidChange = { layout in + if columnLayoutBinding.wrappedValue != layout { + columnLayoutBinding.wrappedValue = layout + } + } delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns coordinator.typePickerColumns = configuration.typePickerColumns @@ -362,53 +374,12 @@ struct DataGridView: NSViewRepresentable { } if !coordinator.hasUserResizedColumns, !hasSavedLayout { - var newWidths: [String: CGFloat] = [:] - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < tableRows.columns.count else { continue } - newWidths[tableRows.columns[colIndex]] = column.width - } - if !newWidths.isEmpty && newWidths != columnLayout.columnWidths { - coordinator.isWritingColumnLayout = true - Task { @MainActor in - coordinator.isWritingColumnLayout = false - self.columnLayout.columnWidths = newWidths - } - } + coordinator.scheduleLayoutPersist() } } else { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { column.isEditable = isEditable } - - guard !coordinator.isWritingColumnLayout else { return } - - if coordinator.hasUserResizedColumns, tableView.tableColumns.count > 1 { - var currentWidths: [String: CGFloat] = [:] - var currentOrder: [String] = [] - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < tableRows.columns.count else { continue } - let baseName = tableRows.columns[colIndex] - currentWidths[baseName] = column.width - currentOrder.append(baseName) - } - let widthsChanged = !currentWidths.isEmpty && currentWidths != columnLayout.columnWidths - let orderChanged = !currentOrder.isEmpty && columnLayout.columnOrder != currentOrder - if widthsChanged || orderChanged { - coordinator.isWritingColumnLayout = true - Task { @MainActor in - coordinator.isWritingColumnLayout = false - if widthsChanged { - self.columnLayout.columnWidths = currentWidths - } - if orderChanged { - self.columnLayout.columnOrder = currentOrder - } - } - } - coordinator.hasUserResizedColumns = false - } } } @@ -488,45 +459,54 @@ struct DataGridView: NSViewRepresentable { private static func applyColumnOrder(_ order: [String], to tableView: NSTableView, columns: [String]) { guard Set(order) == Set(columns) else { return } - let dataColumns = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } - - var columnMap: [String: NSTableColumn] = [:] - for col in dataColumns { + var columnByName: [String: NSTableColumn] = [:] + for col in tableView.tableColumns where col.identifier.rawValue != "__rowNumber__" { if let idx = dataColumnIndex(from: col.identifier), idx < columns.count { - columnMap[columns[idx]] = col + columnByName[columns[idx]] = col } } - for (targetIndex, columnName) in order.enumerated() { - guard let sourceColumn = columnMap[columnName], - let currentIndex = tableView.tableColumns.firstIndex(of: sourceColumn) else { continue } - let targetTableIndex = tableColumnIndex(for: targetIndex) - if currentIndex != targetTableIndex && targetTableIndex < tableView.numberOfColumns { - tableView.moveColumn(currentIndex, toColumn: targetTableIndex) + for (targetDataIndex, columnName) in order.enumerated() { + guard let desired = columnByName[columnName] else { continue } + let targetTableIndex = tableColumnIndex(for: targetDataIndex) + guard targetTableIndex < tableView.numberOfColumns else { continue } + + let current = tableView.tableColumns + var currentIndex = -1 + for i in targetTableIndex..= 0, currentIndex != targetTableIndex else { continue } + tableView.moveColumn(currentIndex, toColumn: targetTableIndex) } } // MARK: - Sort Indicator Helpers + private static let ascendingSortIndicator = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) + private static let descendingSortIndicator = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) + private static func updateSortIndicators(tableView: NSTableView, sortState: SortState, columns: [String]) { + var columnByDataIndex: [Int: NSTableColumn] = [:] for column in tableView.tableColumns { guard let colIndex = dataColumnIndex(from: column.identifier), colIndex < columns.count else { continue } + columnByDataIndex[colIndex] = column + tableView.setIndicatorImage(nil, in: column) + } - let baseName = columns[colIndex] + for sortCol in sortState.columns { + guard let column = columnByDataIndex[sortCol.columnIndex] else { continue } + let image = sortCol.direction == .ascending ? ascendingSortIndicator : descendingSortIndicator + tableView.setIndicatorImage(image, in: column) + } - if let sortIndex = sortState.columns.firstIndex(where: { $0.columnIndex == colIndex }) { - let sortCol = sortState.columns[sortIndex] - if sortState.columns.count > 1 { - let indicator = " \(sortIndex + 1)\(sortCol.direction.indicator)" - column.title = "\(baseName)\(indicator)" - } else { - column.title = baseName - } - } else { - column.title = baseName - } + if let primary = sortState.columns.first, + let column = columnByDataIndex[primary.columnIndex] { + tableView.highlightedTableColumn = column + } else { + tableView.highlightedTableColumn = nil } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index b9b4b99b1..b640da6bc 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -97,5 +97,4 @@ extension TableViewCoordinator { rowView.rowIndex = row return rowView } - } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 4198d4758..231da68ec 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -20,7 +20,7 @@ extension TableViewCoordinator { scheduleLayoutPersist() } - private func scheduleLayoutPersist() { + func scheduleLayoutPersist() { layoutPersistTask?.cancel() layoutPersistTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .milliseconds(500)) diff --git a/TableProTests/Models/SortStateTests.swift b/TableProTests/Models/SortStateTests.swift index 37eead196..a0361f2d5 100644 --- a/TableProTests/Models/SortStateTests.swift +++ b/TableProTests/Models/SortStateTests.swift @@ -48,11 +48,6 @@ struct SortDirectionTests { #expect(dir == .ascending) } - @Test("Indicator strings are correct") - func indicatorStrings() { - #expect(SortDirection.ascending.indicator == "▲") - #expect(SortDirection.descending.indicator == "▼") - } } @Suite("SortColumn") From 3922ad5871f8ae7ad26b774e105dd2dd15705019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 14:16:08 +0700 Subject: [PATCH 02/38] refactor(datagrid): name-based column identifiers, protocol-based layout persister --- CHANGELOG.md | 3 + ...rage.swift => ColumnLayoutPersister.swift} | 23 +- .../Storage/ValueDisplayFormatStorage.swift | 3 - TablePro/Models/UI/ColumnIdentitySchema.swift | 47 +++ .../Main/Child/MainEditorContentView.swift | 1 - .../MainContentCoordinator+ColumnLayout.swift | 27 -- ...nContentCoordinator+ColumnVisibility.swift | 9 + .../MainContentCoordinator+Navigation.swift | 8 +- .../MainContentCoordinator+TabSwitch.swift | 2 +- .../Extensions/MainContentView+Setup.swift | 4 +- .../Views/Results/DataGridCoordinator.swift | 53 +++- TablePro/Views/Results/DataGridView.swift | 281 ++++++++++-------- .../Extensions/DataGridView+Columns.swift | 5 +- .../Extensions/DataGridView+Editing.swift | 4 +- .../Extensions/DataGridView+Selection.swift | 3 - .../Extensions/DataGridView+Sort.swift | 25 +- .../Views/Results/KeyHandlingTableView.swift | 2 +- .../Views/Structure/TableStructureView.swift | 2 +- .../Models/UI/ColumnIdentitySchemaTests.swift | 72 +++++ .../FileColumnLayoutPersisterTests.swift | 117 ++++++++ 20 files changed, 493 insertions(+), 198 deletions(-) rename TablePro/Core/Storage/{ColumnLayoutStorage.swift => ColumnLayoutPersister.swift} (89%) create mode 100644 TablePro/Models/UI/ColumnIdentitySchema.swift delete mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnLayout.swift create mode 100644 TableProTests/Models/UI/ColumnIdentitySchemaTests.swift create mode 100644 TableProTests/Storage/FileColumnLayoutPersisterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdc0a423..0e2c58999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ 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 use NSTableView's native `highlightedTableColumn` and `setIndicatorImage(_:in:)`, replacing Unicode arrows that were embedded in the column title string. Screen readers now announce 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. diff --git a/TablePro/Core/Storage/ColumnLayoutStorage.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift similarity index 89% rename from TablePro/Core/Storage/ColumnLayoutStorage.swift rename to TablePro/Core/Storage/ColumnLayoutPersister.swift index a0c3c6ab0..d5d24cb94 100644 --- a/TablePro/Core/Storage/ColumnLayoutStorage.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -1,5 +1,5 @@ // -// ColumnLayoutStorage.swift +// ColumnLayoutPersister.swift // TablePro // @@ -7,10 +7,17 @@ import Foundation import os @MainActor -internal final class ColumnLayoutStorage { - static let shared = ColumnLayoutStorage() +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) +} + +@MainActor +final class FileColumnLayoutPersister: ColumnLayoutPersisting { + static let shared = FileColumnLayoutPersister() - private static let logger = Logger(subsystem: "com.TablePro", category: "ColumnLayoutStorage") + 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" @@ -25,19 +32,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) { diff --git a/TablePro/Core/Storage/ValueDisplayFormatStorage.swift b/TablePro/Core/Storage/ValueDisplayFormatStorage.swift index b7cb12997..d49a097c4 100644 --- a/TablePro/Core/Storage/ValueDisplayFormatStorage.swift +++ b/TablePro/Core/Storage/ValueDisplayFormatStorage.swift @@ -2,9 +2,6 @@ // ValueDisplayFormatStorage.swift // TablePro // -// Persists per-column display format overrides to UserDefaults. -// Follows the same pattern as ColumnLayoutStorage. -// import Foundation diff --git a/TablePro/Models/UI/ColumnIdentitySchema.swift b/TablePro/Models/UI/ColumnIdentitySchema.swift new file mode 100644 index 000000000..7c55344be --- /dev/null +++ b/TablePro/Models/UI/ColumnIdentitySchema.swift @@ -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] + } +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 20bed91e6..b61c2f39a 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -663,7 +663,6 @@ struct MainEditorContentView: View { } Task { @MainActor in coordinator.isUpdatingColumnLayout = false - coordinator.saveColumnLayoutForTable() } } ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnLayout.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnLayout.swift deleted file mode 100644 index 5d8b0fe0c..000000000 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnLayout.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// MainContentCoordinator+ColumnLayout.swift -// TablePro -// - -import Foundation - -extension MainContentCoordinator { - func saveColumnLayoutForTable() { - guard let index = tabManager.selectedTabIndex else { return } - let tab = tabManager.tabs[index] - guard tab.tabType == .table, let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return } - - ColumnLayoutStorage.shared.save(tab.columnLayout, for: tableName, connectionId: connectionId) - columnVisibilityManager.saveLastHiddenColumns(for: tableName, connectionId: connectionId) - } - - func restoreColumnLayoutForTable(_ tableName: String) { - guard let index = tabManager.selectedTabIndex else { return } - - if let savedLayout = ColumnLayoutStorage.shared.load(for: tableName, connectionId: connectionId) { - tabManager.tabs[index].columnLayout.columnWidths = savedLayout.columnWidths - tabManager.tabs[index].columnLayout.columnOrder = savedLayout.columnOrder - } - restoreLastHiddenColumnsForTable(tableName) - } -} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index fc297a137..eaa2049bd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -22,6 +22,15 @@ extension MainContentCoordinator { columnVisibilityManager.restoreLastHiddenColumns(for: tableName, connectionId: connectionId) } + /// Save current hidden columns for the active table tab (visibility lives outside the layout persister). + 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) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 2df2d23dd..3972cbb19 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -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) @@ -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) @@ -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 @@ -282,7 +282,7 @@ extension MainContentCoordinator { tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } - restoreColumnLayoutForTable(tableName) + restoreLastHiddenColumnsForTable(tableName) restoreFiltersForTable(tableName) runQuery() return diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 4e45c1b7e..3ca061d3f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -40,7 +40,7 @@ extension MainContentCoordinator { filterStateManager.saveLastFilters(for: tableName) } saveColumnVisibilityToTab() - saveColumnLayoutForTable() + saveColumnVisibilityForActiveTable() } let saveMs = Int(Date().timeIntervalSince(saveStart) * 1_000) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index cb97526e2..82f540198 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -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() } @@ -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() } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index aa018c2a7..3bb437889 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -25,32 +25,61 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var primaryKeyColumns: [String] = [] var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? + var layoutPersister: any ColumnLayoutPersisting = FileColumnLayoutPersister.shared var onColumnLayoutDidChange: ((ColumnLayoutState) -> Void)? + private(set) var identitySchema: ColumnIdentitySchema = .empty - func persistColumnLayoutToStorage() { - guard let tableView else { return } + func columnIdentifier(for dataIndex: Int) -> NSUserInterfaceItemIdentifier? { + identitySchema.identifier(for: dataIndex) + } + + func dataColumnIndex(from identifier: NSUserInterfaceItemIdentifier) -> Int? { + identitySchema.dataIndex(from: identifier) + } + + func savedColumnLayout(binding: ColumnLayoutState) -> ColumnLayoutState? { + if tabType == .table, + let connectionId, + let tableName, + !tableName.isEmpty, + let stored = layoutPersister.load(for: tableName, connectionId: connectionId) { + return stored + } + if binding.columnWidths.isEmpty && binding.columnOrder == nil { + return nil + } + return binding + } + + func captureColumnLayout() -> ColumnLayoutState? { + guard let tableView else { return nil } let tableRows = tableRowsProvider() - guard !tableRows.columns.isEmpty else { return } + guard !tableRows.columns.isEmpty else { return nil } var widths: [String: CGFloat] = [:] var order: [String] = [] - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = DataGridView.dataColumnIndex(from: column.identifier), + for column in tableView.tableColumns + where column.identifier != ColumnIdentitySchema.rowNumberIdentifier { + guard let colIndex = dataColumnIndex(from: column.identifier), colIndex < tableRows.columns.count else { continue } let name = tableRows.columns[colIndex] widths[name] = column.width order.append(name) } - guard !widths.isEmpty else { return } + guard !widths.isEmpty else { return nil } var layout = ColumnLayoutState() layout.columnWidths = widths layout.columnOrder = order + return layout + } + func persistColumnLayoutToStorage() { + guard let layout = captureColumnLayout() else { return } onColumnLayoutDidChange?(layout) if tabType == .table, let connectionId, let tableName, !tableName.isEmpty { - ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId) + layoutPersister.save(layout, for: tableName, connectionId: connectionId) } } @@ -72,7 +101,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isSyncingSortDescriptors: Bool = false var isSyncingSelection = false var isRebuildingColumns: Bool = false - var hasUserResizedColumns: Bool = false var isEscapeCancelling = false var isCommittingCellEdit = false var layoutPersistTask: Task? @@ -434,8 +462,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let tableRows = tableRowsProvider() let fkColumnIndices = IndexSet( tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in - guard tableColumn.identifier.rawValue != "__rowNumber__", - let modelIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier), + guard tableColumn.identifier != ColumnIdentitySchema.rowNumberIdentifier, + let modelIndex = dataColumnIndex(from: tableColumn.identifier), modelIndex < tableRows.columns.count else { return nil } let columnName = tableRows.columns[modelIndex] return tableRows.columnForeignKeys[columnName] != nil ? displayIndex : nil @@ -477,6 +505,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } enumOrSetColumns = enumSet fkColumns = fkSet + + let nextSchema = ColumnIdentitySchema(columns: columns) + if nextSchema != identitySchema { + identitySchema = nextSchema + } } // MARK: - Font Updates diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 8cd041aa9..e8fca3f90 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -66,7 +66,7 @@ struct DataGridView: NSViewRepresentable { tableView.action = #selector(TableViewCoordinator.handleClick(_:)) tableView.doubleAction = #selector(TableViewCoordinator.handleDoubleClick(_:)) - let rowNumberColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("__rowNumber__")) + let rowNumberColumn = NSTableColumn(identifier: ColumnIdentitySchema.rowNumberIdentifier) rowNumberColumn.title = "#" rowNumberColumn.width = 40 rowNumberColumn.minWidth = 40 @@ -78,10 +78,13 @@ struct DataGridView: NSViewRepresentable { rowNumberColumn.isHidden = !configuration.showRowNumbers let initialRows = tableRowsProvider() + context.coordinator.rebuildColumnMetadataCache(from: initialRows) + let identitySchema = context.coordinator.identitySchema context.coordinator.isRebuildingColumns = true for (index, columnName) in initialRows.columns.enumerated() { - let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) + guard let identifier = identitySchema.identifier(for: index) else { continue } + let column = NSTableColumn(identifier: identifier) column.title = columnName if index < initialRows.columnTypes.count { let typeName = initialRows.columnTypes[index].rawType ?? initialRows.columnTypes[index].displayName @@ -99,30 +102,22 @@ struct DataGridView: NSViewRepresentable { column.resizingMask = .userResizingMask column.isEditable = isEditable column.sortDescriptorPrototype = NSSortDescriptor( - key: Self.columnIdentifier(for: index).rawValue, + key: identifier.rawValue, ascending: true ) tableView.addTableColumn(column) } - if !columnLayout.columnWidths.isEmpty { - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < initialRows.columns.count else { continue } - let baseName = initialRows.columns[colIndex] - if let savedWidth = columnLayout.columnWidths[baseName] { - column.width = savedWidth - } - } - context.coordinator.hasUserResizedColumns = true - } - - if let savedOrder = columnLayout.columnOrder { - DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: initialRows.columns) - } + let initialLayout = context.coordinator.savedColumnLayout(binding: columnLayout) + applySavedLayout( + to: tableView, + coordinator: context.coordinator, + columns: initialRows.columns, + layout: initialLayout + ) context.coordinator.isRebuildingColumns = false - applyColumnVisibility(to: tableView, columns: initialRows.columns) + applyColumnVisibility(to: tableView, coordinator: context.coordinator, columns: initialRows.columns) if let headerView = tableView.headerView { let headerMenu = NSMenu() @@ -175,7 +170,7 @@ struct DataGridView: NSViewRepresentable { if tableView.editedRow >= 0 { return } if let editor = context.coordinator.overlayEditor, editor.isActive { return } - if let rowNumCol = tableView.tableColumns.first(where: { $0.identifier.rawValue == "__rowNumber__" }) { + if let rowNumCol = tableView.tableColumns.first(where: { $0.identifier == ColumnIdentitySchema.rowNumberIdentifier }) { let shouldHide = !configuration.showRowNumbers if rowNumCol.isHidden != shouldHide { rowNumCol.isHidden = shouldHide @@ -256,7 +251,7 @@ struct DataGridView: NSViewRepresentable { let currentDataColumns = tableView.tableColumns.dropFirst() let currentColumnIds = currentDataColumns.map { $0.identifier.rawValue } - let expectedColumnIds = latestRows.columns.indices.map { Self.columnIdentifier(for: $0).rawValue } + let expectedColumnIds = coordinator.identitySchema.identifiers.map { $0.rawValue } let columnsChanged = !latestRows.columns.isEmpty && (currentColumnIds != expectedColumnIds) let isInitialDataLoad = structureChanged && oldRowCount == 0 && !latestRows.columns.isEmpty @@ -271,7 +266,7 @@ struct DataGridView: NSViewRepresentable { structureChanged: structureChanged ) - applyColumnVisibility(to: tableView, columns: latestRows.columns) + applyColumnVisibility(to: tableView, coordinator: coordinator, columns: latestRows.columns) syncSortDescriptors(tableView: tableView, coordinator: coordinator, columns: latestRows.columns) @@ -296,93 +291,139 @@ struct DataGridView: NSViewRepresentable { coordinator.isRebuildingColumns = true defer { coordinator.isRebuildingColumns = false } + let savedLayout = coordinator.savedColumnLayout(binding: columnLayout) + if columnsChanged { - let columnsToRemove = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } - for column in columnsToRemove { - tableView.removeTableColumn(column) - } - - let willRestoreWidths = !columnLayout.columnWidths.isEmpty - for (index, columnName) in tableRows.columns.enumerated() { - let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) - column.title = columnName - if index < tableRows.columnTypes.count { - let typeName = tableRows.columnTypes[index].rawType - ?? tableRows.columnTypes[index].displayName - column.headerToolTip = "\(columnName) (\(typeName))" - } - column.headerCell.setAccessibilityLabel( - String(format: String(localized: "Column: %@"), columnName) - ) - if willRestoreWidths { - column.width = columnLayout.columnWidths[columnName] ?? 100 - } else { - column.width = coordinator.cellFactory.calculateOptimalColumnWidth( - for: columnName, - columnIndex: index, - tableRows: tableRows - ) - } - column.minWidth = 30 - column.resizingMask = .userResizingMask - column.isEditable = isEditable - column.sortDescriptorPrototype = NSSortDescriptor( - key: Self.columnIdentifier(for: index).rawValue, - ascending: true - ) - tableView.addTableColumn(column) - } + rebuildColumns( + tableView: tableView, + coordinator: coordinator, + tableRows: tableRows, + savedLayout: savedLayout + ) } else { - let hasSavedWidths = !columnLayout.columnWidths.isEmpty - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < tableRows.columns.count else { continue } - let columnName = tableRows.columns[colIndex] - column.title = columnName - if colIndex < tableRows.columnTypes.count { - let typeName = tableRows.columnTypes[colIndex].rawType - ?? tableRows.columnTypes[colIndex].displayName - column.headerToolTip = "\(columnName) (\(typeName))" - } - if !hasSavedWidths { - column.width = coordinator.cellFactory.calculateOptimalColumnWidth( - for: columnName, - columnIndex: colIndex, - tableRows: tableRows - ) - } - column.isEditable = isEditable - } - } - let hasSavedLayout = !columnLayout.columnWidths.isEmpty - - if hasSavedLayout { - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.dataColumnIndex(from: column.identifier), - colIndex < tableRows.columns.count else { continue } - let baseName = tableRows.columns[colIndex] - if let savedWidth = columnLayout.columnWidths[baseName] { - column.width = savedWidth - } - } - coordinator.hasUserResizedColumns = true + refreshColumnTitles( + tableView: tableView, + coordinator: coordinator, + tableRows: tableRows, + hasSavedWidths: !(savedLayout?.columnWidths.isEmpty ?? true) + ) } - if let savedOrder = columnLayout.columnOrder { - DataGridView.applyColumnOrder(savedOrder, to: tableView, columns: tableRows.columns) - coordinator.hasUserResizedColumns = true - } + applySavedLayout(to: tableView, coordinator: coordinator, columns: tableRows.columns, layout: savedLayout) - if !coordinator.hasUserResizedColumns, !hasSavedLayout { + if savedLayout == nil { coordinator.scheduleLayoutPersist() } } else { - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { + for column in tableView.tableColumns + where column.identifier != ColumnIdentitySchema.rowNumberIdentifier { column.isEditable = isEditable } } } + private func rebuildColumns( + tableView: NSTableView, + coordinator: TableViewCoordinator, + tableRows: TableRows, + savedLayout: ColumnLayoutState? + ) { + let columnsToRemove = tableView.tableColumns.filter { + $0.identifier != ColumnIdentitySchema.rowNumberIdentifier + } + for column in columnsToRemove { + tableView.removeTableColumn(column) + } + + let willRestoreWidths = !(savedLayout?.columnWidths.isEmpty ?? true) + let schema = coordinator.identitySchema + for (index, columnName) in tableRows.columns.enumerated() { + guard let identifier = schema.identifier(for: index) else { continue } + let column = NSTableColumn(identifier: identifier) + column.title = columnName + if index < tableRows.columnTypes.count { + let typeName = tableRows.columnTypes[index].rawType + ?? tableRows.columnTypes[index].displayName + column.headerToolTip = "\(columnName) (\(typeName))" + } + column.headerCell.setAccessibilityLabel( + String(format: String(localized: "Column: %@"), columnName) + ) + if willRestoreWidths { + column.width = savedLayout?.columnWidths[columnName] ?? 100 + } else { + column.width = coordinator.cellFactory.calculateOptimalColumnWidth( + for: columnName, + columnIndex: index, + tableRows: tableRows + ) + } + column.minWidth = 30 + column.resizingMask = .userResizingMask + column.isEditable = isEditable + column.sortDescriptorPrototype = NSSortDescriptor( + key: identifier.rawValue, + ascending: true + ) + tableView.addTableColumn(column) + } + } + + private func refreshColumnTitles( + tableView: NSTableView, + coordinator: TableViewCoordinator, + tableRows: TableRows, + hasSavedWidths: Bool + ) { + for column in tableView.tableColumns + where column.identifier != ColumnIdentitySchema.rowNumberIdentifier { + guard let colIndex = coordinator.dataColumnIndex(from: column.identifier), + colIndex < tableRows.columns.count else { continue } + let columnName = tableRows.columns[colIndex] + column.title = columnName + if colIndex < tableRows.columnTypes.count { + let typeName = tableRows.columnTypes[colIndex].rawType + ?? tableRows.columnTypes[colIndex].displayName + column.headerToolTip = "\(columnName) (\(typeName))" + } + if !hasSavedWidths { + column.width = coordinator.cellFactory.calculateOptimalColumnWidth( + for: columnName, + columnIndex: colIndex, + tableRows: tableRows + ) + } + column.isEditable = isEditable + } + } + + private func applySavedLayout( + to tableView: NSTableView, + coordinator: TableViewCoordinator, + columns: [String], + layout: ColumnLayoutState? + ) { + guard let layout else { return } + + for column in tableView.tableColumns + where column.identifier != ColumnIdentitySchema.rowNumberIdentifier { + guard let colIndex = coordinator.dataColumnIndex(from: column.identifier), + colIndex < columns.count else { continue } + if let savedWidth = layout.columnWidths[columns[colIndex]] { + column.width = savedWidth + } + } + + if let savedOrder = layout.columnOrder { + DataGridView.applyColumnOrder( + savedOrder, + to: tableView, + schema: coordinator.identitySchema, + columns: columns + ) + } + } + private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) { coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } @@ -392,8 +433,8 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = [] } } else if let firstSort = sortState.columns.first, - firstSort.columnIndex >= 0 && firstSort.columnIndex < columns.count { - let key = Self.columnIdentifier(for: firstSort.columnIndex).rawValue + let identifier = coordinator.identitySchema.identifier(for: firstSort.columnIndex) { + let key = identifier.rawValue let ascending = firstSort.direction == .ascending let currentDescriptor = tableView.sortDescriptors.first if currentDescriptor?.key != key || currentDescriptor?.ascending != ascending { @@ -401,7 +442,11 @@ struct DataGridView: NSViewRepresentable { } } - Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: columns) + Self.updateSortIndicators( + tableView: tableView, + sortState: sortState, + schema: coordinator.identitySchema + ) } private func reloadAndSyncSelection( @@ -424,9 +469,10 @@ struct DataGridView: NSViewRepresentable { // MARK: - Column Visibility - private func applyColumnVisibility(to tableView: NSTableView, columns: [String]) { - for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.dataColumnIndex(from: column.identifier), + private func applyColumnVisibility(to tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) { + for column in tableView.tableColumns + where column.identifier != ColumnIdentitySchema.rowNumberIdentifier { + guard let colIndex = coordinator.dataColumnIndex(from: column.identifier), colIndex < columns.count else { continue } let columnName = columns[colIndex] let shouldHide = configuration.hiddenColumns.contains(columnName) @@ -438,10 +484,6 @@ struct DataGridView: NSViewRepresentable { // MARK: - Column Layout Helpers - static func columnIdentifier(for dataIndex: Int) -> NSUserInterfaceItemIdentifier { - NSUserInterfaceItemIdentifier("col_\(dataIndex)") - } - static func tableColumnIndex(for dataIndex: Int) -> Int { dataIndex + 1 } @@ -450,18 +492,18 @@ struct DataGridView: NSViewRepresentable { tableColumnIndex - 1 } - static func dataColumnIndex(from identifier: NSUserInterfaceItemIdentifier) -> Int? { - let raw = identifier.rawValue - guard raw.hasPrefix("col_") else { return nil } - return Int(raw.dropFirst(4)) - } - - private static func applyColumnOrder(_ order: [String], to tableView: NSTableView, columns: [String]) { + private static func applyColumnOrder( + _ order: [String], + to tableView: NSTableView, + schema: ColumnIdentitySchema, + columns: [String] + ) { guard Set(order) == Set(columns) else { return } var columnByName: [String: NSTableColumn] = [:] - for col in tableView.tableColumns where col.identifier.rawValue != "__rowNumber__" { - if let idx = dataColumnIndex(from: col.identifier), idx < columns.count { + for col in tableView.tableColumns + where col.identifier != ColumnIdentitySchema.rowNumberIdentifier { + if let idx = schema.dataIndex(from: col.identifier), idx < columns.count { columnByName[columns[idx]] = col } } @@ -487,11 +529,14 @@ struct DataGridView: NSViewRepresentable { private static let ascendingSortIndicator = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) private static let descendingSortIndicator = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) - private static func updateSortIndicators(tableView: NSTableView, sortState: SortState, columns: [String]) { + private static func updateSortIndicators( + tableView: NSTableView, + sortState: SortState, + schema: ColumnIdentitySchema + ) { var columnByDataIndex: [Int: NSTableColumn] = [:] for column in tableView.tableColumns { - guard let colIndex = dataColumnIndex(from: column.identifier), - colIndex < columns.count else { continue } + guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } columnByDataIndex[colIndex] = column tableView.setIndicatorImage(nil, in: column) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index b640da6bc..21c7b4611 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -10,11 +10,10 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let column = tableColumn else { return nil } - let columnId = column.identifier.rawValue let tableRows = tableRowsProvider() let displayCount = sortedIDs?.count ?? tableRows.count - if columnId == "__rowNumber__" { + if column.identifier == ColumnIdentitySchema.rowNumberIdentifier { return cellFactory.makeRowNumberCell( tableView: tableView, row: row, @@ -23,7 +22,7 @@ extension TableViewCoordinator { ) } - guard let columnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return nil } + guard let columnIndex = dataColumnIndex(from: column.identifier) else { return nil } guard row >= 0 && row < displayCount, columnIndex >= 0 && columnIndex < cachedColumnCount else { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 2548ba2be..00a2af8a4 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -57,8 +57,8 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { guard let tableColumn else { return false } - guard tableColumn.identifier.rawValue != "__rowNumber__" else { return false } - guard let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier) else { return false } + guard tableColumn.identifier != ColumnIdentitySchema.rowNumberIdentifier else { return false } + guard let columnIndex = dataColumnIndex(from: tableColumn.identifier) else { return false } switch inlineEditEligibility(row: row, columnIndex: columnIndex) { case .eligible: diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 231da68ec..9392f8601 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -8,15 +8,12 @@ import SwiftUI extension TableViewCoordinator { func tableViewColumnDidResize(_ notification: Notification) { - // Only track user-initiated resizes, not programmatic ones during column rebuilds guard !isRebuildingColumns else { return } - hasUserResizedColumns = true scheduleLayoutPersist() } func tableViewColumnDidMove(_ notification: Notification) { guard !isRebuildingColumns else { return } - hasUserResizedColumns = true scheduleLayoutPersist() } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index c9026d896..e46e04e3c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -14,7 +14,7 @@ extension TableViewCoordinator { guard let sortDescriptor = tableView.sortDescriptors.first, let key = sortDescriptor.key, - let columnIndex = DataGridView.dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), + let columnIndex = dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), columnIndex >= 0 && columnIndex < tableRowsProvider().columns.count else { return } @@ -27,10 +27,10 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, sizeToFitWidthOfColumn columnIndex: Int) -> CGFloat { let column = tableView.tableColumns[columnIndex] - guard column.identifier.rawValue != "__rowNumber__" else { + guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier else { return column.width } - guard let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { + guard let dataColumnIndex = dataColumnIndex(from: column.identifier) else { return column.width } @@ -40,7 +40,6 @@ extension TableViewCoordinator { columnIndex: dataColumnIndex, tableRows: tableRows ) - hasUserResizedColumns = true return width } @@ -60,18 +59,18 @@ extension TableViewCoordinator { guard columnIndex >= 0 && columnIndex < tableView.tableColumns.count else { return } let column = tableView.tableColumns[columnIndex] - if column.identifier.rawValue == "__rowNumber__" { return } + if column.identifier == ColumnIdentitySchema.rowNumberIdentifier { return } let tableRows = tableRowsProvider() let baseName: String = { - if let idx = DataGridView.dataColumnIndex(from: column.identifier), + if let idx = dataColumnIndex(from: column.identifier), idx < tableRows.columns.count { return tableRows.columns[idx] } return column.title }() - if let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) { + if let dataColumnIndex = dataColumnIndex(from: column.identifier) { let sortAscItem = NSMenuItem( title: String(localized: "Sort Ascending"), action: #selector(sortAscending(_:)), @@ -103,7 +102,7 @@ extension TableViewCoordinator { filterItem.target = self menu.addItem(filterItem) - if let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) { + if let dataColumnIndex = dataColumnIndex(from: column.identifier) { let columnType = dataColumnIndex < tableRows.columnTypes.count ? tableRows.columnTypes[dataColumnIndex] : nil let applicableFormats = ValueDisplayFormat.applicableFormats(for: columnType) if applicableFormats.count > 1 { @@ -153,7 +152,7 @@ extension TableViewCoordinator { menu.addItem(hideItem) if delegate != nil, - tableView.tableColumns.contains(where: { $0.isHidden && $0.identifier.rawValue != "__rowNumber__" }) { + tableView.tableColumns.contains(where: { $0.isHidden && $0.identifier != ColumnIdentitySchema.rowNumberIdentifier }) { let showAllItem = NSMenuItem( title: String(localized: "Show All Columns"), action: #selector(showAllColumns), @@ -199,7 +198,7 @@ extension TableViewCoordinator { columnIndex >= 0 && columnIndex < tableView.tableColumns.count else { return } let column = tableView.tableColumns[columnIndex] - guard let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return } + guard let dataColumnIndex = dataColumnIndex(from: column.identifier) else { return } let tableRows = tableRowsProvider() let width = cellFactory.calculateFitToContentWidth( @@ -208,7 +207,6 @@ extension TableViewCoordinator { tableRows: tableRows ) column.width = width - hasUserResizedColumns = true } @objc func sizeAllColumnsToFit(_ sender: NSMenuItem) { @@ -216,8 +214,8 @@ extension TableViewCoordinator { let tableRows = tableRowsProvider() for column in tableView.tableColumns { - guard column.identifier.rawValue != "__rowNumber__", - let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { continue } + guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier, + let dataColumnIndex = dataColumnIndex(from: column.identifier) else { continue } let width = cellFactory.calculateFitToContentWidth( for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, @@ -226,7 +224,6 @@ extension TableViewCoordinator { ) column.width = width } - hasUserResizedColumns = true } @objc func setDisplayFormat(_ sender: NSMenuItem) { diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index c07e554ad..1eb8e59e7 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -86,7 +86,7 @@ final class KeyHandlingTableView: NSTableView { } let column = tableColumns[clickedColumn] - if column.identifier.rawValue == "__rowNumber__" { + if column.identifier == ColumnIdentitySchema.rowNumberIdentifier { focusedRow = -1 focusedColumn = -1 return diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 7705b7ce8..23fd2bce9 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -245,7 +245,7 @@ struct TableStructureView: View { await loadColumns() loadSchemaForEditing() isReloadingAfterSave = false - ColumnLayoutStorage.shared.clear(for: tableName, connectionId: connection.id) + FileColumnLayoutPersister.shared.clear(for: tableName, connectionId: connection.id) NotificationCenter.default.post(name: .refreshData, object: nil) } catch { AlertHelper.showErrorSheet( diff --git a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift new file mode 100644 index 000000000..a7ef452f0 --- /dev/null +++ b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift @@ -0,0 +1,72 @@ +// +// ColumnIdentitySchemaTests.swift +// TableProTests +// + +import AppKit +import Testing + +@testable import TablePro + +@Suite("ColumnIdentitySchema") +@MainActor +struct ColumnIdentitySchemaTests { + @Test("Unique columns produce name-based identifiers") + func nameBasedIdentifiers() { + let schema = ColumnIdentitySchema(columns: ["id", "name", "email"]) + #expect(schema.isNameBased) + #expect(schema.identifier(for: 0)?.rawValue == "id") + #expect(schema.identifier(for: 1)?.rawValue == "name") + #expect(schema.identifier(for: 2)?.rawValue == "email") + } + + @Test("Duplicate column names fall back to positional identifiers") + func positionalFallbackForDuplicates() { + let schema = ColumnIdentitySchema(columns: ["a", "b", "a"]) + #expect(!schema.isNameBased) + #expect(schema.identifier(for: 0)?.rawValue == "col_0") + #expect(schema.identifier(for: 1)?.rawValue == "col_1") + #expect(schema.identifier(for: 2)?.rawValue == "col_2") + } + + @Test("Reserved row-number identifier triggers positional fallback") + func rowNumberCollisionFallback() { + let schema = ColumnIdentitySchema(columns: ["__rowNumber__", "name"]) + #expect(!schema.isNameBased) + } + + @Test("dataIndex round-trips for name-based schema") + func roundTripNameBased() { + let schema = ColumnIdentitySchema(columns: ["id", "name", "email"]) + let identifier = NSUserInterfaceItemIdentifier("name") + #expect(schema.dataIndex(from: identifier) == 1) + } + + @Test("dataIndex round-trips for positional schema") + func roundTripPositional() { + let schema = ColumnIdentitySchema(columns: ["a", "b", "a"]) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_2")) == 2) + } + + @Test("Out-of-range identifier returns nil") + func unknownIdentifier() { + let schema = ColumnIdentitySchema(columns: ["id", "name"]) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("missing")) == nil) + #expect(schema.identifier(for: 99) == nil) + #expect(schema.identifier(for: -1) == nil) + } + + @Test("Row-number identifier is excluded from data index") + func rowNumberIsNotDataColumn() { + let schema = ColumnIdentitySchema(columns: ["id", "name"]) + #expect(schema.dataIndex(from: ColumnIdentitySchema.rowNumberIdentifier) == nil) + } + + @Test("Empty schema is constructible and queryable") + func emptySchema() { + let schema = ColumnIdentitySchema.empty + #expect(schema.identifiers.isEmpty) + #expect(schema.isNameBased) + #expect(schema.identifier(for: 0) == nil) + } +} diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift new file mode 100644 index 000000000..5f56056d3 --- /dev/null +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -0,0 +1,117 @@ +// +// FileColumnLayoutPersisterTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("FileColumnLayoutPersister") +@MainActor +struct FileColumnLayoutPersisterTests { + private func makeIsolatedPersister() -> (FileColumnLayoutPersister, URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + let persister = FileColumnLayoutPersister(storageDirectory: directory) + return (persister, directory) + } + + private func cleanup(_ directory: URL) { + try? FileManager.default.removeItem(at: directory) + } + + @Test("Save then load returns the same widths and order") + func roundTrip() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionId = UUID() + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 60, "name": 200, "email": 240] + layout.columnOrder = ["id", "name", "email"] + persister.save(layout, for: "users", connectionId: connectionId) + + let loaded = persister.load(for: "users", connectionId: connectionId) + #expect(loaded?.columnWidths == layout.columnWidths) + #expect(loaded?.columnOrder == layout.columnOrder) + } + + @Test("Loading an unknown table returns nil") + func loadMissing() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + #expect(persister.load(for: "missing", connectionId: UUID()) == nil) + } + + @Test("Save with empty widths is a no-op") + func saveEmptyIsNoOp() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionId = UUID() + persister.save(ColumnLayoutState(), for: "users", connectionId: connectionId) + #expect(persister.load(for: "users", connectionId: connectionId) == nil) + } + + @Test("Multiple tables on the same connection coexist") + func multipleTables() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionId = UUID() + var users = ColumnLayoutState() + users.columnWidths = ["id": 60] + var orders = ColumnLayoutState() + orders.columnWidths = ["total": 120] + + persister.save(users, for: "users", connectionId: connectionId) + persister.save(orders, for: "orders", connectionId: connectionId) + + #expect(persister.load(for: "users", connectionId: connectionId)?.columnWidths == ["id": 60]) + #expect(persister.load(for: "orders", connectionId: connectionId)?.columnWidths == ["total": 120]) + } + + @Test("Clear removes only the targeted table") + func clearTargeted() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } + + let connectionId = UUID() + var a = ColumnLayoutState() + a.columnWidths = ["x": 100] + var b = ColumnLayoutState() + b.columnWidths = ["y": 200] + + persister.save(a, for: "a", connectionId: connectionId) + persister.save(b, for: "b", connectionId: connectionId) + persister.clear(for: "a", connectionId: connectionId) + + #expect(persister.load(for: "a", connectionId: connectionId) == nil) + #expect(persister.load(for: "b", connectionId: connectionId)?.columnWidths == ["y": 200]) + } + + @Test("Save survives a fresh persister instance pointed at the same directory") + func persistenceAcrossInstances() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + let connectionId = UUID() + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 80, "name": 220] + layout.columnOrder = ["name", "id"] + + do { + let persister = FileColumnLayoutPersister(storageDirectory: directory) + persister.save(layout, for: "users", connectionId: connectionId) + } + + let restored = FileColumnLayoutPersister(storageDirectory: directory) + .load(for: "users", connectionId: connectionId) + #expect(restored?.columnWidths == layout.columnWidths) + #expect(restored?.columnOrder == layout.columnOrder) + } +} From e7daf8da4738d04c64301e9d129f74b65f2a6ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 14:31:11 +0700 Subject: [PATCH 03/38] =?UTF-8?q?refactor(datagrid):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20split=20persister=20protocol,=20drop=20redundant=20?= =?UTF-8?q?calls,=20add=20coordinator=20+=20schema-shift=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Storage/ColumnLayoutPersister.swift | 7 - .../Core/Storage/ColumnLayoutPersisting.swift | 13 ++ .../Views/Results/DataGridCellFactory.swift | 1 - TablePro/Views/Results/DataGridView.swift | 7 - .../Models/UI/ColumnIdentitySchemaTests.swift | 39 +++++ .../TableViewCoordinatorLayoutTests.swift | 139 ++++++++++++++++++ 6 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 TablePro/Core/Storage/ColumnLayoutPersisting.swift create mode 100644 TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index d5d24cb94..448bb6323 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -6,13 +6,6 @@ import Foundation import os -@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) -} - @MainActor final class FileColumnLayoutPersister: ColumnLayoutPersisting { static let shared = FileColumnLayoutPersister() diff --git a/TablePro/Core/Storage/ColumnLayoutPersisting.swift b/TablePro/Core/Storage/ColumnLayoutPersisting.swift new file mode 100644 index 000000000..085597c76 --- /dev/null +++ b/TablePro/Core/Storage/ColumnLayoutPersisting.swift @@ -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) +} diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index c1aeaf0f2..f9c3f2cc5 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -9,7 +9,6 @@ import AppKit import QuartzCore -/// Custom button that stores FK row/column context for the click handler @MainActor final class FKArrowButton: NSButton { var fkRow: Int = -1 diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e8fca3f90..e35b44a58 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -139,12 +139,6 @@ struct DataGridView: NSViewRepresentable { context.coordinator.sortedIDs = sortedIDs context.coordinator.syncDisplayFormats(displayFormats) context.coordinator.delegate = delegate - let columnLayoutBinding = $columnLayout - context.coordinator.onColumnLayoutDidChange = { layout in - if columnLayoutBinding.wrappedValue != layout { - columnLayoutBinding.wrappedValue = layout - } - } delegate?.dataGridAttach(tableViewCoordinator: context.coordinator) context.coordinator.dropdownColumns = configuration.dropdownColumns context.coordinator.typePickerColumns = configuration.typePickerColumns @@ -154,7 +148,6 @@ struct DataGridView: NSViewRepresentable { context.coordinator.tableName = configuration.tableName context.coordinator.primaryKeyColumns = configuration.primaryKeyColumns context.coordinator.tabType = configuration.tabType - context.coordinator.rebuildColumnMetadataCache(from: tableRowsProvider()) if let connectionId = configuration.connectionId { context.coordinator.observeTeardown(connectionId: connectionId) } diff --git a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift index a7ef452f0..ee43ab986 100644 --- a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift +++ b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift @@ -69,4 +69,43 @@ struct ColumnIdentitySchemaTests { #expect(schema.isNameBased) #expect(schema.identifier(for: 0) == nil) } + + @Test("Inserting a new column shifts position but the existing identifier still resolves") + func identifiersFollowColumnsAcrossInsert() { + let before = ColumnIdentitySchema(columns: ["id", "name", "email"]) + let after = ColumnIdentitySchema(columns: ["id", "created_at", "name", "email"]) + + let nameId = NSUserInterfaceItemIdentifier("name") + #expect(before.dataIndex(from: nameId) == 1) + #expect(after.dataIndex(from: nameId) == 2) + + let emailId = NSUserInterfaceItemIdentifier("email") + #expect(before.dataIndex(from: emailId) == 2) + #expect(after.dataIndex(from: emailId) == 3) + } + + @Test("Reordering columns reassigns indices but identifiers track the column") + func identifiersFollowColumnsAcrossReorder() { + let before = ColumnIdentitySchema(columns: ["id", "name", "email"]) + let after = ColumnIdentitySchema(columns: ["email", "id", "name"]) + + for column in ["id", "name", "email"] { + let id = NSUserInterfaceItemIdentifier(column) + let beforeIndex = before.dataIndex(from: id) + let afterIndex = after.dataIndex(from: id) + #expect(beforeIndex != nil) + #expect(afterIndex != nil) + #expect(beforeIndex != afterIndex) + } + } + + @Test("Removing a column drops its identifier and keeps the others") + func identifiersDropOnColumnRemoval() { + let before = ColumnIdentitySchema(columns: ["id", "name", "email"]) + let after = ColumnIdentitySchema(columns: ["id", "email"]) + + #expect(after.dataIndex(from: NSUserInterfaceItemIdentifier("name")) == nil) + #expect(before.dataIndex(from: NSUserInterfaceItemIdentifier("email")) == 2) + #expect(after.dataIndex(from: NSUserInterfaceItemIdentifier("email")) == 1) + } } diff --git a/TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift b/TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift new file mode 100644 index 000000000..94a703956 --- /dev/null +++ b/TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift @@ -0,0 +1,139 @@ +// +// TableViewCoordinatorLayoutTests.swift +// TableProTests +// + +import Foundation +import SwiftUI +import Testing + +@testable import TablePro + +@MainActor +private final class FakeColumnLayoutPersister: ColumnLayoutPersisting { + var stored: [String: ColumnLayoutState] = [:] + + func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { + stored[tableName] + } + + func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { + stored[tableName] = layout + } + + func clear(for tableName: String, connectionId: UUID) { + stored.removeValue(forKey: tableName) + } +} + +@Suite("TableViewCoordinator.savedColumnLayout") +@MainActor +struct TableViewCoordinatorLayoutTests { + private func makeCoordinator( + tabType: TabType?, + connectionId: UUID?, + tableName: String?, + persister: ColumnLayoutPersisting + ) -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil + ) + coordinator.tabType = tabType + coordinator.connectionId = connectionId + coordinator.tableName = tableName + coordinator.layoutPersister = persister + return coordinator + } + + private func nonEmptyLayout() -> ColumnLayoutState { + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 60] + return layout + } + + @Test("Table tab returns persisted layout when present, ignoring binding") + func tableTabPrefersPersister() { + let persister = FakeColumnLayoutPersister() + let stored = nonEmptyLayout() + persister.stored["users"] = stored + let coordinator = makeCoordinator( + tabType: .table, + connectionId: UUID(), + tableName: "users", + persister: persister + ) + + var binding = ColumnLayoutState() + binding.columnWidths = ["other": 999] + + let resolved = coordinator.savedColumnLayout(binding: binding) + #expect(resolved?.columnWidths == ["id": 60]) + } + + @Test("Table tab falls back to binding when persister has nothing") + func tableTabFallsBackToBinding() { + let coordinator = makeCoordinator( + tabType: .table, + connectionId: UUID(), + tableName: "users", + persister: FakeColumnLayoutPersister() + ) + let resolved = coordinator.savedColumnLayout(binding: nonEmptyLayout()) + #expect(resolved?.columnWidths == ["id": 60]) + } + + @Test("Table tab returns nil when both persister and binding are empty") + func tableTabBothEmptyReturnsNil() { + let coordinator = makeCoordinator( + tabType: .table, + connectionId: UUID(), + tableName: "users", + persister: FakeColumnLayoutPersister() + ) + #expect(coordinator.savedColumnLayout(binding: ColumnLayoutState()) == nil) + } + + @Test("Non-table tab uses the binding directly") + func nonTableTabUsesBinding() { + let coordinator = makeCoordinator( + tabType: .query, + connectionId: nil, + tableName: nil, + persister: FakeColumnLayoutPersister() + ) + let resolved = coordinator.savedColumnLayout(binding: nonEmptyLayout()) + #expect(resolved?.columnWidths == ["id": 60]) + } + + @Test("Non-table tab returns nil when binding is empty") + func nonTableTabEmptyReturnsNil() { + let coordinator = makeCoordinator( + tabType: .query, + connectionId: nil, + tableName: nil, + persister: FakeColumnLayoutPersister() + ) + #expect(coordinator.savedColumnLayout(binding: ColumnLayoutState()) == nil) + } + + @Test("Table tab without connectionId or tableName falls back to binding") + func tableTabMissingIdentitySkipsPersister() { + let persister = FakeColumnLayoutPersister() + persister.stored["users"] = nonEmptyLayout() + let coordinator = makeCoordinator( + tabType: .table, + connectionId: nil, + tableName: nil, + persister: persister + ) + + var binding = ColumnLayoutState() + binding.columnWidths = ["fallback": 42] + + let resolved = coordinator.savedColumnLayout(binding: binding) + #expect(resolved?.columnWidths == ["fallback": 42]) + } +} From f8438f02924af9236425e772b5b8d56e19cec531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 14:49:25 +0700 Subject: [PATCH 04/38] fix(datagrid): compare column sets not arrays so user reorder does not trigger rebuild path --- TablePro/Views/Results/DataGridView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e35b44a58..7db37b259 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -243,8 +243,8 @@ struct DataGridView: NSViewRepresentable { coordinator.rebuildVisualStateCache() let currentDataColumns = tableView.tableColumns.dropFirst() - let currentColumnIds = currentDataColumns.map { $0.identifier.rawValue } - let expectedColumnIds = coordinator.identitySchema.identifiers.map { $0.rawValue } + let currentColumnIds = Set(currentDataColumns.map { $0.identifier.rawValue }) + let expectedColumnIds = Set(coordinator.identitySchema.identifiers.map { $0.rawValue }) let columnsChanged = !latestRows.columns.isEmpty && (currentColumnIds != expectedColumnIds) let isInitialDataLoad = structureChanged && oldRowCount == 0 && !latestRows.columns.isEmpty From 0f97a36e10ded0d4f750e4baddd85c77a1183dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:00:06 +0700 Subject: [PATCH 05/38] diag(datagrid): trace sort dispatch + SF Symbol fallback for sort indicator images --- TablePro/Views/Results/DataGridView.swift | 10 +++++-- .../Extensions/DataGridView+Sort.swift | 30 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 7db37b259..c4992c1ce 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -519,8 +519,14 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - private static let ascendingSortIndicator = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) - private static let descendingSortIndicator = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) + private static let ascendingSortIndicator: NSImage? = { + NSImage(named: NSImage.Name("NSAscendingSortIndicator")) + ?? NSImage(systemSymbolName: "chevron.up", accessibilityDescription: nil) + }() + private static let descendingSortIndicator: NSImage? = { + NSImage(named: NSImage.Name("NSDescendingSortIndicator")) + ?? NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil) + }() private static func updateSortIndicators( tableView: NSTableView, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index e46e04e3c..2f512743a 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -4,22 +4,42 @@ // import AppKit +import os import SwiftUI +private let sortLogger = Logger(subsystem: "com.TablePro", category: "DataGridSort") + extension TableViewCoordinator { // MARK: - Native Sorting func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { - guard !isSyncingSortDescriptors else { return } + guard !isSyncingSortDescriptors else { + sortLogger.debug("sortDescriptorsDidChange skipped: isSyncing") + return + } + + guard let sortDescriptor = tableView.sortDescriptors.first else { + sortLogger.debug("sortDescriptorsDidChange: no sort descriptor (cleared)") + return + } + + guard let key = sortDescriptor.key else { + sortLogger.error("sortDescriptorsDidChange: descriptor has nil key") + return + } + + guard let columnIndex = dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)) else { + sortLogger.error("sortDescriptorsDidChange: could not resolve key '\(key, privacy: .public)' to a data column index. Schema columns: \(self.identitySchema.identifiers.map { $0.rawValue }, privacy: .public)") + return + } - guard let sortDescriptor = tableView.sortDescriptors.first, - let key = sortDescriptor.key, - let columnIndex = dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), - columnIndex >= 0 && columnIndex < tableRowsProvider().columns.count else { + guard columnIndex >= 0, columnIndex < tableRowsProvider().columns.count else { + sortLogger.error("sortDescriptorsDidChange: columnIndex \(columnIndex) out of range") return } let isMultiSort = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + sortLogger.debug("sortDescriptorsDidChange: dispatching column=\(columnIndex) ascending=\(sortDescriptor.ascending) isMultiSort=\(isMultiSort)") delegate?.dataGridSort(column: columnIndex, ascending: sortDescriptor.ascending, isMultiSort: isMultiSort) } From e266472ca4dfee4e1c8f841b217e158653ba8247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:03:28 +0700 Subject: [PATCH 06/38] fix(datagrid): read live shift modifier from NSEvent for multi-sort detection --- TablePro/Views/Results/Extensions/DataGridView+Sort.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 2f512743a..e2566e8c3 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -38,7 +38,7 @@ extension TableViewCoordinator { return } - let isMultiSort = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + let isMultiSort = NSEvent.modifierFlags.contains(.shift) sortLogger.debug("sortDescriptorsDidChange: dispatching column=\(columnIndex) ascending=\(sortDescriptor.ascending) isMultiSort=\(isMultiSort)") delegate?.dataGridSort(column: columnIndex, ascending: sortDescriptor.ascending, isMultiSort: isMultiSort) } From f57315083aea5dee74d43c90c0b79ca30886dde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:08:02 +0700 Subject: [PATCH 07/38] =?UTF-8?q?diag(datagrid):=20expand=20sort=20logging?= =?UTF-8?q?=20=E2=80=94=20modifier=20flags=20raw,=20indicator=20placement,?= =?UTF-8?q?=20syncSortDescriptors=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/Views/Results/DataGridView.swift | 13 ++++++++++++- .../Results/Extensions/DataGridView+Sort.swift | 9 +++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index c4992c1ce..73719637d 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -7,6 +7,7 @@ // import AppKit +import os import SwiftUI struct CellPosition: Hashable { @@ -421,6 +422,8 @@ struct DataGridView: NSViewRepresentable { coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } + Self.sortDiagLogger.debug("syncSortDescriptors: sortState.columns=\(sortState.columns.map { "\($0.columnIndex):\($0.direction == .ascending ? "asc" : "desc")" }, privacy: .public)") + if !sortState.isSorting { if !tableView.sortDescriptors.isEmpty { tableView.sortDescriptors = [] @@ -519,6 +522,8 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers + fileprivate static let sortDiagLogger = Logger(subsystem: "com.TablePro", category: "DataGridSort") + private static let ascendingSortIndicator: NSImage? = { NSImage(named: NSImage.Name("NSAscendingSortIndicator")) ?? NSImage(systemSymbolName: "chevron.up", accessibilityDescription: nil) @@ -533,6 +538,8 @@ struct DataGridView: NSViewRepresentable { sortState: SortState, schema: ColumnIdentitySchema ) { + sortDiagLogger.debug("updateSortIndicators: ascImage=\(ascendingSortIndicator != nil) descImage=\(descendingSortIndicator != nil) sortCount=\(sortState.columns.count)") + var columnByDataIndex: [Int: NSTableColumn] = [:] for column in tableView.tableColumns { guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } @@ -541,9 +548,13 @@ struct DataGridView: NSViewRepresentable { } for sortCol in sortState.columns { - guard let column = columnByDataIndex[sortCol.columnIndex] else { continue } + guard let column = columnByDataIndex[sortCol.columnIndex] else { + sortDiagLogger.error("updateSortIndicators: no column for data index \(sortCol.columnIndex)") + continue + } let image = sortCol.direction == .ascending ? ascendingSortIndicator : descendingSortIndicator tableView.setIndicatorImage(image, in: column) + sortDiagLogger.debug("updateSortIndicators: set indicator on '\(column.identifier.rawValue, privacy: .public)' direction=\(sortCol.direction == .ascending ? "asc" : "desc")") } if let primary = sortState.columns.first, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index e2566e8c3..7cb18ba9c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -38,8 +38,13 @@ extension TableViewCoordinator { return } - let isMultiSort = NSEvent.modifierFlags.contains(.shift) - sortLogger.debug("sortDescriptorsDidChange: dispatching column=\(columnIndex) ascending=\(sortDescriptor.ascending) isMultiSort=\(isMultiSort)") + let liveFlags = NSEvent.modifierFlags + let eventFlags = NSApp.currentEvent?.modifierFlags ?? [] + let isMultiSort = liveFlags.contains(.shift) || eventFlags.contains(.shift) + let eventTypeRaw = Int(NSApp.currentEvent?.type.rawValue ?? 0) + sortLogger.debug( + "sortDescriptorsDidChange: column=\(columnIndex) ascending=\(sortDescriptor.ascending) isMultiSort=\(isMultiSort) liveFlags=\(liveFlags.rawValue, privacy: .public) eventFlags=\(eventFlags.rawValue, privacy: .public) eventType=\(eventTypeRaw)" + ) delegate?.dataGridSort(column: columnIndex, ascending: sortDescriptor.ascending, isMultiSort: isMultiSort) } From c693a79d4fa7df7e5ba16c72dccaf864ba06805a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:12:56 +0700 Subject: [PATCH 08/38] fix(datagrid): intercept shift+click in custom NSTableHeaderView for multi-sort --- .../Views/Results/DataGridCoordinator.swift | 1 + TablePro/Views/Results/DataGridView.swift | 13 +++-- .../Views/Results/SortableHeaderView.swift | 48 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 TablePro/Views/Results/SortableHeaderView.swift diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 3bb437889..dc631df01 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -28,6 +28,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var layoutPersister: any ColumnLayoutPersisting = FileColumnLayoutPersister.shared var onColumnLayoutDidChange: ((ColumnLayoutState) -> Void)? private(set) var identitySchema: ColumnIdentitySchema = .empty + var currentSortState: SortState = SortState() func columnIdentifier(for dataIndex: Int) -> NSUserInterfaceItemIdentifier? { identitySchema.identifier(for: dataIndex) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 73719637d..6e05ba22a 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -120,11 +120,12 @@ struct DataGridView: NSViewRepresentable { applyColumnVisibility(to: tableView, coordinator: context.coordinator, columns: initialRows.columns) - if let headerView = tableView.headerView { - let headerMenu = NSMenu() - headerMenu.delegate = context.coordinator - headerView.menu = headerMenu - } + let sortableHeader = SortableHeaderView(frame: tableView.headerView?.frame ?? .zero) + sortableHeader.coordinator = context.coordinator + let headerMenu = NSMenu() + headerMenu.delegate = context.coordinator + sortableHeader.menu = headerMenu + tableView.headerView = sortableHeader let hasMoveRow = delegate != nil if hasMoveRow { @@ -422,6 +423,8 @@ struct DataGridView: NSViewRepresentable { coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } + coordinator.currentSortState = sortState + Self.sortDiagLogger.debug("syncSortDescriptors: sortState.columns=\(sortState.columns.map { "\($0.columnIndex):\($0.direction == .ascending ? "asc" : "desc")" }, privacy: .public)") if !sortState.isSorting { diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift new file mode 100644 index 000000000..080555e3c --- /dev/null +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -0,0 +1,48 @@ +// +// SortableHeaderView.swift +// TablePro +// + +import AppKit +import os + +@MainActor +final class SortableHeaderView: NSTableHeaderView { + private static let logger = Logger(subsystem: "com.TablePro", category: "DataGridSort") + + weak var coordinator: TableViewCoordinator? + + override func mouseDown(with event: NSEvent) { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.shift), + let tableView = tableView, + let coordinator = coordinator else { + super.mouseDown(with: event) + return + } + + let pointInHeader = convert(event.locationInWindow, from: nil) + let columnIndex = column(at: pointInHeader) + guard columnIndex >= 0, columnIndex < tableView.numberOfColumns else { + super.mouseDown(with: event) + return + } + + let column = tableView.tableColumns[columnIndex] + guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier, + let dataIndex = coordinator.dataColumnIndex(from: column.identifier) else { + super.mouseDown(with: event) + return + } + + let ascending: Bool + if let existing = coordinator.currentSortState.columns.first(where: { $0.columnIndex == dataIndex }) { + ascending = existing.direction != .ascending + } else { + ascending = true + } + + Self.logger.debug("SortableHeaderView intercepted shift+click: column=\(dataIndex) ascending=\(ascending)") + coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) + } +} From 4c25c340d9fd548660ce7399e73ffd6164bb370d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:22:29 +0700 Subject: [PATCH 09/38] fix(datagrid): set all sort descriptors so multi-sort indicators render on every sorted column --- .../Views/Results/DataGridCoordinator.swift | 2 +- TablePro/Views/Results/DataGridView.swift | 38 ++++++++----------- .../Extensions/DataGridView+Sort.swift | 38 ++++--------------- .../Views/Results/SortableHeaderView.swift | 4 -- 4 files changed, 23 insertions(+), 59 deletions(-) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index dc631df01..42ff46bcf 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -28,7 +28,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var layoutPersister: any ColumnLayoutPersisting = FileColumnLayoutPersister.shared var onColumnLayoutDidChange: ((ColumnLayoutState) -> Void)? private(set) var identitySchema: ColumnIdentitySchema = .empty - var currentSortState: SortState = SortState() + var currentSortState = SortState() func columnIdentifier(for dataIndex: Int) -> NSUserInterfaceItemIdentifier? { identitySchema.identifier(for: dataIndex) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 6e05ba22a..da7fd0adb 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -7,7 +7,6 @@ // import AppKit -import os import SwiftUI struct CellPosition: Hashable { @@ -425,20 +424,13 @@ struct DataGridView: NSViewRepresentable { coordinator.currentSortState = sortState - Self.sortDiagLogger.debug("syncSortDescriptors: sortState.columns=\(sortState.columns.map { "\($0.columnIndex):\($0.direction == .ascending ? "asc" : "desc")" }, privacy: .public)") + let desired: [NSSortDescriptor] = sortState.columns.compactMap { sortCol in + guard let identifier = coordinator.identitySchema.identifier(for: sortCol.columnIndex) else { return nil } + return NSSortDescriptor(key: identifier.rawValue, ascending: sortCol.direction == .ascending) + } - if !sortState.isSorting { - if !tableView.sortDescriptors.isEmpty { - tableView.sortDescriptors = [] - } - } else if let firstSort = sortState.columns.first, - let identifier = coordinator.identitySchema.identifier(for: firstSort.columnIndex) { - let key = identifier.rawValue - let ascending = firstSort.direction == .ascending - let currentDescriptor = tableView.sortDescriptors.first - if currentDescriptor?.key != key || currentDescriptor?.ascending != ascending { - tableView.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)] - } + if !descriptorsEqual(tableView.sortDescriptors, desired) { + tableView.sortDescriptors = desired } Self.updateSortIndicators( @@ -448,6 +440,14 @@ struct DataGridView: NSViewRepresentable { ) } + private func descriptorsEqual(_ lhs: [NSSortDescriptor], _ rhs: [NSSortDescriptor]) -> Bool { + guard lhs.count == rhs.count else { return false } + for (a, b) in zip(lhs, rhs) where a.key != b.key || a.ascending != b.ascending { + return false + } + return true + } + private func reloadAndSyncSelection( tableView: NSTableView, coordinator: TableViewCoordinator, @@ -525,8 +525,6 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - fileprivate static let sortDiagLogger = Logger(subsystem: "com.TablePro", category: "DataGridSort") - private static let ascendingSortIndicator: NSImage? = { NSImage(named: NSImage.Name("NSAscendingSortIndicator")) ?? NSImage(systemSymbolName: "chevron.up", accessibilityDescription: nil) @@ -541,8 +539,6 @@ struct DataGridView: NSViewRepresentable { sortState: SortState, schema: ColumnIdentitySchema ) { - sortDiagLogger.debug("updateSortIndicators: ascImage=\(ascendingSortIndicator != nil) descImage=\(descendingSortIndicator != nil) sortCount=\(sortState.columns.count)") - var columnByDataIndex: [Int: NSTableColumn] = [:] for column in tableView.tableColumns { guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } @@ -551,13 +547,9 @@ struct DataGridView: NSViewRepresentable { } for sortCol in sortState.columns { - guard let column = columnByDataIndex[sortCol.columnIndex] else { - sortDiagLogger.error("updateSortIndicators: no column for data index \(sortCol.columnIndex)") - continue - } + guard let column = columnByDataIndex[sortCol.columnIndex] else { continue } let image = sortCol.direction == .ascending ? ascendingSortIndicator : descendingSortIndicator tableView.setIndicatorImage(image, in: column) - sortDiagLogger.debug("updateSortIndicators: set indicator on '\(column.identifier.rawValue, privacy: .public)' direction=\(sortCol.direction == .ascending ? "asc" : "desc")") } if let primary = sortState.columns.first, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 7cb18ba9c..d86d00c7d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -4,47 +4,23 @@ // import AppKit -import os import SwiftUI -private let sortLogger = Logger(subsystem: "com.TablePro", category: "DataGridSort") - extension TableViewCoordinator { // MARK: - Native Sorting func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { - guard !isSyncingSortDescriptors else { - sortLogger.debug("sortDescriptorsDidChange skipped: isSyncing") - return - } + guard !isSyncingSortDescriptors else { return } - guard let sortDescriptor = tableView.sortDescriptors.first else { - sortLogger.debug("sortDescriptorsDidChange: no sort descriptor (cleared)") + guard let sortDescriptor = tableView.sortDescriptors.first, + let key = sortDescriptor.key, + let columnIndex = dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), + columnIndex >= 0, columnIndex < tableRowsProvider().columns.count else { return } - guard let key = sortDescriptor.key else { - sortLogger.error("sortDescriptorsDidChange: descriptor has nil key") - return - } - - guard let columnIndex = dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)) else { - sortLogger.error("sortDescriptorsDidChange: could not resolve key '\(key, privacy: .public)' to a data column index. Schema columns: \(self.identitySchema.identifiers.map { $0.rawValue }, privacy: .public)") - return - } - - guard columnIndex >= 0, columnIndex < tableRowsProvider().columns.count else { - sortLogger.error("sortDescriptorsDidChange: columnIndex \(columnIndex) out of range") - return - } - - let liveFlags = NSEvent.modifierFlags - let eventFlags = NSApp.currentEvent?.modifierFlags ?? [] - let isMultiSort = liveFlags.contains(.shift) || eventFlags.contains(.shift) - let eventTypeRaw = Int(NSApp.currentEvent?.type.rawValue ?? 0) - sortLogger.debug( - "sortDescriptorsDidChange: column=\(columnIndex) ascending=\(sortDescriptor.ascending) isMultiSort=\(isMultiSort) liveFlags=\(liveFlags.rawValue, privacy: .public) eventFlags=\(eventFlags.rawValue, privacy: .public) eventType=\(eventTypeRaw)" - ) + let isMultiSort = NSEvent.modifierFlags.contains(.shift) + || NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false delegate?.dataGridSort(column: columnIndex, ascending: sortDescriptor.ascending, isMultiSort: isMultiSort) } diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 080555e3c..fe63e7a3e 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -4,12 +4,9 @@ // import AppKit -import os @MainActor final class SortableHeaderView: NSTableHeaderView { - private static let logger = Logger(subsystem: "com.TablePro", category: "DataGridSort") - weak var coordinator: TableViewCoordinator? override func mouseDown(with event: NSEvent) { @@ -42,7 +39,6 @@ final class SortableHeaderView: NSTableHeaderView { ascending = true } - Self.logger.debug("SortableHeaderView intercepted shift+click: column=\(dataIndex) ascending=\(ascending)") coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) } } From ecf0017a8a03d330ec386dbc4892318701e5f125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:26:14 +0700 Subject: [PATCH 10/38] fix(datagrid): custom NSTableHeaderCell renders indicators on every multi-sort column --- TablePro/Views/Results/DataGridView.swift | 23 +++++++++-- .../Views/Results/MultiSortHeaderCell.swift | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 TablePro/Views/Results/MultiSortHeaderCell.swift diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index da7fd0adb..d12e14ab9 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -85,7 +85,10 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in initialRows.columns.enumerated() { guard let identifier = identitySchema.identifier(for: index) else { continue } let column = NSTableColumn(identifier: identifier) - column.title = columnName + let headerCell = MultiSortHeaderCell(textCell: columnName) + headerCell.font = column.headerCell.font + headerCell.alignment = column.headerCell.alignment + column.headerCell = headerCell if index < initialRows.columnTypes.count { let typeName = initialRows.columnTypes[index].rawType ?? initialRows.columnTypes[index].displayName column.headerToolTip = "\(columnName) (\(typeName))" @@ -334,7 +337,10 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in tableRows.columns.enumerated() { guard let identifier = schema.identifier(for: index) else { continue } let column = NSTableColumn(identifier: identifier) - column.title = columnName + let headerCell = MultiSortHeaderCell(textCell: columnName) + headerCell.font = column.headerCell.font + headerCell.alignment = column.headerCell.alignment + column.headerCell = headerCell if index < tableRows.columnTypes.count { let typeName = tableRows.columnTypes[index].rawType ?? tableRows.columnTypes[index].displayName @@ -544,12 +550,19 @@ struct DataGridView: NSViewRepresentable { guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } columnByDataIndex[colIndex] = column tableView.setIndicatorImage(nil, in: column) + if let cell = column.headerCell as? MultiSortHeaderCell { + cell.sortIndicatorImage = nil + cell.sortPriority = 0 + } } - for sortCol in sortState.columns { + for (priority, sortCol) in sortState.columns.enumerated() { guard let column = columnByDataIndex[sortCol.columnIndex] else { continue } let image = sortCol.direction == .ascending ? ascendingSortIndicator : descendingSortIndicator - tableView.setIndicatorImage(image, in: column) + if let cell = column.headerCell as? MultiSortHeaderCell { + cell.sortIndicatorImage = image + cell.sortPriority = priority + 1 + } } if let primary = sortState.columns.first, @@ -558,6 +571,8 @@ struct DataGridView: NSViewRepresentable { } else { tableView.highlightedTableColumn = nil } + + tableView.headerView?.needsDisplay = true } static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) { diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift new file mode 100644 index 000000000..0590f444c --- /dev/null +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -0,0 +1,40 @@ +// +// MultiSortHeaderCell.swift +// TablePro +// + +import AppKit + +@MainActor +final class MultiSortHeaderCell: NSTableHeaderCell { + var sortIndicatorImage: NSImage? + var sortPriority: Int = 0 + + override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { + let imageGap: CGFloat = 4 + let imageSize: CGFloat = 11 + + var titleFrame = cellFrame + if sortIndicatorImage != nil { + titleFrame.size.width -= imageSize + imageGap * 2 + } + super.drawInterior(withFrame: titleFrame, in: controlView) + + guard let image = sortIndicatorImage else { return } + + let imageRect = NSRect( + x: cellFrame.maxX - imageSize - imageGap, + y: cellFrame.midY - imageSize / 2, + width: imageSize, + height: imageSize + ) + image.draw( + in: imageRect, + from: .zero, + operation: .sourceOver, + fraction: 1.0, + respectFlipped: true, + hints: nil + ) + } +} From 99308fc6a86f24bc4e7a5db6d4813dbd2f03512f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:31:47 +0700 Subject: [PATCH 11/38] fix(datagrid): use native NSAscendingSortIndicator at natural size, drop SF Symbol fallback and highlightedTableColumn duplication --- TablePro/Views/Results/DataGridView.swift | 31 ++++++------------- .../Views/Results/MultiSortHeaderCell.swift | 14 ++++----- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index d12e14ab9..6117fd61d 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -531,14 +531,8 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - private static let ascendingSortIndicator: NSImage? = { - NSImage(named: NSImage.Name("NSAscendingSortIndicator")) - ?? NSImage(systemSymbolName: "chevron.up", accessibilityDescription: nil) - }() - private static let descendingSortIndicator: NSImage? = { - NSImage(named: NSImage.Name("NSDescendingSortIndicator")) - ?? NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil) - }() + private static let ascendingSortIndicator = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) + private static let descendingSortIndicator = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) private static func updateSortIndicators( tableView: NSTableView, @@ -549,7 +543,6 @@ struct DataGridView: NSViewRepresentable { for column in tableView.tableColumns { guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } columnByDataIndex[colIndex] = column - tableView.setIndicatorImage(nil, in: column) if let cell = column.headerCell as? MultiSortHeaderCell { cell.sortIndicatorImage = nil cell.sortPriority = 0 @@ -557,21 +550,15 @@ struct DataGridView: NSViewRepresentable { } for (priority, sortCol) in sortState.columns.enumerated() { - guard let column = columnByDataIndex[sortCol.columnIndex] else { continue } - let image = sortCol.direction == .ascending ? ascendingSortIndicator : descendingSortIndicator - if let cell = column.headerCell as? MultiSortHeaderCell { - cell.sortIndicatorImage = image - cell.sortPriority = priority + 1 - } - } - - if let primary = sortState.columns.first, - let column = columnByDataIndex[primary.columnIndex] { - tableView.highlightedTableColumn = column - } else { - tableView.highlightedTableColumn = nil + guard let column = columnByDataIndex[sortCol.columnIndex], + let cell = column.headerCell as? MultiSortHeaderCell else { continue } + cell.sortIndicatorImage = sortCol.direction == .ascending + ? ascendingSortIndicator + : descendingSortIndicator + cell.sortPriority = priority + 1 } + tableView.highlightedTableColumn = nil tableView.headerView?.needsDisplay = true } diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index 0590f444c..c26783424 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -12,21 +12,19 @@ final class MultiSortHeaderCell: NSTableHeaderCell { override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { let imageGap: CGFloat = 4 - let imageSize: CGFloat = 11 var titleFrame = cellFrame - if sortIndicatorImage != nil { - titleFrame.size.width -= imageSize + imageGap * 2 + if let image = sortIndicatorImage { + titleFrame.size.width -= image.size.width + imageGap * 2 } super.drawInterior(withFrame: titleFrame, in: controlView) guard let image = sortIndicatorImage else { return } - let imageRect = NSRect( - x: cellFrame.maxX - imageSize - imageGap, - y: cellFrame.midY - imageSize / 2, - width: imageSize, - height: imageSize + x: cellFrame.maxX - image.size.width - imageGap, + y: cellFrame.midY - image.size.height / 2, + width: image.size.width, + height: image.size.height ) image.draw( in: imageRect, From d91541a873b8d0630b45f242f1575a300354b59a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:34:37 +0700 Subject: [PATCH 12/38] fix(datagrid): clear AppKit's auto sort indicator so custom cell does not double-draw on primary --- TablePro/Views/Results/DataGridView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 6117fd61d..4c5dfa3f4 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -541,6 +541,7 @@ struct DataGridView: NSViewRepresentable { ) { var columnByDataIndex: [Int: NSTableColumn] = [:] for column in tableView.tableColumns { + tableView.setIndicatorImage(nil, in: column) guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } columnByDataIndex[colIndex] = column if let cell = column.headerCell as? MultiSortHeaderCell { From 90a1722fa0c0bdc4819be151ca0f7836e6d389de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:38:05 +0700 Subject: [PATCH 13/38] fix(datagrid): override drawSortIndicator to no-op so AppKit stops drawing duplicate chevron over custom cell --- TablePro/Views/Results/MultiSortHeaderCell.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index c26783424..96a65ecea 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -35,4 +35,11 @@ final class MultiSortHeaderCell: NSTableHeaderCell { hints: nil ) } + + override func drawSortIndicator( + withFrame cellFrame: NSRect, + in controlView: NSView, + ascending: Bool, + priority: Int + ) {} } From a955be957cd0537f818c47b4c3cdc432993f67b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:40:43 +0700 Subject: [PATCH 14/38] fix(datagrid): tint native sort indicator template image with secondaryLabelColor to match AppKit's subtle gray --- TablePro/Views/Results/MultiSortHeaderCell.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index 96a65ecea..b29f115d6 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -34,6 +34,10 @@ final class MultiSortHeaderCell: NSTableHeaderCell { respectFlipped: true, hints: nil ) + if image.isTemplate { + NSColor.secondaryLabelColor.set() + imageRect.fill(using: .sourceAtop) + } } override func drawSortIndicator( From b295cfebf0aaee4af40ddefa239a015096da44c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:43:19 +0700 Subject: [PATCH 15/38] fix(datagrid): tint sort indicator template image in offscreen context so fill respects only image alpha --- .../Views/Results/MultiSortHeaderCell.swift | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index b29f115d6..a86ec0bcd 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -26,18 +26,19 @@ final class MultiSortHeaderCell: NSTableHeaderCell { width: image.size.width, height: image.size.height ) - image.draw( - in: imageRect, - from: .zero, - operation: .sourceOver, - fraction: 1.0, - respectFlipped: true, - hints: nil - ) + + let drawable: NSImage if image.isTemplate { - NSColor.secondaryLabelColor.set() - imageRect.fill(using: .sourceAtop) + drawable = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect) + NSColor.secondaryLabelColor.set() + rect.fill(using: .sourceAtop) + return true + } + } else { + drawable = image } + drawable.draw(in: imageRect) } override func drawSortIndicator( From 02f81f5eb814dc7756d2f07c0e5539a1d47e0fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:45:35 +0700 Subject: [PATCH 16/38] fix(datagrid): tint sort indicator with tertiaryLabelColor for native subtle gray match --- TablePro/Views/Results/MultiSortHeaderCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index a86ec0bcd..7ab368aac 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -31,7 +31,7 @@ final class MultiSortHeaderCell: NSTableHeaderCell { if image.isTemplate { drawable = NSImage(size: image.size, flipped: false) { rect in image.draw(in: rect) - NSColor.secondaryLabelColor.set() + NSColor.tertiaryLabelColor.set() rect.fill(using: .sourceAtop) return true } From bab57ac31bd4a5846a6557ada8fc7f1c6e339b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:53:25 +0700 Subject: [PATCH 17/38] fix(datagrid): use SF Symbol chevron with light weight for sort indicator to match modern AppKit native style --- TablePro/Views/Results/DataGridView.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 4c5dfa3f4..e87eca3c2 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -531,8 +531,21 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - private static let ascendingSortIndicator = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) - private static let descendingSortIndicator = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) + private static let ascendingSortIndicator: NSImage? = { + let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .light) + let image = NSImage(systemSymbolName: "chevron.up", accessibilityDescription: nil)? + .withSymbolConfiguration(config) + image?.isTemplate = true + return image + }() + + private static let descendingSortIndicator: NSImage? = { + let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .light) + let image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)? + .withSymbolConfiguration(config) + image?.isTemplate = true + return image + }() private static func updateSortIndicators( tableView: NSTableView, From 6223708cb482f1f4156b68b56ea6cef1058ad1a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:56:14 +0700 Subject: [PATCH 18/38] fix(datagrid): tweak sort indicator alpha to 0.4 between secondary and tertiary label color --- TablePro/Views/Results/MultiSortHeaderCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index 7ab368aac..05f8cb534 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -31,7 +31,7 @@ final class MultiSortHeaderCell: NSTableHeaderCell { if image.isTemplate { drawable = NSImage(size: image.size, flipped: false) { rect in image.draw(in: rect) - NSColor.tertiaryLabelColor.set() + NSColor.labelColor.withAlphaComponent(0.4).set() rect.fill(using: .sourceAtop) return true } From c26525818c412a7499efe9d798e627402dd43808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 15:58:14 +0700 Subject: [PATCH 19/38] refactor(datagrid): use AppKit's native NSTableHeaderCell.drawSortIndicator for pixel-perfect chevron rendering on every sorted column --- TablePro/Views/Results/DataGridView.swift | 22 ++---------- .../Views/Results/MultiSortHeaderCell.swift | 35 +++++-------------- 2 files changed, 10 insertions(+), 47 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e87eca3c2..19db300a3 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -531,22 +531,6 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - private static let ascendingSortIndicator: NSImage? = { - let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .light) - let image = NSImage(systemSymbolName: "chevron.up", accessibilityDescription: nil)? - .withSymbolConfiguration(config) - image?.isTemplate = true - return image - }() - - private static let descendingSortIndicator: NSImage? = { - let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .light) - let image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)? - .withSymbolConfiguration(config) - image?.isTemplate = true - return image - }() - private static func updateSortIndicators( tableView: NSTableView, sortState: SortState, @@ -558,7 +542,7 @@ struct DataGridView: NSViewRepresentable { guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } columnByDataIndex[colIndex] = column if let cell = column.headerCell as? MultiSortHeaderCell { - cell.sortIndicatorImage = nil + cell.sortAscending = nil cell.sortPriority = 0 } } @@ -566,9 +550,7 @@ struct DataGridView: NSViewRepresentable { for (priority, sortCol) in sortState.columns.enumerated() { guard let column = columnByDataIndex[sortCol.columnIndex], let cell = column.headerCell as? MultiSortHeaderCell else { continue } - cell.sortIndicatorImage = sortCol.direction == .ascending - ? ascendingSortIndicator - : descendingSortIndicator + cell.sortAscending = sortCol.direction == .ascending cell.sortPriority = priority + 1 } diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index 05f8cb534..b0243674b 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -7,38 +7,19 @@ import AppKit @MainActor final class MultiSortHeaderCell: NSTableHeaderCell { - var sortIndicatorImage: NSImage? + var sortAscending: Bool? var sortPriority: Int = 0 override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { - let imageGap: CGFloat = 4 + super.drawInterior(withFrame: cellFrame, in: controlView) - var titleFrame = cellFrame - if let image = sortIndicatorImage { - titleFrame.size.width -= image.size.width + imageGap * 2 - } - super.drawInterior(withFrame: titleFrame, in: controlView) - - guard let image = sortIndicatorImage else { return } - let imageRect = NSRect( - x: cellFrame.maxX - image.size.width - imageGap, - y: cellFrame.midY - image.size.height / 2, - width: image.size.width, - height: image.size.height + guard let ascending = sortAscending else { return } + super.drawSortIndicator( + withFrame: cellFrame, + in: controlView, + ascending: ascending, + priority: sortPriority ) - - let drawable: NSImage - if image.isTemplate { - drawable = NSImage(size: image.size, flipped: false) { rect in - image.draw(in: rect) - NSColor.labelColor.withAlphaComponent(0.4).set() - rect.fill(using: .sourceAtop) - return true - } - } else { - drawable = image - } - drawable.draw(in: imageRect) } override func drawSortIndicator( From c202227cb6fa18bb2563e6f082794771a7b4da56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:01:13 +0700 Subject: [PATCH 20/38] fix(datagrid): SF Symbol chevron with paletteColors config for native tinting --- TablePro/Views/Results/DataGridView.swift | 16 +++++++++++-- .../Views/Results/MultiSortHeaderCell.swift | 23 ++++++++++++------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 19db300a3..f187f13cb 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -531,6 +531,16 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers + private static let ascendingSortIndicator: NSImage? = sortIndicatorSymbol("chevron.up") + private static let descendingSortIndicator: NSImage? = sortIndicatorSymbol("chevron.down") + + private static func sortIndicatorSymbol(_ name: String) -> NSImage? { + let size = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular) + let palette = NSImage.SymbolConfiguration(paletteColors: [.secondaryLabelColor]) + return NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(size.applying(palette)) + } + private static func updateSortIndicators( tableView: NSTableView, sortState: SortState, @@ -542,7 +552,7 @@ struct DataGridView: NSViewRepresentable { guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } columnByDataIndex[colIndex] = column if let cell = column.headerCell as? MultiSortHeaderCell { - cell.sortAscending = nil + cell.sortIndicatorImage = nil cell.sortPriority = 0 } } @@ -550,7 +560,9 @@ struct DataGridView: NSViewRepresentable { for (priority, sortCol) in sortState.columns.enumerated() { guard let column = columnByDataIndex[sortCol.columnIndex], let cell = column.headerCell as? MultiSortHeaderCell else { continue } - cell.sortAscending = sortCol.direction == .ascending + cell.sortIndicatorImage = sortCol.direction == .ascending + ? ascendingSortIndicator + : descendingSortIndicator cell.sortPriority = priority + 1 } diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index b0243674b..d9e406cd3 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -7,19 +7,26 @@ import AppKit @MainActor final class MultiSortHeaderCell: NSTableHeaderCell { - var sortAscending: Bool? + var sortIndicatorImage: NSImage? var sortPriority: Int = 0 override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { - super.drawInterior(withFrame: cellFrame, in: controlView) + let imageGap: CGFloat = 4 - guard let ascending = sortAscending else { return } - super.drawSortIndicator( - withFrame: cellFrame, - in: controlView, - ascending: ascending, - priority: sortPriority + var titleFrame = cellFrame + if let image = sortIndicatorImage { + titleFrame.size.width -= image.size.width + imageGap * 2 + } + super.drawInterior(withFrame: titleFrame, in: controlView) + + guard let image = sortIndicatorImage else { return } + let imageRect = NSRect( + x: cellFrame.maxX - image.size.width - imageGap, + y: cellFrame.midY - image.size.height / 2, + width: image.size.width, + height: image.size.height ) + image.draw(in: imageRect) } override func drawSortIndicator( From 9c57e2b8eac4ea0c35cbbd9d3fafccba0fc13408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:03:37 +0700 Subject: [PATCH 21/38] fix(datagrid): reduce sort indicator pointSize to 9 for smaller chevron --- TablePro/Views/Results/DataGridView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index f187f13cb..aba36ae83 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -535,7 +535,7 @@ struct DataGridView: NSViewRepresentable { private static let descendingSortIndicator: NSImage? = sortIndicatorSymbol("chevron.down") private static func sortIndicatorSymbol(_ name: String) -> NSImage? { - let size = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular) + let size = NSImage.SymbolConfiguration(pointSize: 9, weight: .regular) let palette = NSImage.SymbolConfiguration(paletteColors: [.secondaryLabelColor]) return NSImage(systemSymbolName: name, accessibilityDescription: nil)? .withSymbolConfiguration(size.applying(palette)) From f4d9c9e11ad292665a469e5633fccd2ae91ae100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:09:48 +0700 Subject: [PATCH 22/38] =?UTF-8?q?feat(datagrid):=203-state=20sort=20cycle?= =?UTF-8?q?=20+=20'Don't=20Sort'=20context=20menu=20=E2=80=94=20native=20H?= =?UTF-8?q?IG-correct=20unsort=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/Child/DataTabGridDelegate.swift | 5 ++ .../Main/Child/MainEditorContentView.swift | 2 + .../Views/Main/MainContentCoordinator.swift | 27 +++++++++ TablePro/Views/Main/MainContentView.swift | 3 + .../Views/Results/DataGridViewDelegate.swift | 2 + .../Extensions/DataGridView+Sort.swift | 14 +++++ .../Views/Results/SortableHeaderView.swift | 58 +++++++++++++++++-- 7 files changed, 105 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index abdce6059..5777bc4d0 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -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)? @@ -32,6 +33,10 @@ final class DataTabGridDelegate: DataGridViewDelegate { onSort?(column, ascending, isMultiSort) } + func dataGridClearSort() { + onClearSort?() + } + func dataGridAddRow() { onAddRow?() } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index b61c2f39a..e58dbdebb 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -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) -> Void @@ -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 diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 687909dc9..b307610c9 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -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?])], diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 604b588f6..88a89804e 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -410,6 +410,9 @@ struct MainContentView: View { columnIndex: columnIndex, ascending: ascending, isMultiSort: isMultiSort) }, + onClearSort: { + coordinator.clearSort() + }, onAddRow: { coordinator.addNewRow() }, diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 7ea875e49..bea97b67a 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -19,6 +19,7 @@ protocol DataGridViewDelegate: AnyObject { func dataGridUndoInsert(at index: Int) func dataGridMoveRow(from source: Int, to destination: Int) func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) + func dataGridClearSort() func dataGridFilterColumn(_ columnName: String) func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) func dataGridDuplicateRow() @@ -46,6 +47,7 @@ extension DataGridViewDelegate { func dataGridUndoInsert(at index: Int) {} func dataGridMoveRow(from source: Int, to destination: Int) {} func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) {} + func dataGridClearSort() {} func dataGridFilterColumn(_ columnName: String) {} func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) {} func dataGridDuplicateRow() {} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index d86d00c7d..f108edf30 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -90,6 +90,16 @@ extension TableViewCoordinator { sortDescItem.target = self menu.addItem(sortDescItem) + if currentSortState.isSorting { + let clearSortItem = NSMenuItem( + title: String(localized: "Don't Sort"), + action: #selector(clearSortAction), + keyEquivalent: "" + ) + clearSortItem.target = self + menu.addItem(clearSortItem) + } + menu.addItem(NSMenuItem.separator()) } @@ -178,6 +188,10 @@ extension TableViewCoordinator { delegate?.dataGridShowAllColumns() } + @objc func clearSortAction() { + delegate?.dataGridClearSort() + } + @objc func copyColumnName(_ sender: NSMenuItem) { guard let columnName = sender.representedObject as? String else { return } ClipboardService.shared.writeText(columnName) diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index fe63e7a3e..14fc594a9 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -10,9 +10,7 @@ final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? override func mouseDown(with event: NSEvent) { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard flags.contains(.shift), - let tableView = tableView, + guard let tableView = tableView, let coordinator = coordinator else { super.mouseDown(with: event) return @@ -32,13 +30,61 @@ final class SortableHeaderView: NSTableHeaderView { return } + if isInResizeArea(point: pointInHeader, columnIndex: columnIndex, in: tableView) { + super.mouseDown(with: event) + return + } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let isMultiSort = flags.contains(.shift) + let sortState = coordinator.currentSortState + let existing = sortState.columns.first(where: { $0.columnIndex == dataIndex }) + + if isMultiSort { + handleMultiSortClick(coordinator: coordinator, dataIndex: dataIndex, existing: existing) + } else { + handleSingleSortClick(coordinator: coordinator, dataIndex: dataIndex, sortState: sortState, existing: existing) + } + } + + private func handleMultiSortClick( + coordinator: TableViewCoordinator, + dataIndex: Int, + existing: SortColumn? + ) { let ascending: Bool - if let existing = coordinator.currentSortState.columns.first(where: { $0.columnIndex == dataIndex }) { - ascending = existing.direction != .ascending + if existing == nil { + ascending = true + } else { + ascending = false + } + coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) + } + + private func handleSingleSortClick( + coordinator: TableViewCoordinator, + dataIndex: Int, + sortState: SortState, + existing: SortColumn? + ) { + let isOnlyColumn = sortState.columns.count == 1 && existing != nil + if isOnlyColumn, existing?.direction == .descending { + coordinator.delegate?.dataGridClearSort() + return + } + + let ascending: Bool + if isOnlyColumn, existing?.direction == .ascending { + ascending = false } else { ascending = true } + coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: false) + } - coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) + private func isInResizeArea(point: NSPoint, columnIndex: Int, in tableView: NSTableView) -> Bool { + let columnRect = headerRect(ofColumn: columnIndex) + let resizeMargin: CGFloat = 4 + return point.x > columnRect.maxX - resizeMargin && point.x <= columnRect.maxX + resizeMargin } } From fd88b234f2c609863ea425fc3020b0353abe866c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:34:51 +0700 Subject: [PATCH 23/38] =?UTF-8?q?refactor(datagrid):=20split=20sort=20hand?= =?UTF-8?q?ling=20=E2=80=94=20AppKit=20drives=20single-click=203-state,=20?= =?UTF-8?q?SortableHeaderView=20only=20intercepts=20shift-click=20for=20mu?= =?UTF-8?q?lti-sort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/Views/Results/DataGridView.swift | 22 ++++---- .../Extensions/DataGridView+Sort.swift | 16 ++++-- .../Views/Results/SortableHeaderView.swift | 54 ++----------------- 3 files changed, 25 insertions(+), 67 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index aba36ae83..9f8bfe563 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -430,12 +430,18 @@ struct DataGridView: NSViewRepresentable { coordinator.currentSortState = sortState - let desired: [NSSortDescriptor] = sortState.columns.compactMap { sortCol in - guard let identifier = coordinator.identitySchema.identifier(for: sortCol.columnIndex) else { return nil } - return NSSortDescriptor(key: identifier.rawValue, ascending: sortCol.direction == .ascending) + let primary: NSSortDescriptor? + if let firstSort = sortState.columns.first, + let identifier = coordinator.identitySchema.identifier(for: firstSort.columnIndex) { + primary = NSSortDescriptor(key: identifier.rawValue, ascending: firstSort.direction == .ascending) + } else { + primary = nil } - if !descriptorsEqual(tableView.sortDescriptors, desired) { + let desired = primary.map { [$0] } ?? [] + let current = tableView.sortDescriptors.first + let needsUpdate = (current?.key != primary?.key) || (current?.ascending != primary?.ascending) + if needsUpdate { tableView.sortDescriptors = desired } @@ -446,14 +452,6 @@ struct DataGridView: NSViewRepresentable { ) } - private func descriptorsEqual(_ lhs: [NSSortDescriptor], _ rhs: [NSSortDescriptor]) -> Bool { - guard lhs.count == rhs.count else { return false } - for (a, b) in zip(lhs, rhs) where a.key != b.key || a.ascending != b.ascending { - return false - } - return true - } - private func reloadAndSyncSelection( tableView: NSTableView, coordinator: TableViewCoordinator, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index f108edf30..7a193c33b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -12,16 +12,22 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { guard !isSyncingSortDescriptors else { return } - guard let sortDescriptor = tableView.sortDescriptors.first, - let key = sortDescriptor.key, + guard let newDescriptor = tableView.sortDescriptors.first, + let key = newDescriptor.key, let columnIndex = dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), columnIndex >= 0, columnIndex < tableRowsProvider().columns.count else { return } - let isMultiSort = NSEvent.modifierFlags.contains(.shift) - || NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - delegate?.dataGridSort(column: columnIndex, ascending: sortDescriptor.ascending, isMultiSort: isMultiSort) + if let oldDescriptor = oldDescriptors.first, + oldDescriptor.key == newDescriptor.key, + oldDescriptor.ascending == false, + newDescriptor.ascending == true { + delegate?.dataGridClearSort() + return + } + + delegate?.dataGridSort(column: columnIndex, ascending: newDescriptor.ascending, isMultiSort: false) } // MARK: - Double-Click Column Divider Auto-Fit diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 14fc594a9..6977a019d 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -10,7 +10,9 @@ final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? override func mouseDown(with event: NSEvent) { - guard let tableView = tableView, + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.shift), + let tableView = tableView, let coordinator = coordinator else { super.mouseDown(with: event) return @@ -30,28 +32,7 @@ final class SortableHeaderView: NSTableHeaderView { return } - if isInResizeArea(point: pointInHeader, columnIndex: columnIndex, in: tableView) { - super.mouseDown(with: event) - return - } - - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let isMultiSort = flags.contains(.shift) - let sortState = coordinator.currentSortState - let existing = sortState.columns.first(where: { $0.columnIndex == dataIndex }) - - if isMultiSort { - handleMultiSortClick(coordinator: coordinator, dataIndex: dataIndex, existing: existing) - } else { - handleSingleSortClick(coordinator: coordinator, dataIndex: dataIndex, sortState: sortState, existing: existing) - } - } - - private func handleMultiSortClick( - coordinator: TableViewCoordinator, - dataIndex: Int, - existing: SortColumn? - ) { + let existing = coordinator.currentSortState.columns.first(where: { $0.columnIndex == dataIndex }) let ascending: Bool if existing == nil { ascending = true @@ -60,31 +41,4 @@ final class SortableHeaderView: NSTableHeaderView { } coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) } - - private func handleSingleSortClick( - coordinator: TableViewCoordinator, - dataIndex: Int, - sortState: SortState, - existing: SortColumn? - ) { - let isOnlyColumn = sortState.columns.count == 1 && existing != nil - if isOnlyColumn, existing?.direction == .descending { - coordinator.delegate?.dataGridClearSort() - return - } - - let ascending: Bool - if isOnlyColumn, existing?.direction == .ascending { - ascending = false - } else { - ascending = true - } - coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: false) - } - - private func isInResizeArea(point: NSPoint, columnIndex: Int, in tableView: NSTableView) -> Bool { - let columnRect = headerRect(ofColumn: columnIndex) - let resizeMargin: CGFloat = 4 - return point.x > columnRect.maxX - resizeMargin && point.x <= columnRect.maxX + resizeMargin - } } From 09f5222e50492ef4280438dae691fa88ca912401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:41:50 +0700 Subject: [PATCH 24/38] i18n: add 'Don't Sort' string to Localizable catalog --- TablePro/Resources/Localizable.xcstrings | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ffb2a392e..07628c4c0 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -14085,6 +14085,9 @@ } } } + }, + "Don't Sort" : { + }, "Done" : { From cce7b9575710cfc496b1cb664eaf72dea52e5b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:46:15 +0700 Subject: [PATCH 25/38] fix(datagrid): override init(coder:) + copy(with:) on MultiSortHeaderCell to fix ARC crash on sort --- TablePro/Views/Results/MultiSortHeaderCell.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index d9e406cd3..13cb691dd 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -10,6 +10,21 @@ final class MultiSortHeaderCell: NSTableHeaderCell { var sortIndicatorImage: NSImage? var sortPriority: Int = 0 + override init(textCell string: String) { + super.init(textCell: string) + } + + required init(coder: NSCoder) { + super.init(coder: coder) + } + + override func copy(with zone: NSZone? = nil) -> Any { + let copy = super.copy(with: zone) as! MultiSortHeaderCell + copy.sortIndicatorImage = sortIndicatorImage + copy.sortPriority = sortPriority + return copy + } + override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { let imageGap: CGFloat = 4 From dabb7ccf2b9e1857d34f45f769c72c2473a7948b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:49:51 +0700 Subject: [PATCH 26/38] fix(datagrid): drop SF Symbol paletteColors config, tint at draw-time in offscreen context to avoid ARC crash on sort --- TablePro/Views/Results/DataGridView.swift | 9 +++++---- TablePro/Views/Results/MultiSortHeaderCell.swift | 9 ++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 9f8bfe563..0f8ff4dda 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -533,10 +533,11 @@ struct DataGridView: NSViewRepresentable { private static let descendingSortIndicator: NSImage? = sortIndicatorSymbol("chevron.down") private static func sortIndicatorSymbol(_ name: String) -> NSImage? { - let size = NSImage.SymbolConfiguration(pointSize: 9, weight: .regular) - let palette = NSImage.SymbolConfiguration(paletteColors: [.secondaryLabelColor]) - return NSImage(systemSymbolName: name, accessibilityDescription: nil)? - .withSymbolConfiguration(size.applying(palette)) + let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .regular) + let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(config) + image?.isTemplate = true + return image } private static func updateSortIndicators( diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift index 13cb691dd..cbf366a19 100644 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ b/TablePro/Views/Results/MultiSortHeaderCell.swift @@ -41,7 +41,14 @@ final class MultiSortHeaderCell: NSTableHeaderCell { width: image.size.width, height: image.size.height ) - image.draw(in: imageRect) + + let tinted = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect) + NSColor.secondaryLabelColor.set() + rect.fill(using: .sourceAtop) + return true + } + tinted.draw(in: imageRect) } override func drawSortIndicator( From cbc5611b4bd511f2e1485de05944941652c926c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:54:10 +0700 Subject: [PATCH 27/38] fix(datagrid): rip out MultiSortHeaderCell, use pure native NSTableView setIndicatorImage API to fix crash --- TablePro/Views/Results/DataGridView.swift | 48 +++++---------- .../Views/Results/MultiSortHeaderCell.swift | 60 ------------------- 2 files changed, 16 insertions(+), 92 deletions(-) delete mode 100644 TablePro/Views/Results/MultiSortHeaderCell.swift diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0f8ff4dda..da9f3e588 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -85,10 +85,7 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in initialRows.columns.enumerated() { guard let identifier = identitySchema.identifier(for: index) else { continue } let column = NSTableColumn(identifier: identifier) - let headerCell = MultiSortHeaderCell(textCell: columnName) - headerCell.font = column.headerCell.font - headerCell.alignment = column.headerCell.alignment - column.headerCell = headerCell + column.title = columnName if index < initialRows.columnTypes.count { let typeName = initialRows.columnTypes[index].rawType ?? initialRows.columnTypes[index].displayName column.headerToolTip = "\(columnName) (\(typeName))" @@ -337,10 +334,7 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in tableRows.columns.enumerated() { guard let identifier = schema.identifier(for: index) else { continue } let column = NSTableColumn(identifier: identifier) - let headerCell = MultiSortHeaderCell(textCell: columnName) - headerCell.font = column.headerCell.font - headerCell.alignment = column.headerCell.alignment - column.headerCell = headerCell + column.title = columnName if index < tableRows.columnTypes.count { let typeName = tableRows.columnTypes[index].rawType ?? tableRows.columnTypes[index].displayName @@ -529,16 +523,8 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - private static let ascendingSortIndicator: NSImage? = sortIndicatorSymbol("chevron.up") - private static let descendingSortIndicator: NSImage? = sortIndicatorSymbol("chevron.down") - - private static func sortIndicatorSymbol(_ name: String) -> NSImage? { - let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .regular) - let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? - .withSymbolConfiguration(config) - image?.isTemplate = true - return image - } + private static let ascendingSortIndicator = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) + private static let descendingSortIndicator = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) private static func updateSortIndicators( tableView: NSTableView, @@ -548,25 +534,23 @@ struct DataGridView: NSViewRepresentable { var columnByDataIndex: [Int: NSTableColumn] = [:] for column in tableView.tableColumns { tableView.setIndicatorImage(nil, in: column) - guard let colIndex = schema.dataIndex(from: column.identifier) else { continue } - columnByDataIndex[colIndex] = column - if let cell = column.headerCell as? MultiSortHeaderCell { - cell.sortIndicatorImage = nil - cell.sortPriority = 0 + if let colIndex = schema.dataIndex(from: column.identifier) { + columnByDataIndex[colIndex] = column } } - for (priority, sortCol) in sortState.columns.enumerated() { - guard let column = columnByDataIndex[sortCol.columnIndex], - let cell = column.headerCell as? MultiSortHeaderCell else { continue } - cell.sortIndicatorImage = sortCol.direction == .ascending - ? ascendingSortIndicator - : descendingSortIndicator - cell.sortPriority = priority + 1 + for sortCol in sortState.columns { + guard let column = columnByDataIndex[sortCol.columnIndex] else { continue } + let image = sortCol.direction == .ascending ? ascendingSortIndicator : descendingSortIndicator + tableView.setIndicatorImage(image, in: column) } - tableView.highlightedTableColumn = nil - tableView.headerView?.needsDisplay = true + if let primary = sortState.columns.first, + let column = columnByDataIndex[primary.columnIndex] { + tableView.highlightedTableColumn = column + } else { + tableView.highlightedTableColumn = nil + } } static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) { diff --git a/TablePro/Views/Results/MultiSortHeaderCell.swift b/TablePro/Views/Results/MultiSortHeaderCell.swift deleted file mode 100644 index cbf366a19..000000000 --- a/TablePro/Views/Results/MultiSortHeaderCell.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// MultiSortHeaderCell.swift -// TablePro -// - -import AppKit - -@MainActor -final class MultiSortHeaderCell: NSTableHeaderCell { - var sortIndicatorImage: NSImage? - var sortPriority: Int = 0 - - override init(textCell string: String) { - super.init(textCell: string) - } - - required init(coder: NSCoder) { - super.init(coder: coder) - } - - override func copy(with zone: NSZone? = nil) -> Any { - let copy = super.copy(with: zone) as! MultiSortHeaderCell - copy.sortIndicatorImage = sortIndicatorImage - copy.sortPriority = sortPriority - return copy - } - - override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { - let imageGap: CGFloat = 4 - - var titleFrame = cellFrame - if let image = sortIndicatorImage { - titleFrame.size.width -= image.size.width + imageGap * 2 - } - super.drawInterior(withFrame: titleFrame, in: controlView) - - guard let image = sortIndicatorImage else { return } - let imageRect = NSRect( - x: cellFrame.maxX - image.size.width - imageGap, - y: cellFrame.midY - image.size.height / 2, - width: image.size.width, - height: image.size.height - ) - - let tinted = NSImage(size: image.size, flipped: false) { rect in - image.draw(in: rect) - NSColor.secondaryLabelColor.set() - rect.fill(using: .sourceAtop) - return true - } - tinted.draw(in: imageRect) - } - - override func drawSortIndicator( - withFrame cellFrame: NSRect, - in controlView: NSView, - ascending: Bool, - priority: Int - ) {} -} From b20e8a8efc43b542b391c31e456af8d0363b21fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:57:07 +0700 Subject: [PATCH 28/38] =?UTF-8?q?refactor(datagrid):=20NSImageView=20overl?= =?UTF-8?q?ays=20on=20header=20for=20multi-sort=20indicators=20=E2=80=94?= =?UTF-8?q?=20pure=20native,=20no=20NSCell=20subclass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/Views/Results/DataGridView.swift | 37 +--------- .../Views/Results/SortableHeaderView.swift | 71 +++++++++++++++++-- 2 files changed, 68 insertions(+), 40 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index da9f3e588..f115489e7 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -439,11 +439,9 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = desired } - Self.updateSortIndicators( - tableView: tableView, - sortState: sortState, - schema: coordinator.identitySchema - ) + if let header = tableView.headerView as? SortableHeaderView { + header.updateSortIndicators(state: sortState, schema: coordinator.identitySchema) + } } private func reloadAndSyncSelection( @@ -523,35 +521,6 @@ struct DataGridView: NSViewRepresentable { // MARK: - Sort Indicator Helpers - private static let ascendingSortIndicator = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) - private static let descendingSortIndicator = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) - - private static func updateSortIndicators( - tableView: NSTableView, - sortState: SortState, - schema: ColumnIdentitySchema - ) { - var columnByDataIndex: [Int: NSTableColumn] = [:] - for column in tableView.tableColumns { - tableView.setIndicatorImage(nil, in: column) - if let colIndex = schema.dataIndex(from: column.identifier) { - columnByDataIndex[colIndex] = column - } - } - - for sortCol in sortState.columns { - guard let column = columnByDataIndex[sortCol.columnIndex] else { continue } - let image = sortCol.direction == .ascending ? ascendingSortIndicator : descendingSortIndicator - tableView.setIndicatorImage(image, in: column) - } - - if let primary = sortState.columns.first, - let column = columnByDataIndex[primary.columnIndex] { - tableView.highlightedTableColumn = column - } else { - tableView.highlightedTableColumn = nil - } - } static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) { coordinator.overlayEditor?.dismiss(commit: false) diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 6977a019d..5e458abec 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -9,6 +9,70 @@ import AppKit final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? + private var indicatorViews: [String: NSImageView] = [:] + private static let ascendingImage = NSImage(named: NSImage.Name("NSAscendingSortIndicator")) + private static let descendingImage = NSImage(named: NSImage.Name("NSDescendingSortIndicator")) + + func updateSortIndicators(state: SortState, schema: ColumnIdentitySchema) { + let activeKeys: Set = Set(state.columns.compactMap { + schema.identifier(for: $0.columnIndex)?.rawValue + }) + + for (key, view) in indicatorViews where !activeKeys.contains(key) { + view.removeFromSuperview() + indicatorViews.removeValue(forKey: key) + } + + for sortCol in state.columns { + guard let identifier = schema.identifier(for: sortCol.columnIndex) else { continue } + let view = indicatorViews[identifier.rawValue] ?? makeIndicatorView() + view.image = sortCol.direction == .ascending ? Self.ascendingImage : Self.descendingImage + if view.superview == nil { + addSubview(view) + } + indicatorViews[identifier.rawValue] = view + } + + repositionIndicators() + } + + override func layout() { + super.layout() + repositionIndicators() + } + + private func repositionIndicators() { + guard let tableView = tableView else { return } + let padding: CGFloat = 4 + + for (key, view) in indicatorViews { + let identifier = NSUserInterfaceItemIdentifier(key) + let columnIndex = tableView.column(withIdentifier: identifier) + guard columnIndex >= 0 else { + view.isHidden = true + continue + } + view.isHidden = false + let columnRect = headerRect(ofColumn: columnIndex) + let imageSize = view.image?.size ?? NSSize(width: 9, height: 6) + view.frame = NSRect( + x: columnRect.maxX - imageSize.width - padding, + y: columnRect.midY - imageSize.height / 2, + width: imageSize.width, + height: imageSize.height + ) + } + } + + private func makeIndicatorView() -> NSImageView { + let view = NSImageView() + view.imageScaling = .scaleNone + view.imageAlignment = .alignCenter + view.contentTintColor = .secondaryLabelColor + view.translatesAutoresizingMaskIntoConstraints = true + return view + } + override func mouseDown(with event: NSEvent) { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard flags.contains(.shift), @@ -33,12 +97,7 @@ final class SortableHeaderView: NSTableHeaderView { } let existing = coordinator.currentSortState.columns.first(where: { $0.columnIndex == dataIndex }) - let ascending: Bool - if existing == nil { - ascending = true - } else { - ascending = false - } + let ascending = existing == nil coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) } } From 69640842973b3bc1c238f724c65880086ac7c1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 16:59:12 +0700 Subject: [PATCH 29/38] fix(datagrid): clear highlightedTableColumn so AppKit does not auto-draw duplicate chevron + bold title on primary --- TablePro/Views/Results/DataGridView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index f115489e7..5db783a91 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -439,6 +439,8 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = desired } + tableView.highlightedTableColumn = nil + if let header = tableView.headerView as? SortableHeaderView { header.updateSortIndicators(state: sortState, schema: coordinator.identitySchema) } From e0918b3ef77978c4f5688bccb4ae00773fad28b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 17:01:15 +0700 Subject: [PATCH 30/38] fix(datagrid): explicit setIndicatorImage nil to clear AppKit auto-indicator on primary sort column --- TablePro/Views/Results/DataGridView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 5db783a91..87ea1d809 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -439,6 +439,9 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = desired } + for column in tableView.tableColumns { + tableView.setIndicatorImage(nil, in: column) + } tableView.highlightedTableColumn = nil if let header = tableView.headerView as? SortableHeaderView { From cd222f208519743141268998c5a92dddced3635b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 29 Apr 2026 17:04:03 +0700 Subject: [PATCH 31/38] =?UTF-8?q?fix(datagrid):=20stateless=20NSTableHeade?= =?UTF-8?q?rCell=20subclass=20overrides=20drawSortIndicator=20to=20no-op?= =?UTF-8?q?=20=E2=80=94=20kills=20AppKit=20auto-indicator=20at=20source,?= =?UTF-8?q?=20no=20Swift=20property=20=3D=20no=20ARC=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/Views/Results/DataGridView.swift | 13 ++++++---- .../Results/SuppressedSortIndicatorCell.swift | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 TablePro/Views/Results/SuppressedSortIndicatorCell.swift diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 87ea1d809..4db032382 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -85,7 +85,10 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in initialRows.columns.enumerated() { guard let identifier = identitySchema.identifier(for: index) else { continue } let column = NSTableColumn(identifier: identifier) - column.title = columnName + let suppressedCell = SuppressedSortIndicatorCell(textCell: columnName) + suppressedCell.font = column.headerCell.font + suppressedCell.alignment = column.headerCell.alignment + column.headerCell = suppressedCell if index < initialRows.columnTypes.count { let typeName = initialRows.columnTypes[index].rawType ?? initialRows.columnTypes[index].displayName column.headerToolTip = "\(columnName) (\(typeName))" @@ -334,7 +337,10 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in tableRows.columns.enumerated() { guard let identifier = schema.identifier(for: index) else { continue } let column = NSTableColumn(identifier: identifier) - column.title = columnName + let suppressedCell = SuppressedSortIndicatorCell(textCell: columnName) + suppressedCell.font = column.headerCell.font + suppressedCell.alignment = column.headerCell.alignment + column.headerCell = suppressedCell if index < tableRows.columnTypes.count { let typeName = tableRows.columnTypes[index].rawType ?? tableRows.columnTypes[index].displayName @@ -439,9 +445,6 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = desired } - for column in tableView.tableColumns { - tableView.setIndicatorImage(nil, in: column) - } tableView.highlightedTableColumn = nil if let header = tableView.headerView as? SortableHeaderView { diff --git a/TablePro/Views/Results/SuppressedSortIndicatorCell.swift b/TablePro/Views/Results/SuppressedSortIndicatorCell.swift new file mode 100644 index 000000000..95883d8a4 --- /dev/null +++ b/TablePro/Views/Results/SuppressedSortIndicatorCell.swift @@ -0,0 +1,24 @@ +// +// SuppressedSortIndicatorCell.swift +// TablePro +// + +import AppKit + +@MainActor +final class SuppressedSortIndicatorCell: NSTableHeaderCell { + override init(textCell string: String) { + super.init(textCell: string) + } + + required init(coder: NSCoder) { + super.init(coder: coder) + } + + override func drawSortIndicator( + withFrame cellFrame: NSRect, + in controlView: NSView, + ascending: Bool, + priority: Int + ) {} +} From ffe9fe50672e44abfa795ba30b59061e8f9b5518 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 18:44:02 +0700 Subject: [PATCH 32/38] refactor(datagrid): inject ColumnLayoutPersisting per view, drop shared singleton --- .../Core/Storage/ColumnLayoutPersister.swift | 2 -- .../Views/Results/DataGridCoordinator.swift | 6 ++-- TablePro/Views/Results/DataGridView.swift | 32 ++++++++++++------- .../Views/Structure/TableStructureView.swift | 4 ++- .../Extensions/CellPasteRoutingTests.swift | 13 ++++++-- .../TableViewCoordinatorLayoutTests.swift | 4 +-- 6 files changed, 40 insertions(+), 21 deletions(-) diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index 448bb6323..af63e3460 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -8,8 +8,6 @@ import os @MainActor final class FileColumnLayoutPersister: ColumnLayoutPersisting { - static let shared = FileColumnLayoutPersister() - 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" diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 42ff46bcf..5862bdfee 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -25,7 +25,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var primaryKeyColumns: [String] = [] var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? - var layoutPersister: any ColumnLayoutPersisting = FileColumnLayoutPersister.shared + var layoutPersister: any ColumnLayoutPersisting var onColumnLayoutDidChange: ((ColumnLayoutState) -> Void)? private(set) var identitySchema: ColumnIdentitySchema = .empty var currentSortState = SortState() @@ -120,12 +120,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData changeManager: AnyChangeManager, isEditable: Bool, selectedRowIndices: Binding>, - delegate: (any DataGridViewDelegate)? + delegate: (any DataGridViewDelegate)?, + layoutPersister: any ColumnLayoutPersisting ) { self.changeManager = changeManager self.isEditable = isEditable self._selectedRowIndices = selectedRowIndices self.delegate = delegate + self.layoutPersister = layoutPersister self.lastDataGridSettings = AppSettingsManager.shared.dataGrid super.init() updateCache() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 4db032382..31e6c3087 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -31,6 +31,7 @@ struct DataGridView: NSViewRepresentable { var sortedIDs: [RowID]? var displayFormats: [ValueDisplayFormat?] = [] var delegate: (any DataGridViewDelegate)? + var layoutPersister: (any ColumnLayoutPersisting)? @Binding var selectedRowIndices: Set @Binding var sortState: SortState @@ -228,12 +229,6 @@ struct DataGridView: NSViewRepresentable { coordinator.sortedIDs = sortedIDs coordinator.syncDisplayFormats(displayFormats) coordinator.delegate = delegate - let columnLayoutBinding = $columnLayout - coordinator.onColumnLayoutDidChange = { layout in - if columnLayoutBinding.wrappedValue != layout { - columnLayoutBinding.wrappedValue = layout - } - } delegate?.dataGridAttach(tableViewCoordinator: coordinator) coordinator.dropdownColumns = configuration.dropdownColumns coordinator.typePickerColumns = configuration.typePickerColumns @@ -430,11 +425,14 @@ struct DataGridView: NSViewRepresentable { coordinator.currentSortState = sortState + let primaryIdentifier: NSUserInterfaceItemIdentifier? let primary: NSSortDescriptor? if let firstSort = sortState.columns.first, let identifier = coordinator.identitySchema.identifier(for: firstSort.columnIndex) { + primaryIdentifier = identifier primary = NSSortDescriptor(key: identifier.rawValue, ascending: firstSort.direction == .ascending) } else { + primaryIdentifier = nil primary = nil } @@ -445,7 +443,12 @@ struct DataGridView: NSViewRepresentable { tableView.sortDescriptors = desired } - tableView.highlightedTableColumn = nil + if let primaryIdentifier { + let columnIndex = tableView.column(withIdentifier: primaryIdentifier) + tableView.highlightedTableColumn = columnIndex >= 0 ? tableView.tableColumns[columnIndex] : nil + } else { + tableView.highlightedTableColumn = nil + } if let header = tableView.headerView as? SortableHeaderView { header.updateSortIndicators(state: sortState, schema: coordinator.identitySchema) @@ -527,9 +530,6 @@ struct DataGridView: NSViewRepresentable { } } - // MARK: - Sort Indicator Helpers - - static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) { coordinator.overlayEditor?.dismiss(commit: false) coordinator.persistColumnLayoutToStorage() @@ -545,12 +545,20 @@ struct DataGridView: NSViewRepresentable { } func makeCoordinator() -> TableViewCoordinator { - TableViewCoordinator( + let coordinator = TableViewCoordinator( changeManager: changeManager, isEditable: isEditable, selectedRowIndices: $selectedRowIndices, - delegate: delegate + delegate: delegate, + layoutPersister: layoutPersister ?? FileColumnLayoutPersister() ) + let columnLayoutBinding = $columnLayout + coordinator.onColumnLayoutDidChange = { layout in + if columnLayoutBinding.wrappedValue != layout { + columnLayoutBinding.wrappedValue = layout + } + } + return coordinator } } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 23fd2bce9..519e393a9 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -49,6 +49,7 @@ struct TableStructureView: View { @State var selectedRows: Set = [] @State var sortState = SortState() @State var structureColumnLayout = ColumnLayoutState() + @State var columnLayoutPersister: any ColumnLayoutPersisting = FileColumnLayoutPersister() @State var actionHandler = StructureViewActionHandler() @State var gridDelegate: StructureGridDelegate @@ -245,7 +246,7 @@ struct TableStructureView: View { await loadColumns() loadSchemaForEditing() isReloadingAfterSave = false - FileColumnLayoutPersister.shared.clear(for: tableName, connectionId: connection.id) + columnLayoutPersister.clear(for: tableName, connectionId: connection.id) NotificationCenter.default.post(name: .refreshData, object: nil) } catch { AlertHelper.showErrorSheet( @@ -280,6 +281,7 @@ struct TableStructureView: View { databaseType: connection.type ), delegate: gridDelegate, + layoutPersister: columnLayoutPersister, selectedRowIndices: $selectedRows, sortState: $sortState, columnLayout: $structureColumnLayout diff --git a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift index 3d48f0332..48115d213 100644 --- a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift +++ b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift @@ -14,6 +14,13 @@ import SwiftUI import Testing @testable import TablePro +@MainActor +private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { + func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} + func clear(for tableName: String, connectionId: UUID) {} +} + @MainActor private final class StubClipboard: ClipboardProvider { var text: String? @@ -34,7 +41,8 @@ struct CellPasteRoutingTests { changeManager: AnyChangeManager(DataChangeManager()), isEditable: true, selectedRowIndices: .constant([]), - delegate: nil + delegate: nil, + layoutPersister: NoopColumnLayoutPersister() ) let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) let rows = (0.. Date: Wed, 29 Apr 2026 18:44:20 +0700 Subject: [PATCH 33/38] fix(datagrid): preserve native sort cycle on column header clicks --- TablePro/Views/Results/Extensions/DataGridView+Sort.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 7a193c33b..9a2302ac5 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -19,14 +19,6 @@ extension TableViewCoordinator { return } - if let oldDescriptor = oldDescriptors.first, - oldDescriptor.key == newDescriptor.key, - oldDescriptor.ascending == false, - newDescriptor.ascending == true { - delegate?.dataGridClearSort() - return - } - delegate?.dataGridSort(column: columnIndex, ascending: newDescriptor.ascending, isMultiSort: false) } From a337b90ae7a164e2a3897ff4b3a6658c0ea7b045 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 18:44:25 +0700 Subject: [PATCH 34/38] fix(a11y): announce sort direction on data grid column headers --- TablePro/Views/Results/SortableHeaderView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 5e458abec..719a67aa8 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -27,6 +27,11 @@ final class SortableHeaderView: NSTableHeaderView { guard let identifier = schema.identifier(for: sortCol.columnIndex) else { continue } let view = indicatorViews[identifier.rawValue] ?? makeIndicatorView() view.image = sortCol.direction == .ascending ? Self.ascendingImage : Self.descendingImage + view.setAccessibilityLabel( + sortCol.direction == .ascending + ? String(localized: "Sort ascending") + : String(localized: "Sort descending") + ) if view.superview == nil { addSubview(view) } From a67bcd46b745d8b74f36dd19edb3ad5a30f569b7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 18:44:30 +0700 Subject: [PATCH 35/38] refactor(datagrid): drop redundant doc comment on visibility helper --- .../Extensions/MainContentCoordinator+ColumnVisibility.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index eaa2049bd..4f62e52bf 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -22,7 +22,6 @@ extension MainContentCoordinator { columnVisibilityManager.restoreLastHiddenColumns(for: tableName, connectionId: connectionId) } - /// Save current hidden columns for the active table tab (visibility lives outside the layout persister). func saveColumnVisibilityForActiveTable() { guard let tab = tabManager.selectedTab, tab.tabType == .table, From ace27337c303f49b1c41753b1b91571c0ae783e9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 18:44:35 +0700 Subject: [PATCH 36/38] test(datagrid): cover sort transitions, persister corruption, and literal col_0 identifier --- TableProTests/Models/SortStateTests.swift | 58 +++++++++++++++++++ .../Models/UI/ColumnIdentitySchemaTests.swift | 24 ++++++++ .../FileColumnLayoutPersisterTests.swift | 36 ++++++++++++ 3 files changed, 118 insertions(+) diff --git a/TableProTests/Models/SortStateTests.swift b/TableProTests/Models/SortStateTests.swift index a0361f2d5..f43442c07 100644 --- a/TableProTests/Models/SortStateTests.swift +++ b/TableProTests/Models/SortStateTests.swift @@ -163,4 +163,62 @@ struct SortStateTests { b.columns = [SortColumn(columnIndex: 1, direction: .ascending)] #expect(a == b) } + + @Test("isSorting flips true after adding a column") + func isSortingFlipsTrue() { + var state = SortState() + #expect(state.isSorting == false) + state.columns = [SortColumn(columnIndex: 0, direction: .ascending)] + #expect(state.isSorting == true) + } + + @Test("isSorting flips false after clearing columns") + func isSortingFlipsFalse() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 0, direction: .ascending)] + state.columns = [] + #expect(state.isSorting == false) + } + + @Test("Single-column toggle from ascending to descending preserves the column") + func singleColumnToggleDirection() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 2, direction: .ascending)] + state.columns[0].direction.toggle() + #expect(state.columns.count == 1) + #expect(state.columns[0].columnIndex == 2) + #expect(state.columns[0].direction == .descending) + } + + @Test("Multi-column sort retains primary when adding a secondary") + func multiColumnAddSecondary() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 1, direction: .ascending)] + state.columns.append(SortColumn(columnIndex: 3, direction: .descending)) + + #expect(state.columns.count == 2) + #expect(state.columns[0].columnIndex == 1) + #expect(state.columns[0].direction == .ascending) + #expect(state.columns[1].columnIndex == 3) + #expect(state.columns[1].direction == .descending) + } + + @Test("Removing the only sort column clears the state") + func removeOnlySortColumn() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 0, direction: .descending)] + state.columns.removeAll() + #expect(state.isSorting == false) + #expect(state.columnIndex == nil) + } + + @Test("Descending toggled to ascending stays sorting and does not clear") + func descendingToAscendingStaysSorted() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 0, direction: .descending)] + state.columns[0].direction = .ascending + #expect(state.isSorting == true) + #expect(state.columns.count == 1) + #expect(state.columns[0].direction == .ascending) + } } diff --git a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift index ee43ab986..21fd5ee85 100644 --- a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift +++ b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift @@ -108,4 +108,28 @@ struct ColumnIdentitySchemaTests { #expect(before.dataIndex(from: NSUserInterfaceItemIdentifier("email")) == 2) #expect(after.dataIndex(from: NSUserInterfaceItemIdentifier("email")) == 1) } + + @Test("A column literally named col_0 stays name-based and resolves to its own index") + func literalColZeroColumnNameRoundTrips() { + let schema = ColumnIdentitySchema(columns: ["id", "name", "col_0"]) + #expect(schema.isNameBased == true) + + let identifier = schema.identifier(for: 2) + #expect(identifier?.rawValue == "col_0") + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("col_0")) == 2) + #expect(schema.dataIndex(from: NSUserInterfaceItemIdentifier("id")) == 0) + } + + @Test("A literal col_0 column survives reordering without colliding with positional ids") + func literalColZeroSurvivesReorder() { + let before = ColumnIdentitySchema(columns: ["id", "name", "col_0"]) + let after = ColumnIdentitySchema(columns: ["col_0", "id", "name"]) + + #expect(before.isNameBased == true) + #expect(after.isNameBased == true) + + let columnId = NSUserInterfaceItemIdentifier("col_0") + #expect(before.dataIndex(from: columnId) == 2) + #expect(after.dataIndex(from: columnId) == 0) + } } diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index 5f56056d3..9b8ff1a35 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -114,4 +114,40 @@ struct FileColumnLayoutPersisterTests { #expect(restored?.columnWidths == layout.columnWidths) #expect(restored?.columnOrder == layout.columnOrder) } + + @Test("Loading malformed JSON returns nil instead of crashing") + func malformedJSONRecovers() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let connectionId = UUID() + let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") + try Data("{not valid json".utf8).write(to: fileURL) + + let persister = FileColumnLayoutPersister(storageDirectory: directory) + #expect(persister.load(for: "users", connectionId: connectionId) == nil) + } + + @Test("Saving over a corrupted file replaces it cleanly") + func malformedJSONIsRecoverableBySave() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let connectionId = UUID() + let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") + try Data("garbage".utf8).write(to: fileURL) + + let persister = FileColumnLayoutPersister(storageDirectory: directory) + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 100] + persister.save(layout, for: "users", connectionId: connectionId) + + let restored = FileColumnLayoutPersister(storageDirectory: directory) + .load(for: "users", connectionId: connectionId) + #expect(restored?.columnWidths == ["id": 100]) + } } From 6acf667a96d1856f8359a979957bb7df3a8939b8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 18:44:39 +0700 Subject: [PATCH 37/38] docs: correct sort indicator implementation note in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbff4da83..7df62ea2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 use NSTableView's native `highlightedTableColumn` and `setIndicatorImage(_:in:)`, replacing Unicode arrows that were embedded in the column title string. Screen readers now announce the column name and sort direction separately. +- 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. From d6b212ae1a9fcde6ed6f75f9374c53f54b67f7a1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 29 Apr 2026 18:54:07 +0700 Subject: [PATCH 38/38] fix(datagrid): restore three-state sort cycle (asc, desc, none) --- .../Views/Results/Extensions/DataGridView+Sort.swift | 8 ++++++++ TableProTests/Models/SortStateTests.swift | 10 ---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 9a2302ac5..7a193c33b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -19,6 +19,14 @@ extension TableViewCoordinator { return } + if let oldDescriptor = oldDescriptors.first, + oldDescriptor.key == newDescriptor.key, + oldDescriptor.ascending == false, + newDescriptor.ascending == true { + delegate?.dataGridClearSort() + return + } + delegate?.dataGridSort(column: columnIndex, ascending: newDescriptor.ascending, isMultiSort: false) } diff --git a/TableProTests/Models/SortStateTests.swift b/TableProTests/Models/SortStateTests.swift index f43442c07..2bd37d7d1 100644 --- a/TableProTests/Models/SortStateTests.swift +++ b/TableProTests/Models/SortStateTests.swift @@ -211,14 +211,4 @@ struct SortStateTests { #expect(state.isSorting == false) #expect(state.columnIndex == nil) } - - @Test("Descending toggled to ascending stays sorting and does not clear") - func descendingToAscendingStaysSorted() { - var state = SortState() - state.columns = [SortColumn(columnIndex: 0, direction: .descending)] - state.columns[0].direction = .ascending - #expect(state.isSorting == true) - #expect(state.columns.count == 1) - #expect(state.columns[0].direction == .ascending) - } }