diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e89423c..437406649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - AI providers: Anthropic test connection uses the configured model, known model list updated through Claude 4.7, and Ollama detection now logs the actual error category instead of swallowing every failure as 'not running' - AI Chat views: replace custom pill buttons with native `.borderless` styles, switch hardcoded text colors to semantic system colors, use relative font sizing in Markdown rendering, align spacing to the 8-pt grid, and add accessibility labels to icon-only buttons - Translucent backgrounds (Welcome sidebar, settings banners, ER diagram toolbar, JSON editor controls, Pro feature scrim) honor the system Reduce Transparency and Increase Contrast accessibility settings, swapping the material for a solid surface color when either is on +- Internal: result-grid sortable header drops the custom resize cursor handling that duplicated AppKit's built-in column-edge resize, and consolidates three sort delegate methods into one that carries the full sort state. No user-facing change; multi-column sort, shift-click cycle, and the column resize cursor still work the same. ### Fixed diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index be372ac87..b61c609ab 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -15,9 +15,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { var selectionState: GridSelectionState? var onCellEdit: ((Int, Int, String?) -> Void)? - var onSort: ((Int, Bool, Bool) -> Void)? - var onClearSort: (() -> Void)? - var onRemoveSortColumn: ((Int) -> Void)? + var onSortStateChanged: ((SortState) -> Void)? var onAddRow: (() -> Void)? var onUndoInsert: ((Int) -> Void)? var onFilterColumn: ((String) -> Void)? @@ -29,16 +27,8 @@ final class DataTabGridDelegate: DataGridViewDelegate { onCellEdit?(row, column, newValue) } - func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) { - onSort?(column, ascending, isMultiSort) - } - - func dataGridClearSort() { - onClearSort?() - } - - func dataGridRemoveSortColumn(_ columnIndex: Int) { - onRemoveSortColumn?(columnIndex) + func dataGridSortStateChanged(_ state: SortState) { + onSortStateChanged?(state) } func dataGridAddRow() { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 35e366088..ce36a2c4d 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -36,9 +36,7 @@ struct MainEditorContentView: View { // MARK: - Callbacks let onCellEdit: (Int, Int, String?) -> Void - let onSort: (Int, Bool, Bool) -> Void - let onClearSort: () -> Void - let onRemoveSortColumn: (Int) -> Void + let onSortStateChanged: (SortState) -> Void let onAddRow: () -> Void let onUndoInsert: (Int) -> Void let onSelectionChange: (Set) -> Void @@ -182,9 +180,7 @@ struct MainEditorContentView: View { dataTabDelegate.coordinator = coordinator dataTabDelegate.selectionState = selectionState dataTabDelegate.onCellEdit = onCellEdit - dataTabDelegate.onSort = onSort - dataTabDelegate.onClearSort = onClearSort - dataTabDelegate.onRemoveSortColumn = onRemoveSortColumn + dataTabDelegate.onSortStateChanged = onSortStateChanged dataTabDelegate.onUndoInsert = onUndoInsert dataTabDelegate.onFilterColumn = onFilterColumn dataTabDelegate.onRefresh = onRefresh diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 75d5ee4fc..3da535dd2 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1154,46 +1154,25 @@ final class MainContentCoordinator { // MARK: - Sorting - func handleSort(columnIndex: Int, ascending: Bool, isMultiSort: Bool = false) { + func handleSortStateChanged(_ newState: SortState) { guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } + guard newState != tab.sortState else { return } let tableRows = tabSessionRegistry.tableRows(for: tab.id) - guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } - var currentSort = tab.sortState - let newDirection: SortDirection = ascending ? .ascending : .descending - - if isMultiSort { - // Multi-sort: toggle existing or append new column - if let existingIndex = currentSort.columns.firstIndex(where: { $0.columnIndex == columnIndex }) { - if currentSort.columns[existingIndex].direction == newDirection { - // Same direction clicked again — remove from sort - currentSort.columns.remove(at: existingIndex) - } else { - // Toggle direction - currentSort.columns[existingIndex].direction = newDirection - } - } else { - // Add new column to sort list - currentSort.columns.append(SortColumn(columnIndex: columnIndex, direction: newDirection)) - } - } else { - // Single sort: replace all with single column - currentSort = SortState() - currentSort.columns = [SortColumn(columnIndex: columnIndex, direction: newDirection)] - } if tab.tabType == .query { - // When more rows are available server-side, re-execute with ORDER BY - // instead of sorting locally (we only have a partial result set) - if tab.pagination.hasMoreRows { - let columnName = tableRows.columns[columnIndex] - let direction = currentSort.columns.first?.direction == .ascending ? "ASC" : "DESC" + if !newState.columns.isEmpty && tab.pagination.hasMoreRows { let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) - let quotedColumn = queryBuilder.quoteIdentifier(columnName) - let orderQuery = "\(strippedQuery) ORDER BY \(quotedColumn) \(direction)" + let orderClause = newState.columns.compactMap { sortCol -> String? in + guard sortCol.columnIndex >= 0, sortCol.columnIndex < tableRows.columns.count else { return nil } + let columnName = tableRows.columns[sortCol.columnIndex] + let direction = sortCol.direction == .ascending ? "ASC" : "DESC" + return "\(queryBuilder.quoteIdentifier(columnName)) \(direction)" + }.joined(separator: ", ") + let orderQuery = orderClause.isEmpty ? strippedQuery : "\(strippedQuery) ORDER BY \(orderClause)" tabManager.mutate(at: tabIndex) { tab in - tab.sortState = currentSort + tab.sortState = newState tab.hasUserInteraction = true tab.pagination.resetLoadMore() tab.content.query = orderQuery @@ -1202,14 +1181,24 @@ final class MainContentCoordinator { return } + if newState.columns.isEmpty { + tabManager.mutate(at: tabIndex) { tab in + tab.sortState = newState + tab.hasUserInteraction = true + } + querySortCache.removeValue(forKey: tab.id) + dataTabDelegate?.dataGridDidReplaceAllRows() + return + } + tabManager.mutate(at: tabIndex) { tab in - tab.sortState = currentSort + tab.sortState = newState tab.hasUserInteraction = true tab.pagination.reset() } let tabId = tab.id let schemaVersion = tab.schemaVersion - let sortColumns = currentSort.columns + let sortColumns = newState.columns let colTypes = tableRows.columnTypes let storageRows = tableRows.rows let snapshotRows: [(id: RowID, values: [String?])] = storageRows.map { ($0.id, Array($0.values)) } @@ -1232,9 +1221,8 @@ final class MainContentCoordinator { await MainActor.run { [weak self] in guard let self else { return } - // Guard against stale completion: verify tab still expects this sort guard let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }), - self.tabManager.tabs[idx].sortState == currentSort else { + self.tabManager.tabs[idx].sortState == newState else { return } self.querySortCache[tabId] = QuerySortCacheEntry( @@ -1261,16 +1249,21 @@ final class MainContentCoordinator { } let tabId = tab.id - let capturedSort = currentSort + let capturedSort = newState let capturedQuery = tab.content.query let capturedColumns = tableRows.columns confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in guard let self, confirmed else { return } - let newQuery = self.queryBuilder.buildMultiSortQuery( - baseQuery: capturedQuery, - sortState: capturedSort, - columns: capturedColumns - ) + let newQuery: String + if capturedSort.columns.isEmpty { + newQuery = Self.stripTrailingOrderBy(from: capturedQuery) + } else { + newQuery = self.queryBuilder.buildMultiSortQuery( + baseQuery: capturedQuery, + sortState: capturedSort, + columns: capturedColumns + ) + } guard self.tabManager.mutate(tabId: tabId, { tab in tab.sortState = capturedSort tab.hasUserInteraction = true @@ -1281,43 +1274,6 @@ final class MainContentCoordinator { } } - func removeMultiSortColumn(columnIndex: Int) { - guard let tab = tabManager.selectedTab else { return } - guard let existing = tab.sortState.columns.first(where: { $0.columnIndex == columnIndex }) else { return } - let ascending = existing.direction == .ascending - handleSort(columnIndex: columnIndex, ascending: ascending, isMultiSort: true) - } - - func clearSort() { - guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } - guard tab.sortState.isSorting else { return } - - let emptySort = SortState() - - if tab.tabType == .query { - tabManager.mutate(at: tabIndex) { tab in - tab.sortState = emptySort - tab.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 else { return } - guard self.tabManager.mutate(tabId: tabId, { tab in - tab.sortState = emptySort - tab.hasUserInteraction = true - tab.pagination.reset() - tab.content.query = Self.stripTrailingOrderBy(from: capturedQuery) - }) else { return } - 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 ed9b9c8d8..953f5e35d 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -356,16 +356,8 @@ struct MainContentView: View { rowIndex: rowIndex, columnIndex: colIndex, value: value) scheduleInspectorUpdate() }, - onSort: { columnIndex, ascending, isMultiSort in - coordinator.handleSort( - columnIndex: columnIndex, ascending: ascending, - isMultiSort: isMultiSort) - }, - onClearSort: { - coordinator.clearSort() - }, - onRemoveSortColumn: { columnIndex in - coordinator.removeMultiSortColumn(columnIndex: columnIndex) + onSortStateChanged: { newState in + coordinator.handleSortStateChanged(newState) }, onAddRow: { coordinator.addNewRow() diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 79d5ed0f3..a7f315c1b 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -18,9 +18,7 @@ protocol DataGridViewDelegate: AnyObject { func dataGridAddRow() func dataGridUndoInsert(at index: Int) func dataGridMoveRow(from source: Int, to destination: Int) - func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) - func dataGridRemoveSortColumn(_ columnIndex: Int) - func dataGridClearSort() + func dataGridSortStateChanged(_ state: SortState) func dataGridFilterColumn(_ columnName: String) func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) func dataGridDuplicateRow() @@ -47,9 +45,7 @@ extension DataGridViewDelegate { func dataGridAddRow() {} func dataGridUndoInsert(at index: Int) {} func dataGridMoveRow(from source: Int, to destination: Int) {} - func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) {} - func dataGridRemoveSortColumn(_ columnIndex: Int) {} - func dataGridClearSort() {} + func dataGridSortStateChanged(_ state: SortState) {} 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 b93e42e82..7c9745f2b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -159,12 +159,20 @@ extension TableViewCoordinator { @objc func sortAscending(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int else { return } - delegate?.dataGridSort(column: columnIndex, ascending: true, isMultiSort: false) + var state = SortState() + state.columns = [SortColumn(columnIndex: columnIndex, direction: .ascending)] + currentSortState = state + updateSortIndicatorsFromCurrentState() + delegate?.dataGridSortStateChanged(state) } @objc func sortDescending(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int else { return } - delegate?.dataGridSort(column: columnIndex, ascending: false, isMultiSort: false) + var state = SortState() + state.columns = [SortColumn(columnIndex: columnIndex, direction: .descending)] + currentSortState = state + updateSortIndicatorsFromCurrentState() + delegate?.dataGridSortStateChanged(state) } @objc func showAllColumns() { @@ -172,7 +180,14 @@ extension TableViewCoordinator { } @objc func clearSortAction() { - delegate?.dataGridClearSort() + currentSortState = SortState() + updateSortIndicatorsFromCurrentState() + delegate?.dataGridSortStateChanged(SortState()) + } + + private func updateSortIndicatorsFromCurrentState() { + guard let header = tableView?.headerView as? SortableHeaderView else { return } + header.updateSortIndicators(state: currentSortState, schema: identitySchema) } @objc func copyColumnName(_ sender: NSMenuItem) { diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index a4660966b..6dc14af83 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -5,14 +5,7 @@ import AppKit -enum HeaderSortAction: Equatable { - case sort(columnIndex: Int, ascending: Bool, isMultiSort: Bool) - case removeMultiSort(columnIndex: Int) - case clear -} - struct HeaderSortTransition: Equatable { - let action: HeaderSortAction let newState: SortState } @@ -32,10 +25,7 @@ enum HeaderSortCycle { guard let existingIndex = state.columns.firstIndex(where: { $0.columnIndex == clickedColumn }) else { var newState = state newState.columns.append(SortColumn(columnIndex: clickedColumn, direction: .ascending)) - return HeaderSortTransition( - action: .sort(columnIndex: clickedColumn, ascending: true, isMultiSort: true), - newState: newState - ) + return HeaderSortTransition(newState: newState) } let existing = state.columns[existingIndex] @@ -43,17 +33,11 @@ enum HeaderSortCycle { case .ascending: var newState = state newState.columns[existingIndex].direction = .descending - return HeaderSortTransition( - action: .sort(columnIndex: clickedColumn, ascending: false, isMultiSort: true), - newState: newState - ) + return HeaderSortTransition(newState: newState) case .descending: var newState = state newState.columns.remove(at: existingIndex) - return HeaderSortTransition( - action: .removeMultiSort(columnIndex: clickedColumn), - newState: newState - ) + return HeaderSortTransition(newState: newState) } } @@ -61,22 +45,16 @@ enum HeaderSortCycle { guard let primary = state.columns.first, primary.columnIndex == clickedColumn else { var newState = SortState() newState.columns = [SortColumn(columnIndex: clickedColumn, direction: .ascending)] - return HeaderSortTransition( - action: .sort(columnIndex: clickedColumn, ascending: true, isMultiSort: false), - newState: newState - ) + return HeaderSortTransition(newState: newState) } switch primary.direction { case .ascending: var newState = SortState() newState.columns = [SortColumn(columnIndex: clickedColumn, direction: .descending)] - return HeaderSortTransition( - action: .sort(columnIndex: clickedColumn, ascending: false, isMultiSort: false), - newState: newState - ) + return HeaderSortTransition(newState: newState) case .descending: - return HeaderSortTransition(action: .clear, newState: SortState()) + return HeaderSortTransition(newState: SortState()) } } } @@ -86,11 +64,9 @@ final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? private static let clickDragThreshold: CGFloat = 4 - private static let resizeZoneWidth: CGFloat = 4 private var pendingClickStartLocation: NSPoint? private var dragOccurredDuringClick = false - private var mouseMovedTrackingArea: NSTrackingArea? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -100,61 +76,6 @@ final class SortableHeaderView: NSTableHeaderView { super.init(coder: coder) } - override func resetCursorRects() { - super.resetCursorRects() - guard let tableView = tableView else { return } - let zoneWidth = Self.resizeZoneWidth - for (index, column) in tableView.tableColumns.enumerated() { - guard column.resizingMask.contains(.userResizingMask) else { continue } - let columnRect = headerRect(ofColumn: index) - let cursorRect = NSRect( - x: columnRect.maxX - zoneWidth, - y: columnRect.minY, - width: zoneWidth * 2, - height: columnRect.height - ) - addCursorRect(cursorRect, cursor: .resizeLeftRight) - } - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - window?.invalidateCursorRects(for: self) - } - - override func layout() { - super.layout() - window?.invalidateCursorRects(for: self) - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let existing = mouseMovedTrackingArea { - removeTrackingArea(existing) - } - let area = NSTrackingArea( - rect: bounds, - options: [.activeInKeyWindow, .mouseMoved, .inVisibleRect], - owner: self, - userInfo: nil - ) - addTrackingArea(area) - mouseMovedTrackingArea = area - } - - override func mouseMoved(with event: NSEvent) { - guard let tableView = tableView else { - super.mouseMoved(with: event) - return - } - let point = convert(event.locationInWindow, from: nil) - if isInResizeZone(point, in: tableView) { - NSCursor.resizeLeftRight.set() - } else { - NSCursor.arrow.set() - } - } - func updateSortIndicators(state: SortState, schema: ColumnIdentitySchema) { guard let tableView = tableView else { return } @@ -177,22 +98,6 @@ final class SortableHeaderView: NSTableHeaderView { } } - static func isInResizeZone( - point: NSPoint, - columnEdges: [CGFloat], - zoneWidth: CGFloat = SortableHeaderView.resizeZoneWidth - ) -> Bool { - columnEdges.contains { abs(point.x - $0) <= zoneWidth } - } - - private func isInResizeZone(_ point: NSPoint, in tableView: NSTableView) -> Bool { - let edges = tableView.tableColumns.enumerated().compactMap { index, column -> CGFloat? in - guard column.resizingMask.contains(.userResizingMask) else { return nil } - return headerRect(ofColumn: index).maxX - } - return Self.isInResizeZone(point: point, columnEdges: edges) - } - override func mouseDragged(with event: NSEvent) { if let start = pendingClickStartLocation { let current = convert(event.locationInWindow, from: nil) @@ -212,11 +117,6 @@ final class SortableHeaderView: NSTableHeaderView { } let pointInHeader = convert(event.locationInWindow, from: nil) - if isInResizeZone(pointInHeader, in: tableView) { - super.mouseDown(with: event) - return - } - let columnIndex = column(at: pointInHeader) guard columnIndex >= 0, columnIndex < tableView.numberOfColumns else { super.mouseDown(with: event) @@ -247,15 +147,6 @@ final class SortableHeaderView: NSTableHeaderView { return } - if let window { - let cursorInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) - let cursorInHeader = convert(cursorInWindow, from: nil) - if abs(cursorInHeader.x - pointInHeader.x) > Self.clickDragThreshold || - abs(cursorInHeader.y - pointInHeader.y) > Self.clickDragThreshold { - return - } - } - let isMultiSort = event.modifierFlags .intersection(.deviceIndependentFlagsMask) .contains(.shift) @@ -267,21 +158,6 @@ final class SortableHeaderView: NSTableHeaderView { coordinator.currentSortState = transition.newState updateSortIndicators(state: transition.newState, schema: coordinator.identitySchema) - dispatch(transition: transition, on: coordinator) - } - - private func dispatch(transition: HeaderSortTransition, on coordinator: TableViewCoordinator) { - switch transition.action { - case .sort(let columnIndex, let ascending, let isMultiSort): - coordinator.delegate?.dataGridSort( - column: columnIndex, - ascending: ascending, - isMultiSort: isMultiSort - ) - case .removeMultiSort(let columnIndex): - coordinator.delegate?.dataGridRemoveSortColumn(columnIndex) - case .clear: - coordinator.delegate?.dataGridClearSort() - } + coordinator.delegate?.dataGridSortStateChanged(transition.newState) } } diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index 0e5e20a44..cdbdce984 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -297,8 +297,12 @@ final class StructureGridDelegate: DataGridViewDelegate { } } - func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) { - sortHandler?(column, ascending) + func dataGridSortStateChanged(_ state: SortState) { + guard let primary = state.columns.first else { + sortHandler?(-1, true) + return + } + sortHandler?(primary.columnIndex, primary.direction == .ascending) } func dataGridMoveRow(from source: Int, to destination: Int) { diff --git a/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift b/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift index b216b132a..17ded44c0 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorSortTests.swift @@ -8,7 +8,7 @@ import Testing @testable import TablePro -@Suite("MainContentCoordinator handleSort") +@Suite("MainContentCoordinator handleSortStateChanged") @MainActor struct MainContentCoordinatorSortTests { private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager, UUID) { @@ -38,14 +38,18 @@ struct MainContentCoordinatorSortTests { coordinator.setActiveTableRows(tableRows, for: tabId) } - // MARK: - Single column sort + private func sortState(_ columns: [(Int, SortDirection)]) -> SortState { + var state = SortState() + state.columns = columns.map { SortColumn(columnIndex: $0.0, direction: $0.1) } + return state + } - @Test("Single sort writes ascending state on a fresh tab") - func singleSortWritesAscendingState() { + @Test("Applying a single-column ascending state writes it to the tab") + func appliesSingleColumnAscending() { let (coordinator, tabManager, tabId) = makeCoordinator() seedRows(coordinator, for: tabId) - coordinator.handleSort(columnIndex: 1, ascending: true, isMultiSort: false) + coordinator.handleSortStateChanged(sortState([(1, .ascending)])) guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { Issue.record("Expected tab to exist") @@ -57,96 +61,32 @@ struct MainContentCoordinatorSortTests { #expect(tabManager.tabs[idx].hasUserInteraction == true) } - @Test("Single sort flips ascending to descending on the same column") - func singleSortFlipsToDescending() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: 1, ascending: true, isMultiSort: false) - coordinator.handleSort(columnIndex: 1, ascending: false, isMultiSort: false) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns == [ - SortColumn(columnIndex: 1, direction: .descending) - ]) - } - - @Test("Single sort on a different column replaces the existing sort") - func singleSortReplacesAcrossColumns() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) - coordinator.handleSort(columnIndex: 2, ascending: true, isMultiSort: false) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns == [ - SortColumn(columnIndex: 2, direction: .ascending) - ]) - } - - @Test("Out-of-range column index is rejected and state stays unchanged") - func outOfRangeColumnIndexIsIgnored() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: 99, ascending: true, isMultiSort: false) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns.isEmpty) - } - - @Test("Negative column index is rejected and state stays unchanged") - func negativeColumnIndexIsIgnored() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: -1, ascending: true, isMultiSort: false) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns.isEmpty) - } - - // MARK: - Multi column sort - - @Test("Multi-sort appends a new column to the existing sort") - func multiSortAppendsNewColumn() { + @Test("Applying a different state replaces the previous one") + func replacesPreviousState() { let (coordinator, tabManager, tabId) = makeCoordinator() seedRows(coordinator, for: tabId) - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) - coordinator.handleSort(columnIndex: 2, ascending: true, isMultiSort: true) + coordinator.handleSortStateChanged(sortState([(0, .ascending)])) + coordinator.handleSortStateChanged(sortState([(2, .descending)])) guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { Issue.record("Expected tab to exist") return } #expect(tabManager.tabs[idx].sortState.columns == [ - SortColumn(columnIndex: 0, direction: .ascending), - SortColumn(columnIndex: 2, direction: .ascending) + SortColumn(columnIndex: 2, direction: .descending) ]) } - @Test("Multi-sort toggles direction on an existing secondary column") - func multiSortTogglesSecondaryDirection() { + @Test("Applying a multi-column state writes all columns in order") + func appliesMultiColumnState() { let (coordinator, tabManager, tabId) = makeCoordinator() seedRows(coordinator, for: tabId) - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) - coordinator.handleSort(columnIndex: 2, ascending: true, isMultiSort: true) - coordinator.handleSort(columnIndex: 2, ascending: false, isMultiSort: true) + coordinator.handleSortStateChanged(sortState([ + (0, .ascending), + (2, .descending) + ])) guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { Issue.record("Expected tab to exist") @@ -158,85 +98,12 @@ struct MainContentCoordinatorSortTests { ]) } - @Test("Multi-sort with same direction on existing column removes that column") - func multiSortSameDirectionRemovesColumn() { + @Test("Applying an empty state clears the sort and removes the cache entry") + func emptyStateClearsSortAndCache() { let (coordinator, tabManager, tabId) = makeCoordinator() seedRows(coordinator, for: tabId) - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) - coordinator.handleSort(columnIndex: 2, ascending: false, isMultiSort: true) - coordinator.handleSort(columnIndex: 2, ascending: false, isMultiSort: true) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns == [ - SortColumn(columnIndex: 0, direction: .ascending) - ]) - } - - @Test("Multi-sort preserves the primary column when adding a secondary") - func multiSortKeepsPrimaryColumn() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: 0, ascending: false, isMultiSort: false) - coordinator.handleSort(columnIndex: 1, ascending: true, isMultiSort: true) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns.first == SortColumn(columnIndex: 0, direction: .descending)) - #expect(tabManager.tabs[idx].sortState.columns.count == 2) - } - - @Test("removeMultiSortColumn drops the targeted column from the sort list") - func removeMultiSortColumnDropsColumn() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) - coordinator.handleSort(columnIndex: 1, ascending: false, isMultiSort: true) - - coordinator.removeMultiSortColumn(columnIndex: 1) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns == [ - SortColumn(columnIndex: 0, direction: .ascending) - ]) - } - - @Test("removeMultiSortColumn is a no-op when the column is not in the sort") - func removeMultiSortColumnNoOpForUnsortedColumn() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) - - coordinator.removeMultiSortColumn(columnIndex: 1) - - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("Expected tab to exist") - return - } - #expect(tabManager.tabs[idx].sortState.columns == [ - SortColumn(columnIndex: 0, direction: .ascending) - ]) - } - - // MARK: - Cache invariants - - @Test("clearSort on a query tab removes the cache entry for that tab") - func clearSortRemovesCacheEntry() { - let (coordinator, tabManager, tabId) = makeCoordinator() - seedRows(coordinator, for: tabId) - - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) + coordinator.handleSortStateChanged(sortState([(0, .ascending)])) coordinator.querySortCache[tabId] = QuerySortCacheEntry( sortedIDs: [.existing(0), .existing(1), .existing(2)], columnIndex: 0, @@ -244,7 +111,7 @@ struct MainContentCoordinatorSortTests { schemaVersion: 0 ) - coordinator.clearSort() + coordinator.handleSortStateChanged(SortState()) #expect(coordinator.querySortCache[tabId] == nil) guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { @@ -254,18 +121,22 @@ struct MainContentCoordinatorSortTests { #expect(tabManager.tabs[idx].sortState.columns.isEmpty) } - @Test("clearSort on an unsorted tab does not crash and leaves sort state empty") - func clearSortIsNoOpWhenUnsorted() { + @Test("Applying the same state twice is a no-op") + func sameStateIsNoOp() { let (coordinator, tabManager, tabId) = makeCoordinator() seedRows(coordinator, for: tabId) + let state = sortState([(0, .ascending)]) - coordinator.clearSort() - + coordinator.handleSortStateChanged(state) guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { Issue.record("Expected tab to exist") return } - #expect(tabManager.tabs[idx].sortState.columns.isEmpty) + let firstInteractionTimestamp = tabManager.tabs[idx].hasUserInteraction + coordinator.handleSortStateChanged(state) + + #expect(tabManager.tabs[idx].sortState.columns == state.columns) + #expect(tabManager.tabs[idx].hasUserInteraction == firstInteractionTimestamp) } @Test("cleanupSortCache drops entries for tabs that are no longer open") @@ -291,8 +162,6 @@ struct MainContentCoordinatorSortTests { #expect(coordinator.querySortCache[strayTabId] == nil) } - // MARK: - Pagination reset - @Test("Sort resets pagination on the active tab") func sortResetsPagination() { let (coordinator, tabManager, tabId) = makeCoordinator() @@ -305,7 +174,7 @@ struct MainContentCoordinatorSortTests { tabManager.tabs[idx].pagination.currentPage = 5 tabManager.tabs[idx].pagination.currentOffset = 4_000 - coordinator.handleSort(columnIndex: 0, ascending: true, isMultiSort: false) + coordinator.handleSortStateChanged(sortState([(0, .ascending)])) #expect(tabManager.tabs[idx].pagination.currentPage == 1) #expect(tabManager.tabs[idx].pagination.currentOffset == 0) diff --git a/TableProTests/Views/Results/HeaderSortCycleTests.swift b/TableProTests/Views/Results/HeaderSortCycleTests.swift index 9b4653a01..1be837fbb 100644 --- a/TableProTests/Views/Results/HeaderSortCycleTests.swift +++ b/TableProTests/Views/Results/HeaderSortCycleTests.swift @@ -16,7 +16,6 @@ struct HeaderSortCycleSingleColumnTests { clickedColumn: 2, isMultiSort: false ) - #expect(transition.action == .sort(columnIndex: 2, ascending: true, isMultiSort: false)) #expect(transition.newState.columns == [SortColumn(columnIndex: 2, direction: .ascending)]) } @@ -29,7 +28,6 @@ struct HeaderSortCycleSingleColumnTests { clickedColumn: 2, isMultiSort: false ) - #expect(transition.action == .sort(columnIndex: 2, ascending: false, isMultiSort: false)) #expect(transition.newState.columns == [SortColumn(columnIndex: 2, direction: .descending)]) } @@ -42,7 +40,6 @@ struct HeaderSortCycleSingleColumnTests { clickedColumn: 2, isMultiSort: false ) - #expect(transition.action == .clear) #expect(transition.newState.columns.isEmpty) } @@ -55,7 +52,6 @@ struct HeaderSortCycleSingleColumnTests { clickedColumn: 4, isMultiSort: false ) - #expect(transition.action == .sort(columnIndex: 4, ascending: true, isMultiSort: false)) #expect(transition.newState.columns == [SortColumn(columnIndex: 4, direction: .ascending)]) } @@ -71,7 +67,6 @@ struct HeaderSortCycleSingleColumnTests { clickedColumn: 1, isMultiSort: false ) - #expect(transition.action == .sort(columnIndex: 1, ascending: false, isMultiSort: false)) #expect(transition.newState.columns == [SortColumn(columnIndex: 1, direction: .descending)]) } @@ -87,7 +82,6 @@ struct HeaderSortCycleSingleColumnTests { clickedColumn: 3, isMultiSort: false ) - #expect(transition.action == .sort(columnIndex: 3, ascending: true, isMultiSort: false)) #expect(transition.newState.columns == [SortColumn(columnIndex: 3, direction: .ascending)]) } } @@ -103,7 +97,6 @@ struct HeaderSortCycleMultiColumnTests { clickedColumn: 3, isMultiSort: true ) - #expect(transition.action == .sort(columnIndex: 3, ascending: true, isMultiSort: true)) #expect(transition.newState.columns == [ SortColumn(columnIndex: 1, direction: .ascending), SortColumn(columnIndex: 3, direction: .ascending) @@ -122,7 +115,6 @@ struct HeaderSortCycleMultiColumnTests { clickedColumn: 3, isMultiSort: true ) - #expect(transition.action == .sort(columnIndex: 3, ascending: false, isMultiSort: true)) #expect(transition.newState.columns == [ SortColumn(columnIndex: 1, direction: .ascending), SortColumn(columnIndex: 3, direction: .descending) @@ -141,7 +133,6 @@ struct HeaderSortCycleMultiColumnTests { clickedColumn: 3, isMultiSort: true ) - #expect(transition.action == .removeMultiSort(columnIndex: 3)) #expect(transition.newState.columns == [SortColumn(columnIndex: 1, direction: .ascending)]) } @@ -152,7 +143,6 @@ struct HeaderSortCycleMultiColumnTests { clickedColumn: 0, isMultiSort: true ) - #expect(transition.action == .sort(columnIndex: 0, ascending: true, isMultiSort: true)) #expect(transition.newState.columns == [SortColumn(columnIndex: 0, direction: .ascending)]) } @@ -162,7 +152,6 @@ struct HeaderSortCycleMultiColumnTests { state.columns = [SortColumn(columnIndex: 1, direction: .ascending)] let added = HeaderSortCycle.nextTransition(state: state, clickedColumn: 5, isMultiSort: true) - #expect(added.action == .sort(columnIndex: 5, ascending: true, isMultiSort: true)) #expect(added.newState.columns == [ SortColumn(columnIndex: 1, direction: .ascending), SortColumn(columnIndex: 5, direction: .ascending) @@ -171,7 +160,6 @@ struct HeaderSortCycleMultiColumnTests { let toggled = HeaderSortCycle.nextTransition( state: added.newState, clickedColumn: 5, isMultiSort: true ) - #expect(toggled.action == .sort(columnIndex: 5, ascending: false, isMultiSort: true)) #expect(toggled.newState.columns == [ SortColumn(columnIndex: 1, direction: .ascending), SortColumn(columnIndex: 5, direction: .descending) @@ -180,7 +168,6 @@ struct HeaderSortCycleMultiColumnTests { let removed = HeaderSortCycle.nextTransition( state: toggled.newState, clickedColumn: 5, isMultiSort: true ) - #expect(removed.action == .removeMultiSort(columnIndex: 5)) #expect(removed.newState.columns == [SortColumn(columnIndex: 1, direction: .ascending)]) } } diff --git a/TableProTests/Views/Results/SortableHeaderResizeZoneTests.swift b/TableProTests/Views/Results/SortableHeaderResizeZoneTests.swift deleted file mode 100644 index e4452c2b1..000000000 --- a/TableProTests/Views/Results/SortableHeaderResizeZoneTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// SortableHeaderResizeZoneTests.swift -// TableProTests -// - -import AppKit -@testable import TablePro -import Testing - -@MainActor -@Suite("SortableHeaderView resize zone detection") -struct SortableHeaderResizeZoneTests { - @Test("Point on column edge is inside resize zone") - func pointOnEdgeIsInside() { - let edges: [CGFloat] = [60, 220, 380] - #expect(SortableHeaderView.isInResizeZone(point: NSPoint(x: 60, y: 8), columnEdges: edges)) - #expect(SortableHeaderView.isInResizeZone(point: NSPoint(x: 220, y: 8), columnEdges: edges)) - } - - @Test("Point within zoneWidth of an edge is inside resize zone") - func pointNearEdgeIsInside() { - let edges: [CGFloat] = [100] - #expect(SortableHeaderView.isInResizeZone(point: NSPoint(x: 96, y: 8), columnEdges: edges)) - #expect(SortableHeaderView.isInResizeZone(point: NSPoint(x: 104, y: 8), columnEdges: edges)) - } - - @Test("Point outside zoneWidth of every edge is not in resize zone") - func pointFarFromEdgeIsOutside() { - let edges: [CGFloat] = [100, 200] - #expect(!SortableHeaderView.isInResizeZone(point: NSPoint(x: 50, y: 8), columnEdges: edges)) - #expect(!SortableHeaderView.isInResizeZone(point: NSPoint(x: 150, y: 8), columnEdges: edges)) - #expect(!SortableHeaderView.isInResizeZone(point: NSPoint(x: 250, y: 8), columnEdges: edges)) - } - - @Test("Empty edge list never matches") - func emptyEdgesNeverMatches() { - #expect(!SortableHeaderView.isInResizeZone(point: NSPoint(x: 0, y: 0), columnEdges: [])) - #expect(!SortableHeaderView.isInResizeZone(point: NSPoint(x: 100, y: 0), columnEdges: [])) - } - - @Test("Custom zone width widens the match band") - func customZoneWidthWidensBand() { - let edges: [CGFloat] = [100] - #expect(!SortableHeaderView.isInResizeZone( - point: NSPoint(x: 92, y: 0), - columnEdges: edges - )) - #expect(SortableHeaderView.isInResizeZone( - point: NSPoint(x: 92, y: 0), - columnEdges: edges, - zoneWidth: 8 - )) - } -}