Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- PostgreSQL: Schema name lost after app restart, causing "relation does not exist" errors for non-public schemas
- Error dialog OK button not dismissing when a SwiftUI sheet is active, making the app unusable
- Shift+Click on column header not triggering multi-column sort (NSTableView swallows Shift+Click on headers)
- SQL Server: Unicode characters (Thai, CJK, etc.) in nvarchar/nchar/ntext columns displaying as question marks
- Globe+F (fn+F) fullscreen shortcut not working in SwiftUI lifecycle app

Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
var onHideColumn: ((String) -> Void)?
var onMoveRow: ((Int, Int) -> Void)?
var onNavigateFK: ((String, ForeignKeyInfo) -> Void)?
var sortState = SortState()
var getVisualState: ((Int) -> RowVisualState)?
var dropdownColumns: Set<Int>?
var typePickerColumns: Set<Int>?
Expand Down
45 changes: 23 additions & 22 deletions TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ struct DataGridView: NSViewRepresentable {

let tableView = KeyHandlingTableView()
tableView.coordinator = context.coordinator
let sortableHeader = SortableTableHeaderView()
sortableHeader.coordinator = context.coordinator
tableView.headerView = sortableHeader
tableView.style = .plain
tableView.setAccessibilityLabel(String(localized: "Data grid"))
tableView.setAccessibilityRole(.table)
Expand Down Expand Up @@ -176,6 +179,7 @@ struct DataGridView: NSViewRepresentable {

scrollView.documentView = tableView
context.coordinator.tableView = tableView
context.coordinator.sortState = sortState
context.coordinator.onMoveRow = onMoveRow
context.coordinator.rebuildColumnMetadataCache()
if let connectionId {
Expand Down Expand Up @@ -229,6 +233,7 @@ struct DataGridView: NSViewRepresentable {
)
if currentIdentity == coordinator.lastIdentity {
// Only refresh closure callbacks — they capture new state on each body eval
coordinator.sortState = sortState
coordinator.onCellEdit = onCellEdit
coordinator.onSort = onSort
coordinator.onAddRow = onAddRow
Expand Down Expand Up @@ -299,6 +304,7 @@ struct DataGridView: NSViewRepresentable {
coordinator.onRefresh = onRefresh
coordinator.onCellEdit = onCellEdit
coordinator.onDeleteRows = onDeleteRows
coordinator.sortState = sortState
coordinator.onSort = onSort
coordinator.onAddRow = onAddRow
coordinator.onUndoInsert = onUndoInsert
Expand Down Expand Up @@ -504,7 +510,6 @@ struct DataGridView: NSViewRepresentable {
}
}

/// Synchronize sort descriptors and indicators with the table view
private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator) {
coordinator.isSyncingSortDescriptors = true
defer { coordinator.isSyncingSortDescriptors = false }
Expand All @@ -513,18 +518,16 @@ struct DataGridView: NSViewRepresentable {
if !tableView.sortDescriptors.isEmpty {
tableView.sortDescriptors = []
}
} else if let firstSort = sortState.columns.first,
firstSort.columnIndex >= 0 && firstSort.columnIndex < rowProvider.columns.count {
// Sync with first sort column for NSTableView's built-in sort indicators
let key = "col_\(firstSort.columnIndex)"
let ascending = firstSort.direction == .ascending
let currentDescriptor = tableView.sortDescriptors.first
if currentDescriptor?.key != key || currentDescriptor?.ascending != ascending {
tableView.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
} else {
let descriptors = sortState.columns.compactMap { sortCol -> NSSortDescriptor? in
guard sortCol.columnIndex >= 0 && sortCol.columnIndex < rowProvider.columns.count else { return nil }
return NSSortDescriptor(key: "col_\(sortCol.columnIndex)", ascending: sortCol.direction == .ascending)
}
if tableView.sortDescriptors != descriptors {
tableView.sortDescriptors = descriptors
}
}

// Update column header titles for multi-sort indicators
Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: rowProvider.columns)
}

Expand Down Expand Up @@ -662,19 +665,17 @@ struct DataGridView: NSViewRepresentable {
guard let colIndex = Int(idString.dropFirst(4)),
colIndex < columns.count else { continue }

let baseName = columns[colIndex]

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 {
// Single sort: NSTableView shows its own indicator, keep base name
column.title = baseName
}
if let sortCol = sortState.columns.first(where: { $0.columnIndex == colIndex }) {
let imageName = sortCol.direction == .ascending
? "NSAscendingSortIndicator"
: "NSDescendingSortIndicator"
tableView.setIndicatorImage(NSImage(named: imageName), in: column)
} else {
// Not sorted: restore base name
tableView.setIndicatorImage(nil, in: column)
}

let baseName = columns[colIndex]
if column.title != baseName {
column.title = baseName
}
}
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+Sort.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ extension TableViewCoordinator {
onSort?(columnIndex, sortDescriptor.ascending, isMultiSort)
}

// NSTableView swallows Shift+Click on headers, so SortableTableHeaderView calls this directly.
// Cycles: not sorted -> ascending -> descending -> remove
func handleShiftClickSort(columnIndex: Int) {
guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return }
let ascending = !sortState.columns.contains(where: { $0.columnIndex == columnIndex })
onSort?(columnIndex, ascending, true)
}

// MARK: - Double-Click Column Divider Auto-Fit

func tableView(_ tableView: NSTableView, sizeToFitWidthOfColumn columnIndex: Int) -> CGFloat {
Expand Down
34 changes: 34 additions & 0 deletions TablePro/Views/Results/KeyHandlingTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,37 @@ final class KeyHandlingTableView: NSTableView {
return super.menu(for: event)
}
}

// MARK: - Sortable Table Header View

/// Custom NSTableHeaderView that intercepts Shift+Click for multi-column sort.
/// NSTableView swallows Shift+Click on column headers (sortDescriptorsDidChange never fires),
/// so we detect the modifier here and route to the coordinator's multi-sort handler.
final class SortableTableHeaderView: NSTableHeaderView {
weak var coordinator: TableViewCoordinator?

override func mouseDown(with event: NSEvent) {
guard event.modifierFlags.contains(.shift) else {
super.mouseDown(with: event)
return
}

// Shift is held — handle multi-sort manually
let pointInHeader = convert(event.locationInWindow, from: nil)
let clickedColumn = column(at: pointInHeader)
guard clickedColumn >= 0,
let tableView,
clickedColumn < tableView.tableColumns.count else {
super.mouseDown(with: event)
return
}

let column = tableView.tableColumns[clickedColumn]
guard let dataColumnIndex = DataGridView.columnIndex(from: column.identifier) else {
super.mouseDown(with: event)
return
}

coordinator?.handleShiftClickSort(columnIndex: dataColumnIndex)
}
}
Loading