Skip to content

Commit 792428c

Browse files
committed
fix(coordinator): persist sidebar and inspector layout per connection
1 parent 8190fd2 commit 792428c

4 files changed

Lines changed: 106 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- Oracle connections no longer crash the app during connect. A short or unexpected handshake packet from the server (such as session-setup metadata or an error) now surfaces the error or continues instead of trapping. (#1683)
1717
- MongoDB filters on `_id` and other ObjectId fields now match. A 24-character hex value is matched as an ObjectId as well as a string, so filtering by `_id` returns the row instead of nothing. (#1682)
18+
- The sidebar and inspector keep their width per connection, the sidebar keeps its collapsed state, and the inspector keeps its selected tab, when you quit and reopen the app.
1819

1920
## [0.51.0] - 2026-06-13
2021

TablePro/Core/Services/Infrastructure/MainSplitViewController.swift

Lines changed: 33 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
4545
private var inspectorHosting: NSHostingController<AnyView>!
4646
private var hasMaterializedInspector = false
4747

48+
// MARK: - Panel Layout State
49+
50+
private var splitAutosaveName: NSSplitView.AutosaveName {
51+
if let connectionId = payload?.connectionId {
52+
return "com.TablePro.mainSplit.\(connectionId.uuidString)"
53+
}
54+
return "com.TablePro.mainSplit"
55+
}
56+
4857
// MARK: - Toolbar
4958

5059
private var toolbarOwner: MainWindowToolbar?
@@ -151,7 +160,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
151160
}
152161

153162
if let session = resolvedSession {
154-
self.rightPanelState = RightPanelState()
163+
self.rightPanelState = RightPanelState(connectionId: session.connection.id)
155164
let state: SessionStateFactory.SessionState
156165
if let payloadId = payload?.id,
157166
let pending = SessionStateFactory.consumePending(for: payloadId) {
@@ -184,45 +193,36 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
184193

185194
splitView.dividerStyle = .thin
186195
splitView.isVertical = true
187-
splitView.autosaveName = "com.TablePro.mainSplit"
188196

189197
sidebarContainer = SidebarContainerViewController(rootView: AnyView(buildSidebarView()))
190198
sidebarSplitItem = NSSplitViewItem(sidebarWithViewController: sidebarContainer)
191199
sidebarSplitItem.canCollapse = true
192-
sidebarSplitItem.minimumThickness = 280
193-
sidebarSplitItem.maximumThickness = 600
200+
sidebarSplitItem.minimumThickness = Self.sidebarMinThickness
201+
sidebarSplitItem.maximumThickness = Self.sidebarMaxThickness
194202
addSplitViewItem(sidebarSplitItem)
195203

196204
detailHosting = NSHostingController(rootView: AnyView(buildDetailView()))
197205
detailSplitItem = NSSplitViewItem(viewController: detailHosting)
198-
detailSplitItem.minimumThickness = 400
206+
detailSplitItem.minimumThickness = Self.detailMinThickness
199207
detailSplitItem.holdingPriority = .defaultLow
200208
addSplitViewItem(detailSplitItem)
201209

202-
let inspectorPresented = UserDefaults.standard.bool(forKey: Self.inspectorPresentedKey)
203-
let initialInspectorContent: AnyView
204-
if inspectorPresented {
205-
initialInspectorContent = AnyView(buildInspectorView())
206-
hasMaterializedInspector = true
207-
} else {
208-
initialInspectorContent = AnyView(Color.clear)
209-
}
210-
inspectorHosting = NSHostingController(rootView: initialInspectorContent)
210+
inspectorHosting = NSHostingController(rootView: AnyView(Color.clear))
211211
inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting)
212212
inspectorSplitItem.canCollapse = true
213-
inspectorSplitItem.minimumThickness = 270
213+
inspectorSplitItem.minimumThickness = Self.inspectorMinThickness
214214
inspectorSplitItem.maximumThickness = NSSplitViewItem.unspecifiedDimension
215215
addSplitViewItem(inspectorSplitItem)
216216

217-
if currentSession?.driver == nil {
218-
sidebarSplitItem.isCollapsed = true
219-
} else if let session = currentSession, let coordinator = sessionState?.coordinator {
217+
splitView.autosaveName = splitAutosaveName
218+
applyDefaultCollapseStateIfNoAutosave()
219+
220+
if let session = currentSession, session.driver != nil, let coordinator = sessionState?.coordinator {
220221
sidebarContainer.updateSidebarState(
221222
SharedSidebarState.forConnection(session.connection.id),
222223
windowState: coordinator.windowSidebarState
223224
)
224225
}
225-
inspectorSplitItem.isCollapsed = !inspectorPresented
226226
}
227227

228228
override func splitViewDidResizeSubviews(_ notification: Notification) {
@@ -324,11 +324,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
324324
sessionState = nil
325325
currentSession = nil
326326
sidebarContainer.updateSidebarState(nil, windowState: nil)
327-
if view.window?.isVisible == true {
328-
sidebarSplitItem.animator().isCollapsed = true
329-
} else {
330-
sidebarSplitItem.isCollapsed = true
331-
}
332327
}
333328
return
334329
}
@@ -345,7 +340,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
345340
}
346341

347342
if rightPanelState == nil {
348-
rightPanelState = RightPanelState()
343+
rightPanelState = RightPanelState(connectionId: newSession.connection.id)
349344
}
350345
if sessionState == nil {
351346
let state = SessionStateFactory.create(connection: newSession.connection, payload: payload)
@@ -355,12 +350,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
355350
installToolbar(coordinator: state.coordinator)
356351
}
357352

358-
let collapseSidebar = newSession.driver == nil
359-
if view.window?.isVisible == true {
360-
sidebarSplitItem.animator().isCollapsed = collapseSidebar
361-
} else {
362-
sidebarSplitItem.isCollapsed = collapseSidebar
363-
}
364353
rebuildPanes()
365354
}
366355

@@ -527,13 +516,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
527516
func showInspector() {
528517
materializeInspectorIfNeeded()
529518
inspectorSplitItem?.animator().isCollapsed = false
530-
UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey)
531519
recomputeWindowMinSize()
532520
}
533521

534522
func hideInspector() {
535523
inspectorSplitItem?.animator().isCollapsed = true
536-
UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey)
537524
recomputeWindowMinSize()
538525
}
539526

@@ -572,15 +559,19 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
572559

573560
private static let baseWindowMinWidth: CGFloat = 720
574561
private static let baseWindowMinHeight: CGFloat = 480
562+
private static let sidebarMinThickness: CGFloat = 280
563+
private static let sidebarMaxThickness: CGFloat = 600
564+
private static let detailMinThickness: CGFloat = 400
565+
private static let inspectorMinThickness: CGFloat = 270
575566

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

581-
let detailMin: CGFloat = detailSplitItem?.minimumThickness ?? 400
582-
let sidebarMin: CGFloat = sidebarSplitItem?.minimumThickness ?? 280
583-
let inspectorMin: CGFloat = inspectorSplitItem?.minimumThickness ?? 270
572+
let detailMin = Self.detailMinThickness
573+
let sidebarMin = Self.sidebarMinThickness
574+
let inspectorMin = Self.inspectorMinThickness
584575
let dividerThickness = splitView.dividerThickness
585576

586577
var width: CGFloat = detailMin
@@ -612,7 +603,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
612603
}
613604
}
614605

615-
// MARK: - Constants
606+
// MARK: - Panel Layout Persistence
616607

617-
private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented"
608+
private func applyDefaultCollapseStateIfNoAutosave() {
609+
let key = "NSSplitView Subview Frames \(splitAutosaveName)"
610+
guard UserDefaults.standard.object(forKey: key) == nil else { return }
611+
inspectorSplitItem.isCollapsed = true
612+
}
618613
}

TablePro/Models/UI/RightPanelState.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ import os
1010

1111
@MainActor @Observable final class RightPanelState {
1212
@ObservationIgnored private let _didTeardown = OSAllocatedUnfairLock(initialState: false)
13+
@ObservationIgnored private let connectionId: UUID?
14+
@ObservationIgnored private let defaults: UserDefaults
15+
16+
var activeTab: RightPanelTab {
17+
didSet {
18+
guard let connectionId else { return }
19+
defaults.set(activeTab.rawValue, forKey: Self.activeTabKey(connectionId))
20+
}
21+
}
1322

14-
var activeTab: RightPanelTab = .details
1523
var inspectorContext: InspectorContext = .empty
1624

1725
// Save closure — set by MainContentCommandActions, called by UnifiedRightPanelView
@@ -27,6 +35,22 @@ import os
2735
return _aiViewModel! // swiftlint:disable:this force_unwrapping
2836
}
2937

38+
init(connectionId: UUID? = nil, defaults: UserDefaults = .standard) {
39+
self.connectionId = connectionId
40+
self.defaults = defaults
41+
if let connectionId,
42+
let raw = defaults.string(forKey: Self.activeTabKey(connectionId)),
43+
let tab = RightPanelTab(rawValue: raw) {
44+
self.activeTab = tab
45+
} else {
46+
self.activeTab = .details
47+
}
48+
}
49+
50+
private static func activeTabKey(_ connectionId: UUID) -> String {
51+
"com.TablePro.rightPanel.activeTab.\(connectionId.uuidString)"
52+
}
53+
3054
/// Release all heavy data on disconnect so memory drops
3155
/// even if AppKit keeps the window alive.
3256
func teardown() {

TableProTests/Models/RightPanelStateTests.swift

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
//
77

88
import Foundation
9-
import TableProPluginKit
109
@testable import TablePro
10+
import TableProPluginKit
1111
import Testing
1212

1313
@Suite("RightPanelState", .serialized)
@@ -44,4 +44,50 @@ struct RightPanelStateTests {
4444

4545
#expect(state.onSave == nil)
4646
}
47+
48+
private func makeDefaults() throws -> UserDefaults {
49+
let suite = "RightPanelStateTests.\(UUID().uuidString)"
50+
let defaults = try #require(UserDefaults(suiteName: suite))
51+
defaults.removePersistentDomain(forName: suite)
52+
return defaults
53+
}
54+
55+
@Test("active tab defaults to details when nothing stored")
56+
@MainActor
57+
func activeTabDefaults() throws {
58+
let defaults = try makeDefaults()
59+
let state = RightPanelState(connectionId: UUID(), defaults: defaults)
60+
#expect(state.activeTab == .details)
61+
}
62+
63+
@Test("active tab round-trips per connection")
64+
@MainActor
65+
func activeTabRoundTrip() throws {
66+
let defaults = try makeDefaults()
67+
let connectionId = UUID()
68+
let state = RightPanelState(connectionId: connectionId, defaults: defaults)
69+
state.activeTab = .aiChat
70+
let restored = RightPanelState(connectionId: connectionId, defaults: defaults)
71+
#expect(restored.activeTab == .aiChat)
72+
}
73+
74+
@Test("active tab is isolated per connection")
75+
@MainActor
76+
func activeTabPerConnectionIsolation() throws {
77+
let defaults = try makeDefaults()
78+
let a = UUID()
79+
let b = UUID()
80+
RightPanelState(connectionId: a, defaults: defaults).activeTab = .aiChat
81+
#expect(RightPanelState(connectionId: b, defaults: defaults).activeTab == .details)
82+
#expect(RightPanelState(connectionId: a, defaults: defaults).activeTab == .aiChat)
83+
}
84+
85+
@Test("active tab is not persisted without a connection id")
86+
@MainActor
87+
func activeTabNoConnectionNotPersisted() throws {
88+
let defaults = try makeDefaults()
89+
let state = RightPanelState(connectionId: nil, defaults: defaults)
90+
state.activeTab = .aiChat
91+
#expect(defaults.dictionaryRepresentation().keys.allSatisfy { !$0.contains("rightPanel.activeTab") })
92+
}
4793
}

0 commit comments

Comments
 (0)