diff --git a/CHANGELOG.md b/CHANGELOG.md index 9699edbd4..7df62ea2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Data grid column identifiers are now the column name (with positional fallback for duplicate names), so saved widths follow the column across schema changes that shift its position. Identifier resolution moved from static `DataGridView` helpers to a `ColumnIdentitySchema` value type owned by the coordinator. +- `ColumnLayoutStorage` singleton replaced by a `ColumnLayoutPersisting` protocol with an injectable `FileColumnLayoutPersister` default. The coordinator depends on the protocol, not the concrete class, so tests can substitute a fake. +- Column layout save/restore on table-switch (`saveColumnLayoutForTable` / `restoreColumnLayoutForTable`) folded into the data grid coordinator's lifecycle (load on column build, persist on resize/move/dismantle). The standalone `MainContentCoordinator+ColumnLayout` extension is gone; only the visibility orchestration remains. Removes the redundant `hasUserResizedColumns` flag and the external save trigger from the binding setter. +- Data grid header sort indicators are `NSImageView` overlays drawn on a custom `NSTableHeaderView`, replacing Unicode arrows that were embedded in the column title string. The primary sorted column also gets the system header tint via `highlightedTableColumn`. VoiceOver announces the column name and sort direction separately. +- Data grid column layout persistence routes through a coordinator callback fired from outside SwiftUI's update cycle, removing the `Task`-based `@Binding` mutation inside `updateNSView` and the `isWritingColumnLayout` re-entry guard. +- Data grid cell reuse resets foreign-key arrow and dropdown chevron button context (target, action, row, column) when the button hides, preventing a stale handler from firing the wrong row if the column toggles between FK-eligible and not. +- `applyColumnOrder` scans only the unsettled tail of the column array per move, halving the constant cost on reorders with many columns. - Replaced RowBuffer / InMemoryRowProvider / RowDataStore with TableRows / TableRowsStore / TableRowsController. Mutations emit Delta events; the controller drives NSTableView via insertRows / removeRows / reloadData(forRowIndexes:). Sort and the display cache moved off the row provider into the data grid coordinator, keyed by Row.id. - Routed every TableRows mutation through `mutateActiveTableRows` on MainContentCoordinator so the active ResultSet's snapshot stays in sync with the store. The snapshot now refreshes only when the user switches result sets (saving the outgoing tab, loading the incoming one), so each insert / undo / paste no longer triggers an `@Observable` re-render of the whole editor. Fixes empty cells on Load More and CPU spikes when adding or undoing rows. - Undo of a cell edit clears the modified-cell highlight: `DataChangeManager.applyDataUndo` now bumps `reloadVersion` so the data grid rebuilds its visual state cache. diff --git a/TablePro/Core/Storage/ColumnLayoutStorage.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift similarity index 94% rename from TablePro/Core/Storage/ColumnLayoutStorage.swift rename to TablePro/Core/Storage/ColumnLayoutPersister.swift index a0c3c6ab0..af63e3460 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,8 @@ import Foundation import os @MainActor -internal final class ColumnLayoutStorage { - static let shared = ColumnLayoutStorage() - - private static let logger = Logger(subsystem: "com.TablePro", category: "ColumnLayoutStorage") +final class FileColumnLayoutPersister: ColumnLayoutPersisting { + private static let logger = Logger(subsystem: "com.TablePro", category: "ColumnLayoutPersister") private static let legacyKeyPrefix = "com.TablePro.columns.layout." private static let migrationCompleteKey = "com.TablePro.columnLayoutMigrationComplete" @@ -25,19 +23,19 @@ internal final class ColumnLayoutStorage { private var cache: [UUID: [String: PersistedColumnLayout]] = [:] - private init() { - storageDirectory = Self.resolvedStorageDirectory() + init(storageDirectory: URL? = nil) { + self.storageDirectory = storageDirectory ?? Self.resolvedStorageDirectory() do { try FileManager.default.createDirectory( - at: storageDirectory, + at: self.storageDirectory, withIntermediateDirectories: true ) } catch { Self.logger.error("Failed to create storage directory: \(error.localizedDescription)") } - Self.performMigrationIfNeeded(storageDirectory: storageDirectory) + Self.performMigrationIfNeeded(storageDirectory: self.storageDirectory) } func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { 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/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/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/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/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" : { 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 20bed91e6..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 @@ -663,7 +665,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..4f62e52bf 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -22,6 +22,14 @@ extension MainContentCoordinator { columnVisibilityManager.restoreLastHiddenColumns(for: tableName, connectionId: connectionId) } + func saveColumnVisibilityForActiveTable() { + guard let tab = tabManager.selectedTab, + tab.tabType == .table, + let tableName = tab.tableContext.tableName, + !tableName.isEmpty else { return } + columnVisibilityManager.saveLastHiddenColumns(for: tableName, connectionId: connectionId) + } + /// Prune hidden columns that no longer exist in the current result set func pruneHiddenColumns(currentColumns: [String]) { columnVisibilityManager.pruneStaleColumns(currentColumns) 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/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/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index c3c2bcc99..f9c3f2cc5 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -9,14 +9,12 @@ import AppKit 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 +182,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..5862bdfee 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -25,28 +25,63 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var primaryKeyColumns: [String] = [] var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? + var layoutPersister: any ColumnLayoutPersisting + var onColumnLayoutDidChange: ((ColumnLayoutState) -> Void)? + private(set) var identitySchema: ColumnIdentitySchema = .empty + var currentSortState = SortState() - func persistColumnLayoutToStorage() { - guard tabType == .table else { return } - guard let tableView, let connectionId, let tableName, !tableName.isEmpty 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 - ColumnLayoutStorage.shared.save(layout, for: tableName, connectionId: connectionId) + return layout + } + + func persistColumnLayoutToStorage() { + guard let layout = captureColumnLayout() else { return } + onColumnLayoutDidChange?(layout) + + if tabType == .table, let connectionId, let tableName, !tableName.isEmpty { + layoutPersister.save(layout, for: tableName, connectionId: connectionId) + } } weak var tableView: NSTableView? @@ -67,8 +102,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isSyncingSortDescriptors: Bool = false var isSyncingSelection = false var isRebuildingColumns: Bool = false - var hasUserResizedColumns: Bool = false - var isWritingColumnLayout: Bool = false var isEscapeCancelling = false var isCommittingCellEdit = false var layoutPersistTask: Task? @@ -87,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() @@ -430,8 +465,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 @@ -473,6 +508,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 6de54f500..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 @@ -66,7 +67,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,11 +79,17 @@ 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)) - column.title = columnName + guard let identifier = identitySchema.identifier(for: index) else { continue } + let column = NSTableColumn(identifier: identifier) + 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))" @@ -99,36 +106,29 @@ 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() - 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 { @@ -153,7 +153,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) } @@ -169,7 +168,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 @@ -243,8 +242,8 @@ struct DataGridView: NSViewRepresentable { coordinator.rebuildVisualStateCache() let currentDataColumns = tableView.tableColumns.dropFirst() - let currentColumnIds = currentDataColumns.map { $0.identifier.rawValue } - let expectedColumnIds = latestRows.columns.indices.map { Self.columnIdentifier(for: $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 @@ -259,7 +258,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) @@ -284,131 +283,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 { - 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 - } - } + 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) + } - 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 + 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) + 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 + 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 + ) } } @@ -416,21 +423,36 @@ struct DataGridView: NSViewRepresentable { coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } - if !sortState.isSorting { - if !tableView.sortDescriptors.isEmpty { - 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 ascending = firstSort.direction == .ascending - let currentDescriptor = tableView.sortDescriptors.first - if currentDescriptor?.key != key || currentDescriptor?.ascending != ascending { - tableView.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)] - } + 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 + } + + 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 } - Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: columns) + 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) + } } private func reloadAndSyncSelection( @@ -453,9 +475,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) @@ -467,10 +490,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 } @@ -479,54 +498,35 @@ 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 } - let dataColumns = tableView.tableColumns.filter { $0.identifier.rawValue != "__rowNumber__" } - - var columnMap: [String: NSTableColumn] = [:] - for col in dataColumns { - if let idx = dataColumnIndex(from: col.identifier), idx < columns.count { - columnMap[columns[idx]] = col + var columnByName: [String: NSTableColumn] = [:] + 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 } } - 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) - } - } - } - - // MARK: - Sort Indicator Helpers - - private static func updateSortIndicators(tableView: NSTableView, sortState: SortState, columns: [String]) { - for column in tableView.tableColumns { - guard let colIndex = dataColumnIndex(from: column.identifier), - colIndex < columns.count else { continue } - - let baseName = columns[colIndex] + for (targetDataIndex, columnName) in order.enumerated() { + guard let desired = columnByName[columnName] else { continue } + let targetTableIndex = tableColumnIndex(for: targetDataIndex) + guard targetTableIndex < tableView.numberOfColumns else { continue } - 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 + let current = tableView.tableColumns + var currentIndex = -1 + for i in targetTableIndex..= 0, currentIndex != targetTableIndex else { continue } + tableView.moveColumn(currentIndex, toColumn: targetTableIndex) } } @@ -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/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+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index b9b4b99b1..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 { @@ -97,5 +96,4 @@ extension TableViewCoordinator { rowView.rowIndex = row return rowView } - } 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 4198d4758..9392f8601 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -8,19 +8,16 @@ 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() } - private func scheduleLayoutPersist() { + func scheduleLayoutPersist() { layoutPersistTask?.cancel() layoutPersistTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .milliseconds(500)) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index c9026d896..7a193c33b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -12,25 +12,32 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { guard !isSyncingSortDescriptors else { return } - guard let sortDescriptor = tableView.sortDescriptors.first, - let key = sortDescriptor.key, - let columnIndex = DataGridView.dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), - columnIndex >= 0 && columnIndex < tableRowsProvider().columns.count else { + 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 = 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 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 +47,6 @@ extension TableViewCoordinator { columnIndex: dataColumnIndex, tableRows: tableRows ) - hasUserResizedColumns = true return width } @@ -60,18 +66,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(_:)), @@ -90,6 +96,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()) } @@ -103,7 +119,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 +169,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), @@ -178,6 +194,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) @@ -199,7 +219,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 +228,6 @@ extension TableViewCoordinator { tableRows: tableRows ) column.width = width - hasUserResizedColumns = true } @objc func sizeAllColumnsToFit(_ sender: NSMenuItem) { @@ -216,8 +235,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 +245,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/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift new file mode 100644 index 000000000..719a67aa8 --- /dev/null +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -0,0 +1,108 @@ +// +// SortableHeaderView.swift +// TablePro +// + +import AppKit + +@MainActor +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 + view.setAccessibilityLabel( + sortCol.direction == .ascending + ? String(localized: "Sort ascending") + : String(localized: "Sort descending") + ) + 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), + 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 existing = coordinator.currentSortState.columns.first(where: { $0.columnIndex == dataIndex }) + let ascending = existing == nil + coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) + } +} 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 + ) {} +} diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 7705b7ce8..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 - ColumnLayoutStorage.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/Models/SortStateTests.swift b/TableProTests/Models/SortStateTests.swift index 37eead196..2bd37d7d1 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") @@ -168,4 +163,52 @@ 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) + } } diff --git a/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift new file mode 100644 index 000000000..21fd5ee85 --- /dev/null +++ b/TableProTests/Models/UI/ColumnIdentitySchemaTests.swift @@ -0,0 +1,135 @@ +// +// 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) + } + + @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) + } + + @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 new file mode 100644 index 000000000..9b8ff1a35 --- /dev/null +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -0,0 +1,153 @@ +// +// 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) + } + + @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]) + } +} 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.. 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, + layoutPersister: persister + ) + coordinator.tabType = tabType + coordinator.connectionId = connectionId + coordinator.tableName = tableName + 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]) + } +}