From 20ff400da67c7d1c6bf9a14e19f2b1e847821f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 7 Apr 2026 16:12:04 +0700 Subject: [PATCH] refactor: replace NotificationCenter panel sync with shared Observable singleton --- TablePro/ContentView.swift | 8 +-- TablePro/Models/UI/RightPanelState.swift | 57 +-------------- TablePro/Models/UI/RightPanelVisibility.swift | 39 ++++++++++ .../Views/Components/PanelResizeHandle.swift | 2 +- .../Main/MainContentCommandActions.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 2 +- TablePro/Views/Main/MainContentView.swift | 2 +- .../Models/RightPanelStateTests.swift | 71 +++++-------------- 8 files changed, 65 insertions(+), 118 deletions(-) create mode 100644 TablePro/Models/UI/RightPanelVisibility.swift diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index dc2909213..32d20cb52 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -220,8 +220,8 @@ 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, @@ -229,12 +229,12 @@ struct ContentView: View { 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() diff --git a/TablePro/Models/UI/RightPanelState.swift b/TablePro/Models/UI/RightPanelState.swift index ba4616988..32e4ae7b0 100644 --- a/TablePro/Models/UI/RightPanelState.swift +++ b/TablePro/Models/UI/RightPanelState.swift @@ -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 @@ -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() { @@ -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 } } diff --git a/TablePro/Models/UI/RightPanelVisibility.swift b/TablePro/Models/UI/RightPanelVisibility.swift new file mode 100644 index 000000000..39271c4f9 --- /dev/null +++ b/TablePro/Models/UI/RightPanelVisibility.swift @@ -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 + } +} diff --git a/TablePro/Views/Components/PanelResizeHandle.swift b/TablePro/Views/Components/PanelResizeHandle.swift index 6f8531c6b..168479935 100644 --- a/TablePro/Views/Components/PanelResizeHandle.swift +++ b/TablePro/Views/Components/PanelResizeHandle.swift @@ -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 diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 4352ac629..78e3a535c 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -590,7 +590,7 @@ final class MainContentCommandActions { } func toggleRightSidebar() { - rightPanelState.isPresented.toggle() + RightPanelVisibility.shared.isPresented.toggle() } func toggleResults() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 70aa81786..b1cfd3964 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -313,7 +313,7 @@ final class MainContentCoordinator { } func showAIChatPanel() { - rightPanelState?.isPresented = true + RightPanelVisibility.shared.isPresented = true rightPanelState?.activeTab = .aiChat } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 0dd5bbc5d..9c549a939 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -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() diff --git a/TableProTests/Models/RightPanelStateTests.swift b/TableProTests/Models/RightPanelStateTests.swift index eea17543e..24b803b36 100644 --- a/TableProTests/Models/RightPanelStateTests.swift +++ b/TableProTests/Models/RightPanelStateTests.swift @@ -2,7 +2,7 @@ // RightPanelStateTests.swift // TableProTests // -// Tests for RightPanelState persistence of isPresented via UserDefaults. +// Tests for RightPanelVisibility persistence and RightPanelState teardown. // import Foundation @@ -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")