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

### Fixed

- Fixed the right sidebar refusing to resize while a Users & Roles tab was open. The user list now collapses on its own when the tab gets too narrow to hold it. (#1872)
- Fixed dividers that could not be dragged at all: the user list and privilege panes in Users & Roles, the trigger list in Structure, and the metrics and slow query panes in Server Dashboard. (#1872)
- Cancel now stops a connection attempt right away instead of letting it run on in the background. A cancelled connection is also dropped from the last session, so restarting no longer reconnects to a host you gave up on, and it can no longer interrupt a later successful connection. (#1358)
- Fixed a crash when clicking a column header on a table whose columns have comments. Sorting such a table quit the app immediately. (#1869)

Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ Missing a case produces a wrong "{Language} Query" title on the first frame.

**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `MainContentCoordinator.syncRecoveryList()`, activated windows only) so "Reopen Last Session" never replays a connect the user cancelled. This area shipped the same bug four times (#1185, #1358, #1369).

**A split pane's `holdingPriority` must stay below 490**: AppKit applies a divider drag as a layout change at `dragThatCannotResizeWindow` (490). Any pane whose `holdingPriority` is at or above that outranks the drag, so its width constraint wins and the divider cannot move at all. `.defaultHigh` (750) freezes it outright, which shipped as three dead dividers (Users & Roles, Structure triggers, Server Dashboard). Use `.splitPaneHolding` (260, the value AppKit itself gives a sidebar item): high enough to outrank a `.defaultLow` (250) sibling so the pane holds its size when the window resizes, low enough that a drag still wins. `.defaultLow` is not the fix, since the pane then grows with the window instead of holding. (#1872)

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

### 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
65 changes: 46 additions & 19 deletions TablePro/Core/Services/Infrastructure/MainSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,14 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
addSplitViewItem(sidebarSplitItem)

detailHosting = NSHostingController(rootView: AnyView(Color.clear))
detailHosting.sizingOptions = []
detailSplitItem = NSSplitViewItem(viewController: detailHosting)
detailSplitItem.minimumThickness = Self.detailMinThickness
detailSplitItem.minimumThickness = Self.resolveDetailMinimumThickness(for: payload?.tabType)
detailSplitItem.holdingPriority = .defaultLow
addSplitViewItem(detailSplitItem)

inspectorHosting = NSHostingController(rootView: AnyView(Color.clear))
inspectorHosting.sizingOptions = []
inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting)
inspectorSplitItem.canCollapse = true
inspectorSplitItem.minimumThickness = Self.inspectorMinThickness
Expand Down Expand Up @@ -549,32 +551,57 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi

// MARK: - Dynamic Window Minimum Size

private static let baseWindowMinWidth: CGFloat = 720
private static let baseWindowMinHeight: CGFloat = 480
private static let sidebarMinThickness: CGFloat = 280
static let baseWindowMinWidth: CGFloat = 720
static let baseWindowMinHeight: CGFloat = 480
static let sidebarMinThickness: CGFloat = 280
static let defaultDetailMinThickness: CGFloat = 400
static let inspectorMinThickness: CGFloat = 270
private static let sidebarMaxThickness: CGFloat = 600
private static let detailMinThickness: CGFloat = 400
private static let inspectorMinThickness: CGFloat = 270

private func recomputeWindowMinSize() {
guard let window = view.window else { return }
let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true)
let inspectorVisible = !(inspectorSplitItem?.isCollapsed ?? true)

let detailMin = Self.detailMinThickness
let sidebarMin = Self.sidebarMinThickness
let inspectorMin = Self.inspectorMinThickness
let dividerThickness = splitView.dividerThickness
static func resolveDetailMinimumThickness(for tabType: TabType?) -> CGFloat {
guard let tabType else { return defaultDetailMinThickness }
switch tabType {
case .usersRoles:
return UsersRolesLayoutMetrics.tabMinimumWidth
case .query, .table, .createTable, .erDiagram, .serverDashboard:
return defaultDetailMinThickness
}
}

var width: CGFloat = detailMin
static func resolveWindowMinWidth(
detailMinimum: CGFloat,
sidebarVisible: Bool,
inspectorVisible: Bool,
dividerThickness: CGFloat
) -> CGFloat {
var width = detailMinimum
if sidebarVisible {
width += sidebarMin + dividerThickness
width += sidebarMinThickness + dividerThickness
}
if inspectorVisible {
width += inspectorMin + dividerThickness
width += inspectorMinThickness + dividerThickness
}
return max(baseWindowMinWidth, width)
}

let resolvedWidth = max(Self.baseWindowMinWidth, width)
func updateDetailMinimumThickness(for tabType: TabType?) {
let resolved = Self.resolveDetailMinimumThickness(for: tabType)
guard let detailSplitItem, detailSplitItem.minimumThickness != resolved else { return }
detailSplitItem.minimumThickness = resolved
recomputeWindowMinSize()
}

private func recomputeWindowMinSize() {
guard let window = view.window else { return }
let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true)
let inspectorVisible = !(inspectorSplitItem?.isCollapsed ?? true)

let resolvedWidth = Self.resolveWindowMinWidth(
detailMinimum: detailSplitItem?.minimumThickness ?? Self.defaultDetailMinThickness,
sidebarVisible: sidebarVisible,
inspectorVisible: inspectorVisible,
dividerThickness: splitView.dividerThickness
)
let newMinSize = NSSize(width: resolvedWidth, height: Self.baseWindowMinHeight)

guard window.minSize != newMinSize else { return }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import AppKit

internal extension NSLayoutConstraint.Priority {
static let splitPaneHolding = NSLayoutConstraint.Priority(260)
}
64 changes: 59 additions & 5 deletions TablePro/Views/Components/AutosavingSplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ struct AutosavingSplitView<Primary: View, Secondary: View>: NSViewControllerRepr
var primaryMinimum: CGFloat
var primaryMaximum: CGFloat?
var secondaryMinimum: CGFloat
var primaryHoldingPriority: NSLayoutConstraint.Priority = .defaultHigh
var primaryHoldingPriority: NSLayoutConstraint.Priority = .splitPaneHolding
var collapsesPrimaryWhenTight = false
@ViewBuilder let primary: () -> Primary
@ViewBuilder let secondary: () -> Secondary

func makeCoordinator() -> Coordinator { Coordinator() }

func makeNSViewController(context: Context) -> NSSplitViewController {
let controller = NSSplitViewController()
func makeNSViewController(context: Context) -> CollapsingSplitViewController {
let controller = CollapsingSplitViewController()
controller.collapsesPrimaryWhenTight = collapsesPrimaryWhenTight
controller.splitView.isVertical = isVertical
controller.splitView.dividerStyle = .thin

Expand All @@ -41,7 +43,7 @@ struct AutosavingSplitView<Primary: View, Secondary: View>: NSViewControllerRepr

let primaryItem = NSSplitViewItem(viewController: primaryController)
primaryItem.minimumThickness = primaryMinimum
primaryItem.canCollapse = false
primaryItem.canCollapse = collapsesPrimaryWhenTight
primaryItem.holdingPriority = primaryHoldingPriority
if let primaryMaximum {
primaryItem.maximumThickness = primaryMaximum
Expand All @@ -58,13 +60,65 @@ struct AutosavingSplitView<Primary: View, Secondary: View>: NSViewControllerRepr
return controller
}

func updateNSViewController(_ controller: NSSplitViewController, context: Context) {
func updateNSViewController(_ controller: CollapsingSplitViewController, context: Context) {
controller.collapsesPrimaryWhenTight = collapsesPrimaryWhenTight
context.coordinator.primaryController?.rootView = primary()
context.coordinator.secondaryController?.rootView = secondary()
}

func sizeThatFits(
_ proposal: ProposedViewSize,
nsViewController: CollapsingSplitViewController,
context: Context
) -> CGSize? {
let natural = primaryMinimum + secondaryMinimum
let resolved = proposal.replacingUnspecifiedDimensions(
by: CGSize(width: natural, height: natural)
)
guard resolved.width.isFinite, resolved.height.isFinite else { return nil }
return resolved
}

final class Coordinator {
var primaryController: NSHostingController<Primary>?
var secondaryController: NSHostingController<Secondary>?
}
}

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

private var didAutoCollapsePrimary = false

override func viewDidLayout() {
super.viewDidLayout()
applyAutomaticCollapse()
}

private func applyAutomaticCollapse() {
guard collapsesPrimaryWhenTight, splitView.isVertical else { return }
guard splitViewItems.count == 2 else { return }

let primaryItem = splitViewItems[0]
let secondaryItem = splitViewItems[1]

let available = splitView.bounds.width
guard available > 0 else { return }

let required = primaryItem.minimumThickness
+ secondaryItem.minimumThickness
+ splitView.dividerThickness

guard available >= required else {
guard !primaryItem.isCollapsed else { return }
primaryItem.isCollapsed = true
didAutoCollapsePrimary = true
Comment on lines +115 to +116

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 persisting automatic collapses as user state

When a Users & Roles tab is narrowed, setting primaryItem.isCollapsed = true on this autosaving split view can persist the zero-width primary pane under autosaveName. If the tab/window is later recreated at a wide size, didAutoCollapsePrimary starts as false, so the restore path looks like a manual collapse and line 120 never expands the principal list. Users who once narrow the tab can therefore reopen it with the user list stuck hidden; avoid autosaving the automatic collapse or record enough state to undo it after restore.

Useful? React with 👍 / 👎.

return
}

guard didAutoCollapsePrimary, primaryItem.isCollapsed else { return }
primaryItem.isCollapsed = false
didAutoCollapsePrimary = false
}
}
1 change: 1 addition & 0 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ extension MainContentView {
tab: selectedTab,
connection: connection
)
coordinator.splitViewController?.updateDetailMinimumThickness(for: selectedTab?.tabType)
viewWindow?.representedURL = selectedTab?.content.sourceFileURL
viewWindow?.isDocumentEdited = selectedTab?.showsUnsavedIndicator ?? false
}
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable {
let item = NSSplitViewItem(viewController: controller)
item.minimumThickness = 76
item.maximumThickness = 200
item.holdingPriority = .defaultHigh
item.holdingPriority = .splitPaneHolding
coordinator.metricsController = controller
return item

Expand All @@ -74,7 +74,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable {
let item = NSSplitViewItem(viewController: controller)
item.minimumThickness = 100
item.canCollapse = true
item.holdingPriority = .defaultHigh
item.holdingPriority = .splitPaneHolding
coordinator.slowQueriesController = controller
return item
}
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Views/UsersRoles/PrivilegeEditorPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ struct PrivilegeEditorPane: View {
var body: some View {
AutosavingSplitView(
autosaveName: "com.TablePro.usersRoles.privilegeSplit",
primaryMinimum: 240,
primaryMaximum: 640,
secondaryMinimum: 300
primaryMinimum: UsersRolesLayoutMetrics.privilegeScopeMinimumWidth,
primaryMaximum: UsersRolesLayoutMetrics.privilegeScopeMaximumWidth,
secondaryMinimum: UsersRolesLayoutMetrics.privilegeChecklistMinimumWidth
) {
scopePane
} secondary: {
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import CoreGraphics

internal enum UsersRolesLayoutMetrics {
static let principalListMinimumWidth: CGFloat = 200
static let principalListMaximumWidth: CGFloat = 520
static let principalDetailMinimumWidth: CGFloat = 560

static let privilegeScopeMinimumWidth: CGFloat = 200
static let privilegeScopeMaximumWidth: CGFloat = 640
static let privilegeChecklistMinimumWidth: CGFloat = 300

static var tabMinimumWidth: CGFloat { principalDetailMinimumWidth }
}
7 changes: 4 additions & 3 deletions TablePro/Views/UsersRoles/UsersRolesTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ struct UsersRolesTabView: View {
VStack(spacing: 0) {
AutosavingSplitView(
autosaveName: "com.TablePro.usersRoles.mainSplit",
primaryMinimum: 200,
primaryMaximum: 520,
secondaryMinimum: 560
primaryMinimum: UsersRolesLayoutMetrics.principalListMinimumWidth,
primaryMaximum: UsersRolesLayoutMetrics.principalListMaximumWidth,
secondaryMinimum: UsersRolesLayoutMetrics.principalDetailMinimumWidth,
collapsesPrimaryWhenTight: true
) {
PrincipalListPane(viewModel: viewModel)
} secondary: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import AppKit
import Foundation
@testable import TablePro
import Testing

@Suite("Split pane holding priority")
@MainActor
struct SplitPaneHoldingPriorityTests {
private static let dragThatCannotResizeWindow: Float = 490

@Test("A held pane still yields to a divider drag")
func heldPaneYieldsToDivider() {
#expect(NSLayoutConstraint.Priority.splitPaneHolding.rawValue < Self.dragThatCannotResizeWindow)
}

@Test("A held pane still outranks its sibling when the container resizes")
func heldPaneOutranksSibling() {
#expect(
NSLayoutConstraint.Priority.splitPaneHolding.rawValue
> NSLayoutConstraint.Priority.defaultLow.rawValue
)
}

@Test("Privilege editor keeps room to drag at the tab's minimum width")
func privilegeEditorKeepsDragRoom() {
let required = UsersRolesLayoutMetrics.privilegeScopeMinimumWidth
+ UsersRolesLayoutMetrics.privilegeChecklistMinimumWidth
#expect(UsersRolesLayoutMetrics.tabMinimumWidth - required >= 50)
}
}

@Suite("MainSplitViewController detail width")
@MainActor
struct MainSplitViewControllerDetailWidthTests {
@Test("Nil tab type falls back to the default detail minimum")
func nilTabTypeUsesDefault() {
let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: nil)
#expect(resolved == MainSplitViewController.defaultDetailMinThickness)
}

@Test("Users & Roles declares the width its panes actually need")
func usersRolesDeclaresItsOwnMinimum() {
let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles)
#expect(resolved == UsersRolesLayoutMetrics.tabMinimumWidth)
#expect(resolved == 560)
}

@Test(
"Every other tab type keeps the default detail minimum",
arguments: [TabType.query, .table, .createTable, .erDiagram, .serverDashboard]
)
func otherTabTypesUseDefault(tabType: TabType) {
let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: tabType)
#expect(resolved == MainSplitViewController.defaultDetailMinThickness)
#expect(resolved == 400)
}

@Test("Users & Roles fits its privilege editor once the principal list collapses")
func usersRolesMinimumFitsCollapsedLayout() {
let privilegeEditorWidth = UsersRolesLayoutMetrics.privilegeScopeMinimumWidth
+ UsersRolesLayoutMetrics.privilegeChecklistMinimumWidth
#expect(UsersRolesLayoutMetrics.tabMinimumWidth >= privilegeEditorWidth)
}

@Test("Both panels hidden collapses to the base window minimum")
func collapsedPanelsUseBaseWindowMinimum() {
let width = MainSplitViewController.resolveWindowMinWidth(
detailMinimum: MainSplitViewController.defaultDetailMinThickness,
sidebarVisible: false,
inspectorVisible: false,
dividerThickness: 1
)
#expect(width == MainSplitViewController.baseWindowMinWidth)
}

@Test("Both panels visible sums sidebar, detail, inspector and dividers")
func visiblePanelsSumThicknesses() {
let width = MainSplitViewController.resolveWindowMinWidth(
detailMinimum: MainSplitViewController.defaultDetailMinThickness,
sidebarVisible: true,
inspectorVisible: true,
dividerThickness: 1
)
#expect(width == 952)
}

@Test("A Users & Roles tab widens the window minimum instead of pinning the inspector")
func usersRolesWidensWindowMinimum() {
let width = MainSplitViewController.resolveWindowMinWidth(
detailMinimum: MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles),
sidebarVisible: true,
inspectorVisible: true,
dividerThickness: 1
)
#expect(width == 1_112)
}
}
Loading