Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2ccd677
refactor(datagrid): collapse cell hierarchy to single DataGridCellView
datlechin May 8, 2026
e64c2c9
refactor(datagrid): bound display cache via NSCache and add O(1) RowI…
datlechin May 8, 2026
9803534
refactor(datagrid): incremental row visual state via RowVisualIndex
datlechin May 8, 2026
522bb48
refactor(datagrid): off-main JSON parse and cancellable Task.sleep co…
datlechin May 8, 2026
8fa7779
fix(datagrid): use NSButton for cell accessory clicks and brighten ch…
datlechin May 8, 2026
90a93dd
refactor(datagrid): add typeSelect, animated undo insert, defensive r…
datlechin May 8, 2026
f505edc
fix(datagrid): row tint refresh on mark-delete and focus-follow on pr…
datlechin May 8, 2026
fee0430
fix(datagrid): force focus overlay refresh on every selection change
datlechin May 8, 2026
36cecab
fix(datagrid): keep focus overlay on top via zPosition and defer key-…
datlechin May 8, 2026
1f523fc
fix(datagrid): defer focus overlay refresh through every reload path
datlechin May 8, 2026
e774ec7
refactor(datagrid): cell-owned focus border replaces FocusOverlayView…
datlechin May 8, 2026
ed2df66
chore(datagrid): delete dead code surfaced by audit
datlechin May 8, 2026
2d005e5
refactor(datagrid): snapshot theme palette per render pass and weak-c…
datlechin May 8, 2026
e8b5d85
refactor(quickswitcher): replace .sheet with NSPanel for Spotlight pa…
datlechin May 8, 2026
6628ab0
fix(switcher): ESC clears search if non-empty otherwise bubbles to di…
datlechin May 8, 2026
60bddee
chore(datagrid): inline single-caller TypePicker and drop TableViewCo…
datlechin May 8, 2026
8964a55
refactor(window): replace custom restoration with NSWindowRestoration…
datlechin May 8, 2026
0fb724d
refactor(hig): honor Reduce Transparency and Increase Contrast for ma…
datlechin May 8, 2026
1f231d1
refactor(datagrid): drop redundant resize cursor handling and consoli…
datlechin May 8, 2026
868a1ff
Merge branch 'main' into refactor/datagrid-stage-9b-revised
datlechin May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 3 additions & 13 deletions TablePro/Views/Main/Child/DataTabGridDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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() {
Expand Down
8 changes: 2 additions & 6 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>) -> Void
Expand Down Expand Up @@ -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
Expand Down
114 changes: 35 additions & 79 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)) }
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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?])],
Expand Down
12 changes: 2 additions & 10 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 2 additions & 6 deletions TablePro/Views/Results/DataGridViewDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() {}
Expand Down
21 changes: 18 additions & 3 deletions TablePro/Views/Results/Extensions/DataGridView+Sort.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,35 @@ 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() {
delegate?.dataGridShowAllColumns()
}

@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) {
Expand Down
Loading
Loading