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
8 changes: 4 additions & 4 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,21 +220,21 @@ struct ContentView: View {
)
.frame(maxWidth: .infinity)

if rightPanelState.isPresented {
PanelResizeHandle(panelWidth: Bindable(rightPanelState).panelWidth)
if RightPanelVisibility.shared.isPresented {
PanelResizeHandle(panelWidth: Bindable(RightPanelVisibility.shared).panelWidth)
Divider()
UnifiedRightPanelView(
state: rightPanelState,
inspectorContext: inspectorContext,
connection: currentSession.connection,
tables: currentSession.tables
)
.frame(width: rightPanelState.panelWidth)
.frame(width: RightPanelVisibility.shared.panelWidth)
.background(Color(nsColor: .windowBackgroundColor))
.transition(.move(edge: .trailing))
}
}
.animation(.easeInOut(duration: 0.2), value: rightPanelState.isPresented)
.animation(.easeInOut(duration: 0.2), value: RightPanelVisibility.shared.isPresented)
} else {
VStack(spacing: 16) {
ProgressView()
Expand Down
57 changes: 2 additions & 55 deletions TablePro/Models/UI/RightPanelState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,16 @@
// RightPanelState.swift
// TablePro
//
// Shared state object for the right panel, owned by ContentView.
// Inspector data is now passed directly via InspectorContext instead
// of being cached here.
// Per-window state for the right panel: active tab, edit state, AI chat.
// Panel visibility and width are shared via RightPanelVisibility.
//

import Foundation
import os

@MainActor @Observable final class RightPanelState {
private static let isPresentedKey = "com.TablePro.rightPanel.isPresented"
private static let panelWidthKey = "com.TablePro.rightPanel.width"
private static let isPresentedChangedNotification = Notification.Name("com.TablePro.rightPanel.isPresentedChanged")
private var isSyncing = false

static let minWidth: CGFloat = 280
static let maxWidth: CGFloat = 500
static let defaultWidth: CGFloat = 320
@ObservationIgnored private let _didTeardown = OSAllocatedUnfairLock(initialState: false)

var isPresented: Bool {
didSet {
guard !isSyncing else { return }
UserDefaults.standard.set(isPresented, forKey: Self.isPresentedKey)
NotificationCenter.default.post(name: Self.isPresentedChangedNotification, object: self)
}
}

var panelWidth: CGFloat {
didSet {
let clamped = min(max(panelWidth, Self.minWidth), Self.maxWidth)
if panelWidth != clamped { panelWidth = clamped }
UserDefaults.standard.set(Double(clamped), forKey: Self.panelWidthKey)
}
}

var activeTab: RightPanelTab = .details

// Save closure — set by MainContentCommandActions, called by UnifiedRightPanelView
Expand All @@ -52,18 +27,6 @@ import os
return _aiViewModel! // swiftlint:disable:this force_unwrapping
}

init() {
self.isPresented = UserDefaults.standard.bool(forKey: Self.isPresentedKey)
let savedWidth = UserDefaults.standard.double(forKey: Self.panelWidthKey)
self.panelWidth = savedWidth > 0 ? min(max(savedWidth, Self.minWidth), Self.maxWidth) : Self.defaultWidth
NotificationCenter.default.addObserver(
self,
selector: #selector(handleIsPresentedChanged(_:)),
name: Self.isPresentedChangedNotification,
object: nil
)
}

/// Release all heavy data on disconnect so memory drops
/// even if AppKit keeps the window alive.
func teardown() {
Expand All @@ -72,21 +35,5 @@ import os
onSave = nil
_aiViewModel?.clearSessionData()
editState.releaseData()
NotificationCenter.default.removeObserver(self) // swiftlint:disable:this notification_center_detachment
}

deinit {
if !_didTeardown.withLock({ $0 }) {
NotificationCenter.default.removeObserver(self)
}
}

@objc private func handleIsPresentedChanged(_ notification: Notification) {
guard let sender = notification.object as? RightPanelState, sender !== self else { return }
let newValue = UserDefaults.standard.bool(forKey: Self.isPresentedKey)
guard newValue != isPresented else { return }
isSyncing = true
isPresented = newValue
isSyncing = false
}
}
39 changes: 39 additions & 0 deletions TablePro/Models/UI/RightPanelVisibility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// RightPanelVisibility.swift
// TablePro
//
// Shared visibility and width preferences for the right panel.
// Single @Observable instance shared by all windows — no NotificationCenter sync needed.
//

import Foundation

@MainActor @Observable
final class RightPanelVisibility {
static let shared = RightPanelVisibility()

static let minWidth: CGFloat = 280
static let maxWidth: CGFloat = 500
static let defaultWidth: CGFloat = 320

private static let isPresentedKey = "com.TablePro.rightPanel.isPresented"
private static let panelWidthKey = "com.TablePro.rightPanel.width"

var isPresented: Bool {
didSet { UserDefaults.standard.set(isPresented, forKey: Self.isPresentedKey) }
}

var panelWidth: CGFloat {
didSet {
let clamped = min(max(panelWidth, Self.minWidth), Self.maxWidth)
if panelWidth != clamped { panelWidth = clamped }
UserDefaults.standard.set(Double(clamped), forKey: Self.panelWidthKey)
}
}

private init() {
isPresented = UserDefaults.standard.bool(forKey: Self.isPresentedKey)
let stored = CGFloat(UserDefaults.standard.double(forKey: Self.panelWidthKey))
panelWidth = stored > 0 ? min(max(stored, Self.minWidth), Self.maxWidth) : Self.defaultWidth
}
}
2 changes: 1 addition & 1 deletion TablePro/Views/Components/PanelResizeHandle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct PanelResizeHandle: View {
isDragging = true
// Dragging left increases panel width (handle is on the leading edge)
let newWidth = panelWidth - value.translation.width
panelWidth = min(max(newWidth, RightPanelState.minWidth), RightPanelState.maxWidth)
panelWidth = min(max(newWidth, RightPanelVisibility.minWidth), RightPanelVisibility.maxWidth)
}
.onEnded { _ in
isDragging = false
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ final class MainContentCommandActions {
}

func toggleRightSidebar() {
rightPanelState.isPresented.toggle()
RightPanelVisibility.shared.isPresented.toggle()
}

func toggleResults() {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ final class MainContentCoordinator {
}

func showAIChatPanel() {
rightPanelState?.isPresented = true
RightPanelVisibility.shared.isPresented = true
rightPanelState?.activeTab = .aiChat
}

Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ struct MainContentView: View {
AppSettingsManager.shared.dataGrid.autoShowInspector,
tabManager.selectedTab?.tabType == .table
{
rightPanelState.isPresented = true
RightPanelVisibility.shared.isPresented = true
}
// Deferred: expensive inspector rebuild coalesced with other triggers
scheduleInspectorUpdate()
Expand Down
71 changes: 16 additions & 55 deletions TableProTests/Models/RightPanelStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// RightPanelStateTests.swift
// TableProTests
//
// Tests for RightPanelState persistence of isPresented via UserDefaults.
// Tests for RightPanelVisibility persistence and RightPanelState teardown.
//

import Foundation
Expand All @@ -13,72 +13,33 @@ import Testing
struct RightPanelStateTests {
private static let key = "com.TablePro.rightPanel.isPresented"

/// Yields to the main dispatch queue so deferred DispatchQueue.main.async blocks execute.
private func yieldToMainQueue() async {
await withCheckedContinuation { continuation in
DispatchQueue.main.async {
continuation.resume()
}
}
}

@Test("isPresented defaults to false when no UserDefaults value")
@MainActor
func defaultsToFalse() {
UserDefaults.standard.removeObject(forKey: Self.key)
let state = RightPanelState()
#expect(state.isPresented == false)
let visibility = RightPanelVisibility.shared
visibility.isPresented = false
#expect(visibility.isPresented == false)
}

@Test("isPresented initializes from UserDefaults when true")
@Test("isPresented persists to UserDefaults on change")
@MainActor
func initializesFromUserDefaults() {
UserDefaults.standard.set(true, forKey: Self.key)
let state = RightPanelState()
#expect(state.isPresented == true)
UserDefaults.standard.removeObject(forKey: Self.key)
}

@Test("isPresented persists to UserDefaults on change (deferred)")
@MainActor
func persistsOnChange() async {
UserDefaults.standard.removeObject(forKey: Self.key)
let state = RightPanelState()
state.isPresented = true
// UserDefaults write is deferred via DispatchQueue.main.async
await yieldToMainQueue()
func persistsOnChange() {
let visibility = RightPanelVisibility.shared
visibility.isPresented = true
#expect(UserDefaults.standard.bool(forKey: Self.key) == true)
state.isPresented = false
await yieldToMainQueue()
visibility.isPresented = false
#expect(UserDefaults.standard.bool(forKey: Self.key) == false)
UserDefaults.standard.removeObject(forKey: Self.key)
}

@Test("isPresented does not persist synchronously (deferred write)")
@Test("visibility is shared across references")
@MainActor
func doesNotPersistSynchronously() async {
UserDefaults.standard.removeObject(forKey: Self.key)
let state = RightPanelState()
state.isPresented = true
// Write is deferred — UserDefaults should still be false immediately
#expect(UserDefaults.standard.bool(forKey: Self.key) == false)
// Drain so the deferred write completes before next test
await yieldToMainQueue()
UserDefaults.standard.removeObject(forKey: Self.key)
}

@Test("new instance reads persisted state from previous instance")
@MainActor
func newInstanceReadsPersisted() async {
UserDefaults.standard.removeObject(forKey: Self.key)
let state1 = RightPanelState()
state1.isPresented = true
// Wait for deferred UserDefaults write
await yieldToMainQueue()

let state2 = RightPanelState()
#expect(state2.isPresented == true)
UserDefaults.standard.removeObject(forKey: Self.key)
func sharedInstance() {
let a = RightPanelVisibility.shared
let b = RightPanelVisibility.shared
a.isPresented = true
#expect(b.isPresented == true)
a.isPresented = false
}

@Test("teardown is idempotent - calling twice does not crash")
Expand Down
Loading