Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- A column comment in the data grid header is no longer crossed by the header's bottom line, and the empty strip under it is gone. The line now sits on the header's bottom edge where it belongs. (#2017)
- A sorted column no longer draws an extra line through its comment or an extra divider down its left edge. Its header now looks like every other column's. (#2017)
- Selecting a column now highlights the full height of its header instead of a band across the middle. (#2017)
- The New Connection window now comes to the front on the first try instead of opening behind the Welcome window. This applies to Import from URL, creating a connection from a project folder, picking a database type, File > New Connection, and duplicating a connection.
- Importing a connection URL while a New Connection window was already open no longer throws the pasted URL away. Each import now opens its own window instead of re-using the one already on screen.
- Clicking a foreign key arrow in a query tab's results no longer replaces that tab and loses the query and its results. The referenced table opens in its own tab, and clicking the same reference again returns to that tab instead of opening a duplicate. A tab with unsaved cell edits is kept the same way.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ These have caused real bugs when violated:

**A SwiftUI-hosted split view needs an explicit divider cursor**: `NSSplitView` shows the resize cursor over its dividers through AppKit's cursor-rects system, which does not fire once the split view is mounted inside an `NSHostingController` (every tab-content split is, several SwiftUI layers deep under `MainSplitViewController.detailHosting`). The divider still drags because drag hit-testing is independent of cursor rects, but the pointer never changes. Every SwiftUI-hosted split-view controller must subclass `ResizeCursorSplitViewController`, which adds a key-window tracking area to its own split view and sets `NSCursor.columnResize`/`rowResize` (falling back to `resizeLeftRight`/`resizeUpDown` before macOS 15) in `mouseMoved`, the same hand-rolled approach `SortableHeaderView` uses for column resize. It attaches the tracking area to the framework's split view in `viewDidLoad` rather than replacing the split view, so `NSSplitViewController`'s own layout and divider orientation stay intact; replacing the split view through a `loadView` override that skips `super` leaves the controller half-initialized and its panes stack instead of laying out side by side. Do not swap the controller back to a plain `NSSplitViewController` expecting the stock cursor to work; the window's own sidebar and inspector dividers only get the cursor for free because `MainSplitViewController` is the window's `contentViewController` directly, with no SwiftUI host in between. This shipped as Users & Roles, Structure, Server Dashboard, and query editor dividers that dragged but never showed the resize cursor (#1905).

**The data grid header owns all of its own chrome, so nothing may ask AppKit to paint any of it**: `NSTableHeaderCell` and `NSTableHeaderView` both paint a fixed 28pt band that they centre vertically in whatever frame they are given, a 16pt column divider on `midY` and a 1pt rule at `midY + 13`. The data grid grows its header to 42pt for a column comment, so that band lands mid-cell: the rule crosses the comment's descenders and sits 8pt above the real bottom edge. `SortableHeaderChrome` is therefore the single owner of header geometry and colours, `SortableHeaderCell.draw(withFrame:in:)` never calls `super`, and `SortableHeaderView.draw(_:)` fills the background and rules the bottom edge itself. The trap is that the header view paints a second copy of that same band for `NSTableView.highlightedTableColumn`, driven by *state* rather than by a drawing call, so no cell override can reach it: setting it gives the sorted column a stray divider and a rule no other column has. TablePro already draws the sorted-column affordance itself (bold title, chevron, priority number, with `drawSortIndicator` overridden to nothing), so `highlightedTableColumn` is a redundant second channel and must stay unset. All sorted-column presentation goes through `SortableHeaderView.applySortState(_:schema:)`, which publishes the order natively through `tableView.sortDescriptors` (for accessibility; it paints nothing) and updates the cells. `SortableHeaderRenderingTests` rasterises the header and guards this. This shipped as a rule through the comment line and a stray divider on the sorted column (#2017).

### Main Coordinator Pattern

`MainContentCoordinator` is the central coordinator, split across 7+ extension files in `Views/Main/Extensions/` (e.g., `+Alerts`, `+Filtering`, `+Pagination`, `+RowOperations`). When adding coordinator functionality, add a new extension file rather than growing the main file.
Expand Down
37 changes: 4 additions & 33 deletions TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ struct DataGridView: NSViewRepresentable {
coordinator.lastUpdateSnapshot = snapshot
}

syncSortDescriptors(tableView: tableView, coordinator: coordinator, columns: latestRows.columns)
syncSortState(tableView: tableView, coordinator: coordinator)
syncSelection(tableView: tableView, coordinator: coordinator)
}

Expand Down Expand Up @@ -350,39 +350,10 @@ struct DataGridView: NSViewRepresentable {
)
}

private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) {
private func syncSortState(tableView: NSTableView, coordinator: TableViewCoordinator) {
coordinator.currentSortState = sortState

let schema = coordinator.identitySchema
let primaryIdentifier: NSUserInterfaceItemIdentifier?
let primary: NSSortDescriptor?
if let firstSort = sortState.columns.first,
let identifier = schema.identifier(for: firstSort.columnIndex),
let name = schema.columnName(for: firstSort.columnIndex) {
primaryIdentifier = identifier
primary = NSSortDescriptor(key: name, 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
}

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: schema)
}
guard let header = tableView.headerView as? SortableHeaderView else { return }
header.applySortState(sortState, schema: coordinator.identitySchema)
}

// MARK: - Column Layout Helpers
Expand Down
10 changes: 5 additions & 5 deletions TablePro/Views/Results/Extensions/DataGridView+Sort.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ extension TableViewCoordinator {
var state = SortState()
state.columns = [SortColumn(columnIndex: columnIndex, direction: .ascending)]
currentSortState = state
updateSortIndicatorsFromCurrentState()
applyCurrentSortStateToHeader()
delegate?.dataGridSortStateChanged(state)
}

Expand All @@ -251,7 +251,7 @@ extension TableViewCoordinator {
var state = SortState()
state.columns = [SortColumn(columnIndex: columnIndex, direction: .descending)]
currentSortState = state
updateSortIndicatorsFromCurrentState()
applyCurrentSortStateToHeader()
delegate?.dataGridSortStateChanged(state)
}

Expand All @@ -261,13 +261,13 @@ extension TableViewCoordinator {

@objc func clearSortAction() {
currentSortState = SortState()
updateSortIndicatorsFromCurrentState()
applyCurrentSortStateToHeader()
delegate?.dataGridSortStateChanged(SortState())
}

private func updateSortIndicatorsFromCurrentState() {
private func applyCurrentSortStateToHeader() {
guard let header = tableView?.headerView as? SortableHeaderView else { return }
header.updateSortIndicators(state: currentSortState, schema: identitySchema)
header.applySortState(currentSortState, schema: identitySchema)
}

@objc func copyColumnName(_ sender: NSMenuItem) {
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Views/Results/SortableHeaderCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ final class SortableHeaderCell: NSTableHeaderCell {
wraps = false
}

override func draw(withFrame cellFrame: NSRect, in controlView: NSView) {
SortableHeaderChrome.drawColumnDivider(in: cellFrame)
drawInterior(withFrame: cellFrame, in: controlView)
}

override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
if isColumnSelected {
NSColor.selectedContentBackgroundColor.setFill()
Expand Down
38 changes: 38 additions & 0 deletions TablePro/Views/Results/SortableHeaderChrome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// SortableHeaderChrome.swift
// TablePro
//

import AppKit

@MainActor
enum SortableHeaderChrome {
static let separatorThickness: CGFloat = 1
static let columnDividerHeight: CGFloat = 16

static func fillBackground(_ rect: NSRect) {
NSColor.windowBackgroundColor.setFill()
rect.fill()
}

static func drawBottomSeparator(in bounds: NSRect) {
NSColor.separatorColor.setFill()
NSRect(
x: bounds.minX,
y: bounds.maxY - separatorThickness,
width: bounds.width,
height: separatorThickness
).fill()
}

static func drawColumnDivider(in cellFrame: NSRect) {
let dividerHeight = min(columnDividerHeight, cellFrame.height)
NSColor.separatorColor.setFill()
NSRect(
x: cellFrame.maxX - separatorThickness,
y: cellFrame.midY - dividerHeight / 2,
width: separatorThickness,
height: dividerHeight
).fill()
}
}
34 changes: 31 additions & 3 deletions TablePro/Views/Results/SortableHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ final class SortableHeaderView: NSTableHeaderView {
needsDisplay = true
}

override func draw(_ dirtyRect: NSRect) {
SortableHeaderChrome.fillBackground(dirtyRect)
super.draw(dirtyRect)
SortableHeaderChrome.drawBottomSeparator(in: bounds)
}

override func updateTrackingAreas() {
super.updateTrackingAreas()
if let existing = mouseMovedTrackingArea {
Expand Down Expand Up @@ -216,9 +222,31 @@ final class SortableHeaderView: NSTableHeaderView {
}
}

func updateSortIndicators(state: SortState, schema: ColumnIdentitySchema) {
guard let tableView = tableView else { return }
func applySortState(_ state: SortState, schema: ColumnIdentitySchema) {
guard let tableView else { return }
applySortDescriptors(state: state, schema: schema, in: tableView)
applySortIndicators(state: state, schema: schema, in: tableView)
}

private func applySortDescriptors(
state: SortState,
schema: ColumnIdentitySchema,
in tableView: NSTableView
) {
var primary: NSSortDescriptor?
if let leading = state.columns.first, let name = schema.columnName(for: leading.columnIndex) {
primary = NSSortDescriptor(key: name, ascending: leading.direction == .ascending)
}
let current = tableView.sortDescriptors.first
guard current?.key != primary?.key || current?.ascending != primary?.ascending else { return }
tableView.sortDescriptors = primary.map { [$0] } ?? []
}

private func applySortIndicators(
state: SortState,
schema: ColumnIdentitySchema,
in tableView: NSTableView
) {
var priorityByIdentifier: [NSUserInterfaceItemIdentifier: (direction: SortDirection, priority: Int)] = [:]
for (index, sortCol) in state.columns.enumerated() {
guard let identifier = schema.identifier(for: sortCol.columnIndex) else { continue }
Expand Down Expand Up @@ -315,7 +343,7 @@ final class SortableHeaderView: NSTableHeaderView {
)

coordinator.currentSortState = transition.newState
updateSortIndicators(state: transition.newState, schema: coordinator.identitySchema)
applySortState(transition.newState, schema: coordinator.identitySchema)
coordinator.delegate?.dataGridSortStateChanged(transition.newState)
}
}
Loading
Loading