From 7f858f1c960a47c4b35ba1ee869fc3631e63167b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Jul 2026 16:08:25 +0700 Subject: [PATCH 1/2] fix(hig): show the resize cursor over SwiftUI-hosted split dividers Claude-Session: https://claude.ai/code/session_01A5xT92pokfSFFYVjBgfnJP --- CHANGELOG.md | 1 + CLAUDE.md | 2 + .../Components/AutosavingSplitView.swift | 2 +- .../Components/ResizeCursorSplitView.swift | 133 ++++++++++++++++++ TablePro/Views/Editor/QuerySplitView.swift | 2 +- .../ServerDashboardSplitView.swift | 2 +- .../SplitDividerCursorGeometryTests.swift | 93 ++++++++++++ 7 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 TablePro/Views/Components/ResizeCursorSplitView.swift create mode 100644 TableProTests/Views/Components/SplitDividerCursorGeometryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cf74653..f25ba9470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 9160a1330..a2815c673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 installs a `ResizeCursorSplitView` that adds a key-window tracking area over the divider hit zone 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. Do not swap it 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. diff --git a/TablePro/Views/Components/AutosavingSplitView.swift b/TablePro/Views/Components/AutosavingSplitView.swift index ed1d8e1d4..443dbd79e 100644 --- a/TablePro/Views/Components/AutosavingSplitView.swift +++ b/TablePro/Views/Components/AutosavingSplitView.swift @@ -86,7 +86,7 @@ struct AutosavingSplitView: NSViewControllerRepr } @MainActor -internal final class CollapsingSplitViewController: NSSplitViewController { +internal final class CollapsingSplitViewController: ResizeCursorSplitViewController { var collapsesPrimaryWhenTight = false private var didAutoCollapsePrimary = false diff --git a/TablePro/Views/Components/ResizeCursorSplitView.swift b/TablePro/Views/Components/ResizeCursorSplitView.swift new file mode 100644 index 000000000..061ff5643 --- /dev/null +++ b/TablePro/Views/Components/ResizeCursorSplitView.swift @@ -0,0 +1,133 @@ +// +// ResizeCursorSplitView.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 final class ResizeCursorSplitView: NSSplitView { + private static let hitPadding: CGFloat = 3 + + private var cursorTrackingArea: NSTrackingArea? + private var isShowingResizeCursor = false + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let cursorTrackingArea { + removeTrackingArea(cursorTrackingArea) + } + let area = NSTrackingArea( + rect: .zero, + options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + cursorTrackingArea = area + } + + override func mouseMoved(with event: NSEvent) { + let point = 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: subviews.map(\.frame), + collapsed: subviews.map { isSubviewCollapsed($0) }, + isVertical: isVertical, + padding: Self.hitPadding, + bounds: bounds + ) + return SplitDividerCursorGeometry.isWithinDivider(point: point, hitRects: hitRects) + } + + private var resizeCursor: NSCursor { + if #available(macOS 15.0, *) { + return isVertical + ? .columnResize(directions: [.left, .right]) + : .rowResize(directions: [.up, .down]) + } + return isVertical ? .resizeLeftRight : .resizeUpDown + } +} + +@MainActor +internal class ResizeCursorSplitViewController: NSSplitViewController { + override func loadView() { + let cursorSplitView = ResizeCursorSplitView() + splitView = cursorSplitView + view = cursorSplitView + } +} diff --git a/TablePro/Views/Editor/QuerySplitView.swift b/TablePro/Views/Editor/QuerySplitView.swift index 409e19f64..bd6aeaf2e 100644 --- a/TablePro/Views/Editor/QuerySplitView.swift +++ b/TablePro/Views/Editor/QuerySplitView.swift @@ -12,7 +12,7 @@ struct QuerySplitView: NSViewControllerRe } func makeNSViewController(context: Context) -> NSSplitViewController { - let splitViewController = NSSplitViewController() + let splitViewController = ResizeCursorSplitViewController() splitViewController.splitView.isVertical = false splitViewController.splitView.dividerStyle = .thin splitViewController.splitView.autosaveName = autosaveName diff --git a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift index 761a9e399..4f06942b4 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift @@ -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" diff --git a/TableProTests/Views/Components/SplitDividerCursorGeometryTests.swift b/TableProTests/Views/Components/SplitDividerCursorGeometryTests.swift new file mode 100644 index 000000000..5671f39ae --- /dev/null +++ b/TableProTests/Views/Components/SplitDividerCursorGeometryTests.swift @@ -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)) + } +} From 4d2343662ec9fe36cd3ad4f9cf337aa737a83e11 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Jul 2026 16:20:55 +0700 Subject: [PATCH 2/2] fix(hig): attach the split-divider resize cursor without replacing the split view Claude-Session: https://claude.ai/code/session_01A5xT92pokfSFFYVjBgfnJP --- CLAUDE.md | 2 +- ... => ResizeCursorSplitViewController.swift} | 38 ++++++------------- 2 files changed, 13 insertions(+), 27 deletions(-) rename TablePro/Views/Components/{ResizeCursorSplitView.swift => ResizeCursorSplitViewController.swift} (78%) diff --git a/CLAUDE.md b/CLAUDE.md index a2815c673..630f8b486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,7 @@ 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 installs a `ResizeCursorSplitView` that adds a key-window tracking area over the divider hit zone 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. Do not swap it 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). +**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 diff --git a/TablePro/Views/Components/ResizeCursorSplitView.swift b/TablePro/Views/Components/ResizeCursorSplitViewController.swift similarity index 78% rename from TablePro/Views/Components/ResizeCursorSplitView.swift rename to TablePro/Views/Components/ResizeCursorSplitViewController.swift index 061ff5643..1916057b0 100644 --- a/TablePro/Views/Components/ResizeCursorSplitView.swift +++ b/TablePro/Views/Components/ResizeCursorSplitViewController.swift @@ -1,5 +1,5 @@ // -// ResizeCursorSplitView.swift +// ResizeCursorSplitViewController.swift // TablePro // @@ -59,29 +59,24 @@ internal enum SplitDividerCursorGeometry { } @MainActor -internal final class ResizeCursorSplitView: NSSplitView { +internal class ResizeCursorSplitViewController: NSSplitViewController { private static let hitPadding: CGFloat = 3 - private var cursorTrackingArea: NSTrackingArea? private var isShowingResizeCursor = false - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let cursorTrackingArea { - removeTrackingArea(cursorTrackingArea) - } + override func viewDidLoad() { + super.viewDidLoad() let area = NSTrackingArea( rect: .zero, options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) - addTrackingArea(area) - cursorTrackingArea = area + splitView.addTrackingArea(area) } override func mouseMoved(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) + let point = splitView.convert(event.locationInWindow, from: nil) guard isWithinDivider(point) else { restoreCursorIfNeeded() super.mouseMoved(with: event) @@ -104,30 +99,21 @@ internal final class ResizeCursorSplitView: NSSplitView { private func isWithinDivider(_ point: CGPoint) -> Bool { let hitRects = SplitDividerCursorGeometry.dividerHitRects( - subviewFrames: subviews.map(\.frame), - collapsed: subviews.map { isSubviewCollapsed($0) }, - isVertical: isVertical, + subviewFrames: splitView.subviews.map(\.frame), + collapsed: splitView.subviews.map { splitView.isSubviewCollapsed($0) }, + isVertical: splitView.isVertical, padding: Self.hitPadding, - bounds: bounds + bounds: splitView.bounds ) return SplitDividerCursorGeometry.isWithinDivider(point: point, hitRects: hitRects) } private var resizeCursor: NSCursor { if #available(macOS 15.0, *) { - return isVertical + return splitView.isVertical ? .columnResize(directions: [.left, .right]) : .rowResize(directions: [.up, .down]) } - return isVertical ? .resizeLeftRight : .resizeUpDown - } -} - -@MainActor -internal class ResizeCursorSplitViewController: NSSplitViewController { - override func loadView() { - let cursorSplitView = ResizeCursorSplitView() - splitView = cursorSplitView - view = cursorSplitView + return splitView.isVertical ? .resizeLeftRight : .resizeUpDown } }