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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 35 additions & 46 deletions TablePro/Core/Services/Infrastructure/MainSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,15 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
private var sidebarContainer: SidebarContainerViewController!
private var detailHosting: NSHostingController<AnyView>!
private var inspectorHosting: NSHostingController<AnyView>!
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

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -184,58 +192,43 @@ 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))

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 Materialize restored inspector content

When a saved autosave layout restores the inspector as uncollapsed for an already-active connection, the pane is still initialized with Color.clear and nothing calls materializeInspectorIfNeeded(): handleConnectionStatusChange() returns early for an equivalent current session, and showInspector() is not invoked because the item is already visible. This leaves the restored inspector pane blank when opening another tab/window for a connected session that previously had the inspector open.

Useful? React with 👍 / 👎.

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) {
super.splitViewDidResizeSubviews(notification)
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 }
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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()
}

Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
26 changes: 25 additions & 1 deletion TablePro/Models/UI/RightPanelState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down
48 changes: 47 additions & 1 deletion TableProTests/Models/RightPanelStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
//

import Foundation
import TableProPluginKit
@testable import TablePro
import TableProPluginKit
import Testing

@Suite("RightPanelState", .serialized)
Expand Down Expand Up @@ -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") })
}
}
Loading