diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d68b9e4..45cc21718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - 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) +- 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. ## [0.51.0] - 2026-06-13 diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index d8bb5e3b4..7c360fe4c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -43,7 +43,15 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var sidebarContainer: SidebarContainerViewController! private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! - private var hasMaterializedInspector = false + + // MARK: - Panel Layout State + + private var splitAutosaveName: NSSplitView.AutosaveName { + if let connectionId = payload?.connectionId ?? currentSession?.connection.id { + return "com.TablePro.mainSplit.\(connectionId.uuidString)" + } + return "com.TablePro.mainSplit" + } // MARK: - Toolbar @@ -151,7 +159,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } if let session = resolvedSession { - self.rightPanelState = RightPanelState() + self.rightPanelState = RightPanelState(connectionId: session.connection.id) let state: SessionStateFactory.SessionState if let payloadId = payload?.id, let pending = SessionStateFactory.consumePending(for: payloadId) { @@ -184,45 +192,36 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi splitView.dividerStyle = .thin splitView.isVertical = true - splitView.autosaveName = "com.TablePro.mainSplit" sidebarContainer = SidebarContainerViewController(rootView: AnyView(buildSidebarView())) sidebarSplitItem = NSSplitViewItem(sidebarWithViewController: sidebarContainer) sidebarSplitItem.canCollapse = true - sidebarSplitItem.minimumThickness = 280 - sidebarSplitItem.maximumThickness = 600 + sidebarSplitItem.minimumThickness = Self.sidebarMinThickness + sidebarSplitItem.maximumThickness = Self.sidebarMaxThickness addSplitViewItem(sidebarSplitItem) detailHosting = NSHostingController(rootView: AnyView(buildDetailView())) detailSplitItem = NSSplitViewItem(viewController: detailHosting) - detailSplitItem.minimumThickness = 400 + detailSplitItem.minimumThickness = Self.detailMinThickness detailSplitItem.holdingPriority = .defaultLow addSplitViewItem(detailSplitItem) - let inspectorPresented = UserDefaults.standard.bool(forKey: Self.inspectorPresentedKey) - let initialInspectorContent: AnyView - if inspectorPresented { - initialInspectorContent = AnyView(buildInspectorView()) - hasMaterializedInspector = true - } else { - initialInspectorContent = AnyView(Color.clear) - } - inspectorHosting = NSHostingController(rootView: initialInspectorContent) + inspectorHosting = NSHostingController(rootView: AnyView(Color.clear)) inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting) inspectorSplitItem.canCollapse = true - inspectorSplitItem.minimumThickness = 270 + inspectorSplitItem.minimumThickness = Self.inspectorMinThickness inspectorSplitItem.maximumThickness = NSSplitViewItem.unspecifiedDimension addSplitViewItem(inspectorSplitItem) - if currentSession?.driver == nil { - sidebarSplitItem.isCollapsed = true - } else if let session = currentSession, let coordinator = sessionState?.coordinator { + splitView.autosaveName = splitAutosaveName + applyDefaultCollapseStateIfNoAutosave() + + if let session = currentSession, session.driver != nil, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(session.connection.id), windowState: coordinator.windowSidebarState ) } - inspectorSplitItem.isCollapsed = !inspectorPresented } override func splitViewDidResizeSubviews(_ notification: Notification) { @@ -230,12 +229,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi recomputeWindowMinSize() } - private func materializeInspectorIfNeeded() { - guard !hasMaterializedInspector, let inspectorHosting else { return } - hasMaterializedInspector = true - inspectorHosting.rootView = AnyView(buildInspectorView()) - } - override func viewWillAppear() { super.viewWillAppear() guard let window = view.window else { return } @@ -324,11 +317,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi sessionState = nil currentSession = nil sidebarContainer.updateSidebarState(nil, windowState: nil) - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = true - } else { - sidebarSplitItem.isCollapsed = true - } + sidebarContainer.rootView = AnyView(buildSidebarView()) } return } @@ -345,7 +334,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } if rightPanelState == nil { - rightPanelState = RightPanelState() + rightPanelState = RightPanelState(connectionId: newSession.connection.id) } if sessionState == nil { let state = SessionStateFactory.create(connection: newSession.connection, payload: payload) @@ -355,12 +344,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi installToolbar(coordinator: state.coordinator) } - let collapseSidebar = newSession.driver == nil - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = collapseSidebar - } else { - sidebarSplitItem.isCollapsed = collapseSidebar - } rebuildPanes() } @@ -525,15 +508,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } func showInspector() { - materializeInspectorIfNeeded() + inspectorHosting.rootView = AnyView(buildInspectorView()) inspectorSplitItem?.animator().isCollapsed = false - UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) recomputeWindowMinSize() } func hideInspector() { inspectorSplitItem?.animator().isCollapsed = true - UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) recomputeWindowMinSize() } @@ -572,15 +553,19 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private static let baseWindowMinWidth: CGFloat = 720 private static let baseWindowMinHeight: CGFloat = 480 + private static let sidebarMinThickness: CGFloat = 280 + 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: CGFloat = detailSplitItem?.minimumThickness ?? 400 - let sidebarMin: CGFloat = sidebarSplitItem?.minimumThickness ?? 280 - let inspectorMin: CGFloat = inspectorSplitItem?.minimumThickness ?? 270 + let detailMin = Self.detailMinThickness + let sidebarMin = Self.sidebarMinThickness + let inspectorMin = Self.inspectorMinThickness let dividerThickness = splitView.dividerThickness var width: CGFloat = detailMin @@ -612,7 +597,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } } - // MARK: - Constants + // MARK: - Panel Layout Persistence - private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" + private func applyDefaultCollapseStateIfNoAutosave() { + let key = "NSSplitView Subview Frames \(splitAutosaveName)" + guard UserDefaults.standard.object(forKey: key) == nil else { return } + inspectorSplitItem.isCollapsed = true + } } diff --git a/TablePro/Models/UI/RightPanelState.swift b/TablePro/Models/UI/RightPanelState.swift index 85c6689f5..415925f67 100644 --- a/TablePro/Models/UI/RightPanelState.swift +++ b/TablePro/Models/UI/RightPanelState.swift @@ -10,8 +10,16 @@ import os @MainActor @Observable final class RightPanelState { @ObservationIgnored private let _didTeardown = OSAllocatedUnfairLock(initialState: false) + @ObservationIgnored private let connectionId: UUID? + @ObservationIgnored private let defaults: UserDefaults + + var activeTab: RightPanelTab { + didSet { + guard let connectionId else { return } + defaults.set(activeTab.rawValue, forKey: Self.activeTabKey(connectionId)) + } + } - var activeTab: RightPanelTab = .details var inspectorContext: InspectorContext = .empty // Save closure — set by MainContentCommandActions, called by UnifiedRightPanelView @@ -27,6 +35,22 @@ import os return _aiViewModel! // swiftlint:disable:this force_unwrapping } + init(connectionId: UUID? = nil, defaults: UserDefaults = .standard) { + self.connectionId = connectionId + self.defaults = defaults + if let connectionId, + let raw = defaults.string(forKey: Self.activeTabKey(connectionId)), + let tab = RightPanelTab(rawValue: raw) { + self.activeTab = tab + } else { + self.activeTab = .details + } + } + + private static func activeTabKey(_ connectionId: UUID) -> String { + "com.TablePro.rightPanel.activeTab.\(connectionId.uuidString)" + } + /// Release all heavy data on disconnect so memory drops /// even if AppKit keeps the window alive. func teardown() { diff --git a/TableProTests/Models/RightPanelStateTests.swift b/TableProTests/Models/RightPanelStateTests.swift index 53599a52f..e0d34a176 100644 --- a/TableProTests/Models/RightPanelStateTests.swift +++ b/TableProTests/Models/RightPanelStateTests.swift @@ -6,8 +6,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("RightPanelState", .serialized) @@ -44,4 +44,50 @@ struct RightPanelStateTests { #expect(state.onSave == nil) } + + private func makeDefaults() throws -> UserDefaults { + let suite = "RightPanelStateTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return defaults + } + + @Test("active tab defaults to details when nothing stored") + @MainActor + func activeTabDefaults() throws { + let defaults = try makeDefaults() + let state = RightPanelState(connectionId: UUID(), defaults: defaults) + #expect(state.activeTab == .details) + } + + @Test("active tab round-trips per connection") + @MainActor + func activeTabRoundTrip() throws { + let defaults = try makeDefaults() + let connectionId = UUID() + let state = RightPanelState(connectionId: connectionId, defaults: defaults) + state.activeTab = .aiChat + let restored = RightPanelState(connectionId: connectionId, defaults: defaults) + #expect(restored.activeTab == .aiChat) + } + + @Test("active tab is isolated per connection") + @MainActor + func activeTabPerConnectionIsolation() throws { + let defaults = try makeDefaults() + let a = UUID() + let b = UUID() + RightPanelState(connectionId: a, defaults: defaults).activeTab = .aiChat + #expect(RightPanelState(connectionId: b, defaults: defaults).activeTab == .details) + #expect(RightPanelState(connectionId: a, defaults: defaults).activeTab == .aiChat) + } + + @Test("active tab is not persisted without a connection id") + @MainActor + func activeTabNoConnectionNotPersisted() throws { + let defaults = try makeDefaults() + let state = RightPanelState(connectionId: nil, defaults: defaults) + state.activeTab = .aiChat + #expect(defaults.dictionaryRepresentation().keys.allSatisfy { !$0.contains("rightPanel.activeTab") }) + } }