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

### Fixed

- Fixed split view dividers not showing the resize cursor on hover in the Users and Roles, Structure, Server Dashboard, and SQL editor panels, even though the dividers could be dragged. (#1905)
- Fixed a query with a comment after the closing semicolon, such as `SELECT 1; -- note`, being run as two statements, with the trailing comment sent to the server as a failing second statement. Running a comment-only query or selection now does nothing instead of producing a server error, and AI and MCP clients are no longer told such a query is multi-statement. (#1895)
- Fixed duplicating a connection dropping its Cloudflare Tunnel and Cloud SQL Auth Proxy settings and stored secrets.
- Fixed the tunnel panes warning about only some of the other enabled connection methods. Each pane now lists every conflicting method with a button to turn it off.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ These have caused real bugs when violated:

**Tab content must never pin the window's split dividers**: `NSSplitViewItem.minimumThickness` is a required constraint, so a nested `NSSplitViewController` reports `sum(minimums) + dividers` as its `fittingSize`. SwiftUI adopts that number for an `NSViewControllerRepresentable` and the enclosing `NSHostingView` turns it into a `minWidth` at priority 999.9, which beats the 490 (`dragThatCannotResizeWindow`) a divider drag runs at: the window's sidebar and inspector dividers go dead. Two rules follow. First, every hosting controller that is a split item's view controller sets `sizingOptions = []` (`MainSplitViewController`'s `detailHosting` and `inspectorHosting`, and both panes inside `AutosavingSplitView`), and `AutosavingSplitView` returns the proposal from `sizeThatFits` so its own minimums never escape into SwiftUI. Second, a tab that genuinely needs more width than `defaultDetailMinThickness` declares it through `resolveDetailMinimumThickness(for:)` instead of leaking it; the detail pane's minimum is a per-tab contract, and `recomputeWindowMinSize()` reads it live. AppKit will not rescue you here: `.sidebar` behaviour and `canCollapseFromWindowResize` only auto-collapse on a window live-resize, which an embedded split view never sees, and no form of collapsibility lowers `fittingSize` (only an actual `isCollapsed = true` does). `CollapsingSplitViewController` collapses the pane itself for that reason. This shipped as a dead inspector divider on Users & Roles tabs (#1872).

**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).

### 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
2 changes: 1 addition & 1 deletion TablePro/Views/Components/AutosavingSplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ struct AutosavingSplitView<Primary: View, Secondary: View>: NSViewControllerRepr
}

@MainActor
internal final class CollapsingSplitViewController: NSSplitViewController {
internal final class CollapsingSplitViewController: ResizeCursorSplitViewController {
var collapsesPrimaryWhenTight = false

private var didAutoCollapsePrimary = false
Expand Down
119 changes: 119 additions & 0 deletions TablePro/Views/Components/ResizeCursorSplitViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// ResizeCursorSplitViewController.swift
// TablePro
//

import AppKit

internal enum SplitDividerCursorGeometry {
static func dividerHitRects(
subviewFrames: [CGRect],
collapsed: [Bool],
isVertical: Bool,
padding: CGFloat,
bounds: CGRect
) -> [CGRect] {
guard subviewFrames.count == collapsed.count, subviewFrames.count >= 2 else { return [] }

var rects: [CGRect] = []
for index in 0..<(subviewFrames.count - 1) where !collapsed[index] && !collapsed[index + 1] {
let first = subviewFrames[index]
let second = subviewFrames[index + 1]
if isVertical {
let (start, end) = gap(first.minX, first.maxX, second.minX, second.maxX)
rects.append(
CGRect(
x: start - padding,
y: bounds.minY,
width: end - start + padding * 2,
height: bounds.height
)
)
} else {
let (start, end) = gap(first.minY, first.maxY, second.minY, second.maxY)
rects.append(
CGRect(
x: bounds.minX,
y: start - padding,
width: bounds.width,
height: end - start + padding * 2
)
)
}
}
return rects
}

static func isWithinDivider(point: CGPoint, hitRects: [CGRect]) -> Bool {
hitRects.contains { $0.contains(point) }
}

private static func gap(
_ firstMin: CGFloat,
_ firstMax: CGFloat,
_ secondMin: CGFloat,
_ secondMax: CGFloat
) -> (start: CGFloat, end: CGFloat) {
firstMax <= secondMin ? (firstMax, secondMin) : (secondMax, firstMin)
}
}

@MainActor
internal class ResizeCursorSplitViewController: NSSplitViewController {
private static let hitPadding: CGFloat = 3

private var isShowingResizeCursor = false

override func viewDidLoad() {
super.viewDidLoad()
let area = NSTrackingArea(
rect: .zero,
options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil
)
splitView.addTrackingArea(area)
}

override func mouseMoved(with event: NSEvent) {
let point = splitView.convert(event.locationInWindow, from: nil)
guard isWithinDivider(point) else {
restoreCursorIfNeeded()
super.mouseMoved(with: event)
return
}
resizeCursor.set()
isShowingResizeCursor = true
}

override func mouseExited(with event: NSEvent) {
restoreCursorIfNeeded()
super.mouseExited(with: event)
}

private func restoreCursorIfNeeded() {
guard isShowingResizeCursor else { return }
NSCursor.arrow.set()
isShowingResizeCursor = false
}

private func isWithinDivider(_ point: CGPoint) -> Bool {
let hitRects = SplitDividerCursorGeometry.dividerHitRects(
subviewFrames: splitView.subviews.map(\.frame),
collapsed: splitView.subviews.map { splitView.isSubviewCollapsed($0) },
isVertical: splitView.isVertical,
padding: Self.hitPadding,
bounds: splitView.bounds
)
return SplitDividerCursorGeometry.isWithinDivider(point: point, hitRects: hitRects)
}

private var resizeCursor: NSCursor {
if #available(macOS 15.0, *) {
return splitView.isVertical
? .columnResize(directions: [.left, .right])
: .rowResize(directions: [.up, .down])
Comment on lines +114 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid SDK-only cursor APIs in Xcode 15 builds

When building with the documented Xcode 15/macOS 14 SDK, NSCursor.columnResize(directions:) and rowResize(directions:) are not declared, and the #available(macOS 15.0, *) runtime check does not prevent the compiler from resolving those symbols. Since README/CONTRIBUTING still advertise Xcode 15+ support, this makes the app fail to compile for a supported build environment; use the existing resizeLeftRight/resizeUpDown APIs or hide the newer symbols behind an SDK-safe shim.

Useful? React with 👍 / 👎.

}
return splitView.isVertical ? .resizeLeftRight : .resizeUpDown
}
}
2 changes: 1 addition & 1 deletion TablePro/Views/Editor/QuerySplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct QuerySplitView<TopContent: View, BottomContent: View>: NSViewControllerRe
}

func makeNSViewController(context: Context) -> NSSplitViewController {
let splitViewController = NSSplitViewController()
let splitViewController = ResizeCursorSplitViewController()
splitViewController.splitView.isVertical = false
splitViewController.splitView.dividerStyle = .thin
splitViewController.splitView.autosaveName = autosaveName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable {
}

func makeNSViewController(context: Context) -> NSSplitViewController {
let splitViewController = NSSplitViewController()
let splitViewController = ResizeCursorSplitViewController()
splitViewController.splitView.isVertical = false
splitViewController.splitView.dividerStyle = .thin
splitViewController.splitView.autosaveName = "ServerDashboardSplit"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import AppKit
import Foundation
@testable import TablePro
import Testing

@Suite("Split divider cursor geometry")
@MainActor
struct SplitDividerCursorGeometryTests {
@Test("A vertical split places one padded, full-height rect over the divider")
func verticalSplitProducesOneRect() {
let rects = SplitDividerCursorGeometry.dividerHitRects(
subviewFrames: [
CGRect(x: 0, y: 0, width: 150, height: 200),
CGRect(x: 151, y: 0, width: 149, height: 200)
],
collapsed: [false, false],
isVertical: true,
padding: 3,
bounds: CGRect(x: 0, y: 0, width: 300, height: 200)
)
#expect(rects == [CGRect(x: 147, y: 0, width: 7, height: 200)])
}

@Test("A horizontal split places one padded, full-width rect over the divider")
func horizontalSplitProducesOneRect() {
let rects = SplitDividerCursorGeometry.dividerHitRects(
subviewFrames: [
CGRect(x: 0, y: 0, width: 200, height: 150),
CGRect(x: 0, y: 151, width: 200, height: 149)
],
collapsed: [false, false],
isVertical: false,
padding: 3,
bounds: CGRect(x: 0, y: 0, width: 200, height: 300)
)
#expect(rects == [CGRect(x: 0, y: 147, width: 200, height: 7)])
}

@Test("Three panes produce two dividers")
func threePanesProduceTwoDividers() {
let rects = SplitDividerCursorGeometry.dividerHitRects(
subviewFrames: [
CGRect(x: 0, y: 0, width: 150, height: 200),
CGRect(x: 151, y: 0, width: 150, height: 200),
CGRect(x: 302, y: 0, width: 159, height: 200)
],
collapsed: [false, false, false],
isVertical: true,
padding: 3,
bounds: CGRect(x: 0, y: 0, width: 461, height: 200)
)
#expect(rects == [
CGRect(x: 147, y: 0, width: 7, height: 200),
CGRect(x: 298, y: 0, width: 7, height: 200)
])
}

@Test("A collapsed pane drops the divider next to it")
func collapsedPaneDropsAdjacentDivider() {
let rects = SplitDividerCursorGeometry.dividerHitRects(
subviewFrames: [
CGRect(x: 0, y: 0, width: 150, height: 200),
CGRect(x: 151, y: 0, width: 150, height: 200),
CGRect(x: 302, y: 0, width: 159, height: 200)
],
collapsed: [false, false, true],
isVertical: true,
padding: 3,
bounds: CGRect(x: 0, y: 0, width: 461, height: 200)
)
#expect(rects == [CGRect(x: 147, y: 0, width: 7, height: 200)])
}

@Test("Fewer than two panes produce no dividers")
func singlePaneProducesNoDividers() {
let rects = SplitDividerCursorGeometry.dividerHitRects(
subviewFrames: [CGRect(x: 0, y: 0, width: 300, height: 200)],
collapsed: [false],
isVertical: true,
padding: 3,
bounds: CGRect(x: 0, y: 0, width: 300, height: 200)
)
#expect(rects.isEmpty)
}

@Test("A point inside the divider is detected, one outside is not")
func hitTestingMatchesTheDividerRect() {
let hitRects = [CGRect(x: 147, y: 0, width: 7, height: 200)]
#expect(SplitDividerCursorGeometry.isWithinDivider(point: CGPoint(x: 150, y: 100), hitRects: hitRects))
#expect(!SplitDividerCursorGeometry.isWithinDivider(point: CGPoint(x: 100, y: 100), hitRects: hitRects))
#expect(!SplitDividerCursorGeometry.isWithinDivider(point: CGPoint(x: 200, y: 100), hitRects: hitRects))
}
}
Loading