Skip to content

Commit afad18d

Browse files
authored
fix(connections): keep the connections strip and its commands when a connection is down (#2551)
1 parent ff220a7 commit afad18d

11 files changed

Lines changed: 419 additions & 62 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7272
- Connections strip not scrolling to the entry you switch to.
7373
- Background connections stuck on the session preparation screen after connecting. (#2545)
7474
- Blank sidebar, grid and inspector on a connection opened into a window that was already on screen.
75+
- Connections strip hidden, with Switch Connection and the Show Next and Previous Connection items disabled, whenever the connection on screen was not connected.
76+
- Choosing a connection from the connection list re-fronting its window without switching to it.
7577
- Grid cells left at the old column positions until the next click, after a resize, an auto-fit, a reorder, hiding a column, or a row-number width change. (#2449, #2446)
7678
- The row-number column draggable out of first place, which walked it to the far right on the next refresh.
7779
- A time entered into a date cell discarded when the stored value carried no time.

TablePro/Core/Services/Infrastructure/ConnectionWindowPaneResolver.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ internal enum ConnectionWindowPane: Equatable {
1212
case empty
1313
}
1414

15+
/// What the window's one sidebar item holds. `railOnly` is the state that exists because the
16+
/// workspace rail and the object browser share that item and answer to different owners.
17+
internal enum SidebarChromeMode: Equatable {
18+
case revealed
19+
case railOnly
20+
case hidden
21+
22+
internal var showsObjectBrowser: Bool { self == .revealed }
23+
}
24+
1525
internal enum ConnectionWindowPaneResolver {
1626
internal static func pane(
1727
phase: ConnectionWindowPhase,
@@ -33,8 +43,8 @@ internal enum ConnectionWindowPaneResolver {
3343
}
3444
}
3545

36-
/// A sidebar and an inspector with nothing to put in them are not chrome, they are two empty
37-
/// columns that promise a session the window does not have yet.
46+
/// An object browser and an inspector with nothing to put in them are not chrome, they are two
47+
/// empty columns that promise a session the window does not have yet.
3848
internal static func hidesChrome(for pane: ConnectionWindowPane) -> Bool {
3949
switch pane {
4050
case .content:
@@ -44,6 +54,21 @@ internal enum ConnectionWindowPaneResolver {
4454
}
4555
}
4656

57+
/// How much of the window's sidebar survives the pane it is standing next to.
58+
///
59+
/// The rule above is right about the object browser and wrong about the workspace rail, which
60+
/// lists every connection the window hosts and belongs to the window rather than to any one of
61+
/// them. They share a split item because AppKit grants full-height sidebar layout to exactly one
62+
/// leading sidebar, so collapsing for an empty object browser took the switcher with it and left
63+
/// the window's other connections with no way in.
64+
internal static func sidebarChromeMode(
65+
for pane: ConnectionWindowPane,
66+
hasRail: Bool
67+
) -> SidebarChromeMode {
68+
guard hidesChrome(for: pane) else { return .revealed }
69+
return hasRail ? .railOnly : .hidden
70+
}
71+
4772
/// The tab strip's band is a list of tabs, so it appears only when there is a list worth
4873
/// showing: content behind it, and more than one tab in it. A window with a single tab keeps
4974
/// the chrome it always had, which is what the system does too.

TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,15 @@ extension MainSplitViewController: NSMenuItemValidation {
229229
}
230230
}
231231

232+
/// The workspace-rail facts come from the window in both branches. They are true of the window,
233+
/// not of the connection it happens to be showing, and reading them off a connection that has
234+
/// no coordinator left disabled the only menu route to the window's other connections.
232235
var menuValidationContext: MenuValidationContext {
233236
guard let actions = commandActions else {
234-
return MenuValidationContext(hasSelectedWorkspace: workspaces.selectedConnectionId != nil)
237+
return MenuValidationContext(
238+
hasSelectedWorkspace: workspaces.selectedConnectionId != nil,
239+
canToggleWorkspaceRail: canToggleWorkspaceRail
240+
)
235241
}
236242
return MenuValidationContext(
237243
hasSelectedWorkspace: workspaces.selectedConnectionId != nil,
@@ -259,7 +265,7 @@ extension MainSplitViewController: NSMenuItemValidation {
259265
canNavigateForward: actions.canNavigateForward,
260266
canSaveAsFavorite: actions.canSaveAsFavorite,
261267
canSwitchSidebarLayout: actions.canSwitchSidebarLayout,
262-
canToggleWorkspaceRail: actions.canToggleWorkspaceRail,
268+
canToggleWorkspaceRail: canToggleWorkspaceRail,
263269
canShowTableStructure: actions.canShowTableStructure,
264270
canEditViewDefinition: actions.canEditViewDefinition,
265271
canCreateDatabase: actions.canCreateDatabase,

TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ extension MainSplitViewController {
1010
toggleWorkspaceRail()
1111
}
1212

13+
/// Switching which connection the window shows is the window's own business, so it acts on the
14+
/// registry directly the way `toggleWorkspaceRail(_:)` already does. Routing it through the
15+
/// selected connection's `commandActions` made it a no-op exactly when it was needed: that
16+
/// connection losing its session is what leaves the others unreachable.
1317
@objc func showPreviousWorkspace(_ sender: Any?) {
14-
commandActions?.showPreviousWorkspace()
18+
activateWorkspace(offsetBy: -1)
1519
}
1620

1721
@objc func showNextWorkspace(_ sender: Any?) {
18-
commandActions?.showNextWorkspace()
22+
activateWorkspace(offsetBy: 1)
1923
}
2024

2125
@objc func setResultView(_ sender: Any?) {

TablePro/Core/Services/Infrastructure/MainSplitViewController.swift

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
104104
var tabStripObservationIsArmed = false
105105
var tabStripObservedManager: ObjectIdentifier?
106106

107-
private var chromeState: ChromeState = .unapplied
108-
109107
// MARK: - Panel Layout State
110108

111109
/// One name for the window's split view, because one `NSSplitView` can only carry one.
@@ -253,6 +251,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
253251
navigationSidebar.railController.host = self
254252
navigationSidebar.railController.onLayoutChange = { [weak self] _ in
255253
self?.navigationSidebar.applyRailWidth(animated: false)
254+
/// The row size is a setting, so the rail's own width changes under a sidebar already
255+
/// narrowed to it. Reapplying the clamp is what moves both thicknesses onto the new
256+
/// allowance rather than clipping the rail against the old one.
257+
self?.reapplySidebarClampIfNarrowed()
256258
self?.recomputeWindowMinSize()
257259
}
258260
sidebarSplitItem = NSSplitViewItem(sidebarWithViewController: navigationSidebar)
@@ -935,6 +937,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
935937
navigationSidebar.setRailVisible(visible, animated: view.window != nil) { [weak self] in
936938
self?.recomputeWindowMinSize()
937939
}
940+
/// The rail appearing or going is a chrome change of its own, and it happens without the
941+
/// selected connection's phase moving: a sibling opening or closing is enough. Without this
942+
/// a window whose connection is down keeps a fully collapsed sidebar when the rail arrives,
943+
/// or an empty clamped column after it leaves.
944+
applyPaneChrome()
938945
}
939946

940947
func activateWorkspace(offsetBy offset: Int) {
@@ -943,8 +950,25 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
943950

944951
// MARK: - Sidebar
945952

953+
/// Whether the object browser is off screen, which is the question every caller is really
954+
/// asking: the toolbar's segment, the Show/Hide Sidebar title and the reveal actions. A sidebar
955+
/// narrowed to the workspace rail is an open split item with no object browser in it, so the
956+
/// item's own flag is not the answer on its own.
946957
var isSidebarCollapsed: Bool {
947-
sidebarSplitItem?.isCollapsed ?? true
958+
guard sidebarChromeMode.showsObjectBrowser else { return true }
959+
return sidebarSplitItem?.isCollapsed ?? true
960+
}
961+
962+
var isSidebarUserCollapsible: Bool {
963+
sidebarSplitItem?.canCollapse ?? false
964+
}
965+
966+
var sidebarThicknessRange: (min: CGFloat, max: CGFloat) {
967+
(sidebarSplitItem?.minimumThickness ?? 0, sidebarSplitItem?.maximumThickness ?? 0)
968+
}
969+
970+
var railAllowance: CGFloat {
971+
navigationSidebar?.railAllowance ?? 0
948972
}
949973

950974
/// Every collapse route reaches AppKit's own `toggleSidebar(_:)`: the View menu sends the
@@ -957,13 +981,15 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
957981
}
958982

959983
func focusSidebarSearch() {
984+
guard sidebarChromeMode.showsObjectBrowser else { return }
960985
if sidebarSplitItem?.isCollapsed == true {
961986
sidebarSplitItem?.animator().isCollapsed = false
962987
}
963988
navigationSidebar.objectBrowser.focusSearchField()
964989
}
965990

966991
func presentDatabaseFilter() {
992+
guard sidebarChromeMode.showsObjectBrowser else { return }
967993
guard let connectionId = currentSession?.connection.id else { return }
968994
if sidebarSplitItem?.isCollapsed == true {
969995
sidebarSplitItem?.isCollapsed = false
@@ -981,7 +1007,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
9811007
state.databaseFilterSelected = []
9821008
}
9831009

1010+
/// Refused while the sidebar is narrowed to the workspace rail. The item is open, so the
1011+
/// collapse branch below would read it as showing and collapse it, taking the rail and every
1012+
/// route to the window's other connections with it.
9841013
func setSidebarTab(_ tab: SidebarTab) {
1014+
guard sidebarChromeMode.showsObjectBrowser else { return }
9851015
guard let connectionId = currentSession?.connection.id else { return }
9861016
let sidebarState = SharedSidebarState.forConnection(connectionId)
9871017

@@ -1062,8 +1092,12 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
10621092
)
10631093
}
10641094

1095+
/// Inert while the sidebar is clamped to the rail. Seven other call sites reach
1096+
/// `recomputeWindowMinSize`, and any of them writing the object browser's minimum back over the
1097+
/// clamp would leave a minimum above the maximum.
10651098
private func applySidebarMinimumThickness() {
10661099
guard let sidebarSplitItem else { return }
1100+
guard appliedSidebarMode ?? .revealed == .revealed else { return }
10671101
let resolved = Self.resolveSidebarMinimumThickness(
10681102
railAllowance: navigationSidebar?.railAllowance ?? 0
10691103
)
@@ -1108,52 +1142,99 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
11081142

11091143
// MARK: - Pane Chrome
11101144

1111-
private enum ChromeState {
1112-
case unapplied
1113-
case hidden
1114-
case revealed
1115-
}
1116-
11171145
private var userPaneLayout: ChromePaneLayout?
11181146

1147+
/// What this window's sidebar is currently showing, and `nil` before the first application.
1148+
/// The resolver decides the mode; this records which one has been put on screen.
1149+
private var appliedSidebarMode: SidebarChromeMode?
1150+
1151+
internal var sidebarChromeMode: SidebarChromeMode {
1152+
ConnectionWindowPaneResolver.sidebarChromeMode(
1153+
for: currentPane,
1154+
hasRail: navigationSidebar?.isRailVisible ?? false
1155+
)
1156+
}
1157+
11191158
/// A split item's collapse state is written into the autosave record, which is how the
11201159
/// inspector remembers being hidden. Collapsing the sidebar for a phase the user did not
11211160
/// choose would persist that as their layout and lose the width they set, so autosaving is
1122-
/// switched off for the whole span the chrome is hidden and switched back on to restore it.
1161+
/// switched off for the whole span the chrome is not revealed and switched back on to restore
1162+
/// it. The same is true of a clamp, which writes its narrow width into the record the way a
1163+
/// collapse writes the collapsed flag.
11231164
func applyPaneChrome() {
1124-
if ConnectionWindowPaneResolver.hidesChrome(for: currentPane) {
1125-
hideWindowChrome()
1126-
} else {
1127-
revealWindowChrome()
1128-
}
1165+
applySidebarChromeMode(sidebarChromeMode)
11291166
applyTabStripVisibility()
11301167
toolbarOwner?.managedToolbar.validateVisibleItems()
11311168
recomputeWindowMinSize()
11321169
}
11331170

1134-
private func hideWindowChrome() {
1135-
guard chromeState != .hidden else { return }
1136-
chromeState = .hidden
1171+
private func applySidebarChromeMode(_ mode: SidebarChromeMode) {
1172+
guard appliedSidebarMode != mode else { return }
1173+
let previous = appliedSidebarMode
1174+
appliedSidebarMode = mode
1175+
1176+
guard mode != .revealed else {
1177+
revealWindowChrome()
1178+
return
1179+
}
1180+
1181+
/// Captured on the way out of `revealed` and never again, because the geometry a
1182+
/// `railOnly` to `hidden` step would see is the clamp, not the width the user chose.
1183+
if previous == nil || previous == .revealed {
1184+
resignFirstResponderInsideChrome()
1185+
splitView.autosaveName = nil
1186+
userPaneLayout = ChromePaneLayout(
1187+
isSidebarCollapsed: sidebarSplitItem.isCollapsed,
1188+
isInspectorCollapsed: inspectorSplitItem.isCollapsed
1189+
)
1190+
}
11371191

1138-
resignFirstResponderInsideChrome()
1139-
splitView.autosaveName = nil
1140-
userPaneLayout = ChromePaneLayout(
1141-
isSidebarCollapsed: sidebarSplitItem.isCollapsed,
1142-
isInspectorCollapsed: inspectorSplitItem.isCollapsed
1143-
)
1144-
sidebarSplitItem.isCollapsed = true
11451192
inspectorSplitItem.isCollapsed = true
1193+
switch mode {
1194+
case .railOnly:
1195+
sidebarSplitItem.isCollapsed = false
1196+
clampSidebarToRail()
1197+
case .hidden:
1198+
releaseSidebarClamp()
1199+
sidebarSplitItem.isCollapsed = true
1200+
case .revealed:
1201+
break
1202+
}
11461203
view.window?.recalculateKeyViewLoop()
11471204
}
11481205

1149-
/// Autosaving is off while the chrome is hidden, so the record still holds what the user had.
1150-
/// AppKit will not re-apply it though: assigning an autosave name to a split view that has
1206+
/// Narrowed rather than collapsed, so the rail stays on screen while the object browser it
1207+
/// shares a split item with goes. Measured: clamping and later releasing returns the item to
1208+
/// the width the user set, but a `setPosition` while the clamp holds discards it, which is why
1209+
/// nothing else may write the sidebar's thickness for the span.
1210+
private func clampSidebarToRail() {
1211+
let allowance = navigationSidebar?.railAllowance ?? 0
1212+
sidebarSplitItem.minimumThickness = allowance
1213+
sidebarSplitItem.maximumThickness = allowance
1214+
/// A clamp is not a lock. AppKit still collapses a collapsible item on a divider
1215+
/// double-click or a drag to the edge, which no menu or toolbar validation sees, and the
1216+
/// mode is already applied so nothing would open it again.
1217+
sidebarSplitItem.canCollapse = false
1218+
}
1219+
1220+
internal func reapplySidebarClampIfNarrowed() {
1221+
guard appliedSidebarMode == .railOnly else { return }
1222+
clampSidebarToRail()
1223+
}
1224+
1225+
private func releaseSidebarClamp() {
1226+
sidebarSplitItem.canCollapse = true
1227+
sidebarSplitItem.maximumThickness = Self.sidebarMaxThickness
1228+
applySidebarMinimumThickness()
1229+
}
1230+
1231+
/// Autosaving is off while the chrome is not revealed, so the record still holds what the user
1232+
/// had. AppKit will not re-apply it though: assigning an autosave name to a split view that has
11511233
/// already laid out restores nothing. The state captured on the way in is therefore what gives
11521234
/// the panes back. Forcing the sidebar open here instead reopened a sidebar the user had
11531235
/// deliberately hidden, every time a connection dropped and came back.
11541236
private func revealWindowChrome() {
1155-
guard chromeState != .revealed else { return }
1156-
chromeState = .revealed
1237+
releaseSidebarClamp()
11571238

11581239
/// Only a reveal that follows a hide has something to put back. A first reveal is a window
11591240
/// opening on a live connection, where the panes are already where the user's autosaved

TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ extension MainWindowToolbar: NSToolbarItemValidation {
2424
let supportsServerDashboard: Bool
2525
let canNavigateBack: Bool
2626
let canNavigateForward: Bool
27+
/// Separate from `connected` because a connection that is still dialing counts as alive
28+
/// while its sidebar is narrowed to the workspace rail, and a segment that toggles an
29+
/// object browser the window is not showing has nothing to toggle. Defaulted, because a
30+
/// context built for a connected pane is describing a window that has one.
31+
var showsObjectBrowser = true
2732
}
2833

2934
/// Listed exhaustively so a new state has to choose a side instead of inheriting "alive".
@@ -46,8 +51,10 @@ extension MainWindowToolbar: NSToolbarItemValidation {
4651
return true
4752
case Self.database:
4853
return context.connected && !context.fileBased && context.supportsContainerSwitching
49-
case Self.refresh, Self.quickSwitcher, Self.newTab, Self.exportTables, Self.sidebarToggle:
54+
case Self.refresh, Self.quickSwitcher, Self.newTab, Self.exportTables:
5055
return context.connected
56+
case Self.sidebarToggle:
57+
return context.connected && context.showsObjectBrowser
5158
case Self.addRow:
5259
return context.connected && context.canAddRow
5360
case Self.restorePreviousValues:
@@ -86,10 +93,16 @@ extension MainWindowToolbar: NSToolbarItemValidation {
8693
supportsImport: PluginManager.shared.supportsImport(for: state.databaseType),
8794
supportsServerDashboard: coordinator?.commandActions?.supportsServerDashboard ?? false,
8895
canNavigateBack: coordinator?.canNavigateBack ?? false,
89-
canNavigateForward: coordinator?.canNavigateForward ?? false
96+
canNavigateForward: coordinator?.canNavigateForward ?? false,
97+
showsObjectBrowser: coordinator?.splitViewController?.sidebarChromeMode.showsObjectBrowser ?? false
9098
)
9199
}
92100

101+
/// No subject disables the whole toolbar, Switch Connection included. Every item here needs the
102+
/// coordinator that presents it, so enabling one without a subject would leave a live-looking
103+
/// button that does nothing. The routes to a window's other connections that survive a
104+
/// connection going down are the connections strip and the View menu, neither of which asks a
105+
/// coordinator anything.
93106
func validateToolbarItem(_ item: NSToolbarItem) -> Bool {
94107
guard let context = validationContext() else { return false }
95108
return Self.isEnabled(itemIdentifier: item.itemIdentifier, context: context)

TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,17 @@ internal final class SidebarContainerViewController: NSViewController {
4949
view.addSubview(hostingView)
5050
searchField.nextKeyView = hostingView
5151

52+
/// The insets are a margin, not an invariant, so they yield rather than break when the
53+
/// window narrows the sidebar to the workspace rail and leaves this view no width at all.
54+
let searchLeading = searchField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10)
55+
let searchTrailing = searchField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10)
56+
searchLeading.priority = .defaultHigh
57+
searchTrailing.priority = .defaultHigh
58+
5259
NSLayoutConstraint.activate([
5360
searchField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5),
54-
searchField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10),
55-
searchField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
61+
searchLeading,
62+
searchTrailing,
5663

5764
hostingView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 5),
5865
hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),

0 commit comments

Comments
 (0)