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..630f8b486 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 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. 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/ResizeCursorSplitViewController.swift b/TablePro/Views/Components/ResizeCursorSplitViewController.swift new file mode 100644 index 000000000..1916057b0 --- /dev/null +++ b/TablePro/Views/Components/ResizeCursorSplitViewController.swift @@ -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]) + } + return splitView.isVertical ? .resizeLeftRight : .resizeUpDown + } +} 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)) + } +}