From eba1eb8c64f21951b35687925d6c313aaae97de1 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: Wed, 6 May 2026 15:39:49 +0700 Subject: [PATCH 1/7] perf(window): remove forced layoutSubtreeIfNeeded + display before window appears --- .../Core/Services/Infrastructure/MainSplitViewController.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 0692a45ec..637d999b9 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -179,9 +179,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) } - sidebarContainer.view.layoutSubtreeIfNeeded() - sidebarContainer.view.display() - installObservers() } From cf20c93d9ec6719befd929d6d385867648b4a177 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: Wed, 6 May 2026 15:58:21 +0700 Subject: [PATCH 2/7] perf(window): defer inspector NSHostingController content until first expansion --- .../MainSplitViewController.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 637d999b9..f718d209c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -37,6 +37,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var sidebarContainer: SidebarContainerViewController! private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! + private var hasMaterializedInspector = false // MARK: - Toolbar @@ -130,7 +131,15 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi detailSplitItem.holdingPriority = .defaultLow addSplitViewItem(detailSplitItem) - inspectorHosting = NSHostingController(rootView: AnyView(buildInspectorView())) + 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) inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting) inspectorSplitItem.canCollapse = true inspectorSplitItem.minimumThickness = 270 @@ -145,7 +154,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi windowState: coordinator.windowSidebarState ) } - inspectorSplitItem.isCollapsed = !UserDefaults.standard.bool(forKey: Self.inspectorPresentedKey) + inspectorSplitItem.isCollapsed = !inspectorPresented + } + + private func materializeInspectorIfNeeded() { + guard !hasMaterializedInspector, let inspectorHosting else { return } + hasMaterializedInspector = true + inspectorHosting.rootView = AnyView(buildInspectorView()) } override func viewWillAppear() { @@ -441,6 +456,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } func showInspector() { + materializeInspectorIfNeeded() inspectorSplitItem?.animator().isCollapsed = false UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) } From 775f8568364f8e6fa9609a73a3e02b8304d1d743 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: Wed, 6 May 2026 16:30:06 +0700 Subject: [PATCH 3/7] refactor(window): collapse 3 NSHostingControllers to 1 via NavigationSplitView --- .../MainEditorWindowState.swift | 253 +++++++++ .../MainSplitViewController.swift | 496 ------------------ .../Infrastructure/MainWindowToolbar.swift | 27 +- .../SidebarContainerViewController.swift | 132 ----- .../Infrastructure/TabWindowController.swift | 130 ++--- .../Extensions/MainContentView+Setup.swift | 7 +- .../Main/MainContentCommandActions.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 11 +- TablePro/Views/Main/MainContentView.swift | 2 +- TablePro/Views/Main/MainEditorRootView.swift | 186 +++++++ 10 files changed, 522 insertions(+), 724 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift delete mode 100644 TablePro/Core/Services/Infrastructure/MainSplitViewController.swift delete mode 100644 TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift create mode 100644 TablePro/Views/Main/MainEditorRootView.swift diff --git a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift new file mode 100644 index 000000000..08cf66ba8 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift @@ -0,0 +1,253 @@ +// +// MainEditorWindowState.swift +// TablePro +// + +import AppKit +import Foundation +import Observation +import os +import SwiftUI + +@MainActor +@Observable +internal final class MainEditorWindowState { + private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" + + let payload: EditorTabPayload? + + var currentSession: ConnectionSession? + var sessionState: SessionStateFactory.SessionState? + var rightPanelState: RightPanelState? + var inspectorPresented: Bool + var sidebarColumnVisibility: NavigationSplitViewVisibility + var windowTitle: String + + @ObservationIgnored private var closingSessionId: UUID? + @ObservationIgnored private var connectionStatusObserver: NSObjectProtocol? + @ObservationIgnored private weak var hostWindow: NSWindow? + + init(payload: EditorTabPayload?, sessionState: SessionStateFactory.SessionState?) { + self.payload = payload + self.inspectorPresented = UserDefaults.standard.bool(forKey: Self.inspectorPresentedKey) + + let resolvedSession = MainEditorWindowState.resolveSession(payload: payload) + self.currentSession = resolvedSession + + let resolvedState: SessionStateFactory.SessionState? + let resolvedRightPanel: RightPanelState? + let resolvedVisibility: NavigationSplitViewVisibility + + if let session = resolvedSession { + resolvedRightPanel = RightPanelState() + if let payloadId = payload?.id, + let pending = SessionStateFactory.consumePending(for: payloadId) { + resolvedState = pending + Self.lifecycleLogger.info( + "[open] MainEditorWindowState consumed pending payloadId=\(payloadId, privacy: .public)" + ) + } else if let providedState = sessionState { + resolvedState = providedState + } else { + resolvedState = SessionStateFactory.create(connection: session.connection, payload: payload) + } + resolvedVisibility = .all + } else { + resolvedRightPanel = nil + resolvedState = nil + resolvedVisibility = .detailOnly + } + + self.rightPanelState = resolvedRightPanel + self.sessionState = resolvedState + self.sidebarColumnVisibility = resolvedVisibility + self.windowTitle = MainEditorWindowState.makeInitialTitle( + payload: payload, + sessionState: resolvedState + ) + } + + deinit { + if let observer = connectionStatusObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Lifecycle + + func attachWindow(_ window: NSWindow) { + hostWindow = window + window.title = windowTitle + if let session = currentSession { + window.subtitle = session.connection.name + } + installObservers() + } + + func wireCoordinatorIfNeeded() { + guard let coordinator = sessionState?.coordinator else { return } + coordinator.editorWindowState = self + } + + // MARK: - Inspector + + func showInspector() { + inspectorPresented = true + UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) + } + + func hideInspector() { + inspectorPresented = false + UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) + } + + func toggleInspector() { + if inspectorPresented { + hideInspector() + } else { + showInspector() + } + } + + // MARK: - Sidebar + + var isSidebarCollapsed: Bool { + sidebarColumnVisibility == .detailOnly + } + + func setSidebarTab(_ tab: SidebarTab) { + guard let connectionId = currentSession?.connection.id else { return } + let sidebarState = SharedSidebarState.forConnection(connectionId) + + if isSidebarCollapsed { + sidebarState.selectedSidebarTab = tab + sidebarColumnVisibility = .all + } else if sidebarState.selectedSidebarTab == tab { + sidebarColumnVisibility = .detailOnly + } else { + sidebarState.selectedSidebarTab = tab + } + } + + // MARK: - Title + + func updateWindowTitle(_ title: String) { + windowTitle = title + hostWindow?.title = title + } + + // MARK: - Connection Status + + private func installObservers() { + guard connectionStatusObserver == nil else { return } + connectionStatusObserver = NotificationCenter.default.addObserver( + forName: .connectionStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleConnectionStatusChange() + } + } + handleConnectionStatusChange() + } + + private func handleConnectionStatusChange() { + guard closingSessionId == nil else { return } + + let sessions = DatabaseManager.shared.activeSessions + let connectionId = payload?.connectionId + ?? currentSession?.id + ?? DatabaseManager.shared.currentSessionId + + guard let sid = connectionId else { + if currentSession != nil { currentSession = nil } + return + } + + guard let newSession = sessions[sid] else { + if currentSession?.id == sid { + Self.lifecycleLogger.info( + "[close] MainEditorWindowState session removed connId=\(sid, privacy: .public)" + ) + closingSessionId = sid + rightPanelState?.teardown() + rightPanelState = nil + sessionState?.coordinator.teardown() + sessionState = nil + currentSession = nil + sidebarColumnVisibility = .detailOnly + } + return + } + + if let existing = currentSession, existing.isContentViewEquivalent(to: newSession) { + return + } + currentSession = newSession + + if payload?.tableName == nil, + windowTitle == String(localized: "SQL Query") || windowTitle.hasSuffix(" Query") { + updateWindowTitle(newSession.connection.name) + } + hostWindow?.subtitle = newSession.connection.name + + if rightPanelState == nil { + rightPanelState = RightPanelState() + } + if sessionState == nil { + let state = SessionStateFactory.create(connection: newSession.connection, payload: payload) + sessionState = state + state.coordinator.editorWindowState = self + } + + sidebarColumnVisibility = .all + } + + // MARK: - Static helpers + + private static func resolveSession(payload: EditorTabPayload?) -> ConnectionSession? { + if let connectionId = payload?.connectionId { + return DatabaseManager.shared.activeSessions[connectionId] + } + if let currentId = DatabaseManager.shared.currentSessionId { + return DatabaseManager.shared.activeSessions[currentId] + } + return nil + } + + private static func makeInitialTitle( + payload: EditorTabPayload?, + sessionState: SessionStateFactory.SessionState? + ) -> String { + if payload?.tabType == .serverDashboard { + return String(localized: "Server Dashboard") + } + if payload?.tabType == .erDiagram { + return String(localized: "ER Diagram") + } + if payload?.tabType == .createTable { + return String(localized: "Create Table") + } + if payload?.tabType == .terminal { + return String(localized: "Terminal") + } + if let tabTitle = payload?.tabTitle { + return tabTitle + } + if let tableName = payload?.tableName { + return tableName + } + if payload?.intent == .newEmptyTab, + let tabTitle = sessionState?.coordinator.tabManager.selectedTab?.title { + return tabTitle + } + if let connectionId = payload?.connectionId, + let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection { + let langName = PluginManager.shared.queryLanguageName(for: connection.type) + return "\(langName) Query" + } + return String(localized: "SQL Query") + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift deleted file mode 100644 index f718d209c..000000000 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ /dev/null @@ -1,496 +0,0 @@ -// -// MainSplitViewController.swift -// TablePro -// -// NSSplitViewController replacing NavigationSplitView for native sidebar/inspector. -// Owns session state, manages three panes (sidebar, detail, inspector), and -// serves as window.contentViewController so .toggleSidebar and -// .sidebarTrackingSeparator work via the responder chain. -// - -import AppKit -import os -import SwiftUI - -@MainActor -internal final class MainSplitViewController: NSSplitViewController, InspectorVisibilityProxy { - private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - - // MARK: - Payload & Session - - let payload: EditorTabPayload? - private var currentSession: ConnectionSession? - private var sessionState: SessionStateFactory.SessionState? - private var rightPanelState: RightPanelState? - private var closingSessionId: UUID? - - var windowTitle: String { - didSet { view.window?.title = windowTitle } - } - - // MARK: - Split View Items - - private var sidebarSplitItem: NSSplitViewItem! - private var detailSplitItem: NSSplitViewItem! - private var inspectorSplitItem: NSSplitViewItem! - - private var sidebarContainer: SidebarContainerViewController! - private var detailHosting: NSHostingController! - private var inspectorHosting: NSHostingController! - private var hasMaterializedInspector = false - - // MARK: - Toolbar - - private var toolbarOwner: MainWindowToolbar? - - // MARK: - Observers - - private var connectionStatusObserver: NSObjectProtocol? - - // MARK: - Init - - init(payload: EditorTabPayload?, sessionState: SessionStateFactory.SessionState?) { - self.payload = payload - - let defaultTitle: String - if payload?.tabType == .serverDashboard { - defaultTitle = String(localized: "Server Dashboard") - } else if payload?.tabType == .erDiagram { - defaultTitle = String(localized: "ER Diagram") - } else if payload?.tabType == .createTable { - defaultTitle = String(localized: "Create Table") - } else if payload?.tabType == .terminal { - defaultTitle = String(localized: "Terminal") - } else if let tabTitle = payload?.tabTitle { - defaultTitle = tabTitle - } else if let tableName = payload?.tableName { - defaultTitle = tableName - } else if let connectionId = payload?.connectionId, - let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection { - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - defaultTitle = "\(langName) Query" - } else { - defaultTitle = String(localized: "SQL Query") - } - self.windowTitle = defaultTitle - - var resolvedSession: ConnectionSession? - if let connectionId = payload?.connectionId { - resolvedSession = DatabaseManager.shared.activeSessions[connectionId] - } else if let currentId = DatabaseManager.shared.currentSessionId { - resolvedSession = DatabaseManager.shared.activeSessions[currentId] - } - self.currentSession = resolvedSession - - if let session = resolvedSession { - self.rightPanelState = RightPanelState() - let state: SessionStateFactory.SessionState - if let payloadId = payload?.id, - let pending = SessionStateFactory.consumePending(for: payloadId) { - state = pending - Self.lifecycleLogger.info( - "[open] MainSplitVC.init consumed pending payloadId=\(payloadId, privacy: .public)" - ) - } else { - state = SessionStateFactory.create(connection: session.connection, payload: payload) - } - self.sessionState = state - if payload?.intent == .newEmptyTab, - let tabTitle = state.coordinator.tabManager.selectedTab?.title { - self.windowTitle = tabTitle - } - } - - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("MainSplitViewController does not support NSCoder init") - } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - 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 - addSplitViewItem(sidebarSplitItem) - - detailHosting = NSHostingController(rootView: AnyView(buildDetailView())) - detailSplitItem = NSSplitViewItem(viewController: detailHosting) - detailSplitItem.minimumThickness = 400 - 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) - inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting) - inspectorSplitItem.canCollapse = true - inspectorSplitItem.minimumThickness = 270 - inspectorSplitItem.maximumThickness = 400 - addSplitViewItem(inspectorSplitItem) - - if currentSession == nil { - sidebarSplitItem.isCollapsed = true - } else if let session = currentSession, let coordinator = sessionState?.coordinator { - sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(session.connection.id), - windowState: coordinator.windowSidebarState - ) - } - inspectorSplitItem.isCollapsed = !inspectorPresented - } - - 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 } - - let defaultSize = NSSize(width: 1_200, height: 800) - if window.frame.width < defaultSize.width || window.frame.height < defaultSize.height { - window.setContentSize(NSSize( - width: max(window.frame.width, defaultSize.width), - height: max(window.frame.height, defaultSize.height) - )) - window.center() - } - - window.title = windowTitle - if let session = currentSession { - window.subtitle = session.connection.name - } - - if let sessionState { - sessionState.coordinator.inspectorProxy = self - sessionState.coordinator.splitViewController = self - installToolbar(coordinator: sessionState.coordinator) - } - - if let currentSession, let coordinator = sessionState?.coordinator { - sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState - ) - } - - installObservers() - } - - override func viewDidDisappear() { - super.viewDidDisappear() - removeObservers() - } - - // MARK: - Observers - - private func installObservers() { - guard connectionStatusObserver == nil else { return } - connectionStatusObserver = NotificationCenter.default.addObserver( - forName: .connectionStatusDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.handleConnectionStatusChange() - } - } - handleConnectionStatusChange() - } - - private func removeObservers() { - if let observer = connectionStatusObserver { - NotificationCenter.default.removeObserver(observer) - connectionStatusObserver = nil - } - } - - // MARK: - Toolbar - - func installToolbar(coordinator: MainContentCoordinator) { - guard let window = view.window else { return } - if toolbarOwner == nil { - toolbarOwner = MainWindowToolbar(coordinator: coordinator) - } - if let owner = toolbarOwner, window.toolbar !== owner.managedToolbar { - window.toolbar = owner.managedToolbar - } - } - - func invalidateToolbar() { - toolbarOwner?.invalidate() - toolbarOwner = nil - } - - // MARK: - Connection Status - - private func handleConnectionStatusChange() { - guard closingSessionId == nil else { return } - - let sessions = DatabaseManager.shared.activeSessions - let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId - - guard let sid = connectionId else { - if currentSession != nil { currentSession = nil } - return - } - - guard let newSession = sessions[sid] else { - if currentSession?.id == sid { - Self.lifecycleLogger.info( - "[close] MainSplitVC session removed connId=\(sid, privacy: .public)" - ) - closingSessionId = sid - rightPanelState?.teardown() - rightPanelState = nil - sessionState?.coordinator.teardown() - sessionState = nil - currentSession = nil - sidebarContainer.updateSidebarState(nil, windowState: nil) - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = true - } else { - sidebarSplitItem.isCollapsed = true - } - } - return - } - - if let existing = currentSession, existing.isContentViewEquivalent(to: newSession) { - return - } - currentSession = newSession - - if payload?.tableName == nil, - windowTitle == String(localized: "SQL Query") || windowTitle.hasSuffix(" Query") { - windowTitle = newSession.connection.name - } - view.window?.subtitle = newSession.connection.name - - if rightPanelState == nil { - rightPanelState = RightPanelState() - } - if sessionState == nil { - let state = SessionStateFactory.create(connection: newSession.connection, payload: payload) - sessionState = state - state.coordinator.inspectorProxy = self - state.coordinator.splitViewController = self - installToolbar(coordinator: state.coordinator) - } - - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = false - } else { - sidebarSplitItem.isCollapsed = false - } - rebuildPanes() - } - - // MARK: - Pane Construction - - private func rebuildPanes() { - sidebarContainer.rootView = AnyView(buildSidebarView()) - if let currentSession, let coordinator = sessionState?.coordinator { - sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState - ) - } - detailHosting.rootView = AnyView(buildDetailView()) - inspectorHosting.rootView = AnyView(buildInspectorView()) - } - - @ViewBuilder - private func buildSidebarView() -> some View { - if let currentSession, let sessionState { - sidebarBody(currentSession: currentSession, sessionState: sessionState) - .transaction { $0.animation = nil } - } else { - Color.clear - } - } - - @ViewBuilder - private func sidebarBody( - currentSession: ConnectionSession, - sessionState: SessionStateFactory.SessionState - ) -> some View { - SidebarView( - sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), - onDoubleClick: { [weak self] table in - guard let coordinator = self?.sessionState?.coordinator else { return } - let connectionId = coordinator.connectionId - let isView = table.type == .view - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId), - let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { - if previewCoordinator.tabManager.selectedTab?.tableContext.tableName == table.name { - previewCoordinator.promotePreviewTab() - } else { - previewCoordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) - } - } else { - coordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) - } - }, - pendingTruncates: sessionPendingTruncatesBinding, - pendingDeletes: sessionPendingDeletesBinding, - tableOperationOptions: sessionTableOperationOptionsBinding, - databaseType: currentSession.connection.type, - connectionId: currentSession.connection.id, - coordinator: sessionState.coordinator - ) - } - - @ViewBuilder - private func buildDetailView() -> some View { - if let currentSession, let rightPanelState, let sessionState { - MainContentView( - connection: currentSession.connection, - payload: payload, - windowTitle: windowTitleBinding, - sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), - pendingTruncates: sessionPendingTruncatesBinding, - pendingDeletes: sessionPendingDeletesBinding, - tableOperationOptions: sessionTableOperationOptionsBinding, - rightPanelState: rightPanelState, - tabManager: sessionState.tabManager, - changeManager: sessionState.changeManager, - toolbarState: sessionState.toolbarState, - coordinator: sessionState.coordinator - ) - .transaction { $0.animation = nil } - } else { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.5) - Text("Connecting...") - .font(.headline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - @ViewBuilder - private func buildInspectorView() -> some View { - if let currentSession, let rightPanelState { - UnifiedRightPanelView( - state: rightPanelState, - connection: currentSession.connection, - tables: currentSession.tables - ) - } else { - Color.clear - } - } - - // MARK: - Session Bindings - - private func createSessionBinding( - get: @escaping (ConnectionSession) -> T, - set: @escaping (inout ConnectionSession, T) -> Void, - defaultValue: T - ) -> Binding { - Binding( - get: { [weak self] in - guard let session = self?.currentSession else { return defaultValue } - return get(session) - }, - set: { [weak self] newValue in - guard let sessionId = self?.payload?.connectionId ?? self?.currentSession?.id else { return } - Task { - DatabaseManager.shared.updateSession(sessionId) { session in - set(&session, newValue) - } - } - } - ) - } - - private var sessionPendingTruncatesBinding: Binding> { - createSessionBinding(get: { $0.pendingTruncates }, set: { $0.pendingTruncates = $1 }, defaultValue: []) - } - - private var sessionPendingDeletesBinding: Binding> { - createSessionBinding(get: { $0.pendingDeletes }, set: { $0.pendingDeletes = $1 }, defaultValue: []) - } - - private var sessionTableOperationOptionsBinding: Binding<[String: TableOperationOptions]> { - createSessionBinding(get: { $0.tableOperationOptions }, set: { $0.tableOperationOptions = $1 }, defaultValue: [:]) - } - - private var windowTitleBinding: Binding { - Binding( - get: { [weak self] in self?.windowTitle ?? "" }, - set: { [weak self] in self?.windowTitle = $0 } - ) - } - - // MARK: - InspectorVisibilityProxy - - var isInspectorVisible: Bool { - guard let inspectorSplitItem else { return false } - return !inspectorSplitItem.isCollapsed - } - - func showInspector() { - materializeInspectorIfNeeded() - inspectorSplitItem?.animator().isCollapsed = false - UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) - } - - func hideInspector() { - inspectorSplitItem?.animator().isCollapsed = true - UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) - } - - @objc override func toggleInspector(_ sender: Any?) { - toggleInspector() - } - - // MARK: - Sidebar - - var isSidebarCollapsed: Bool { - sidebarSplitItem?.isCollapsed ?? true - } - - func setSidebarTab(_ tab: SidebarTab) { - guard let connectionId = currentSession?.connection.id else { return } - let sidebarState = SharedSidebarState.forConnection(connectionId) - - if sidebarSplitItem?.isCollapsed == true { - sidebarState.selectedSidebarTab = tab - sidebarSplitItem?.animator().isCollapsed = false - } else if sidebarState.selectedSidebarTab == tab { - sidebarSplitItem?.animator().isCollapsed = true - } else { - sidebarState.selectedSidebarTab = tab - } - } - - // MARK: - Constants - - private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" -} diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 5b8b08948..fc1783e5f 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -38,7 +38,6 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { private var hostingControllers: [NSToolbarItem.Identifier: NSHostingController] = [:] private var sidebarButtons: [NSButton] = [] private var sidebarObservationTask: Task? - private var splitViewObserver: NSObjectProtocol? internal init(coordinator: MainContentCoordinator) { self.coordinator = coordinator @@ -70,10 +69,6 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { func invalidate() { sidebarObservationTask?.cancel() sidebarObservationTask = nil - if let observer = splitViewObserver { - NotificationCenter.default.removeObserver(observer) - splitViewObserver = nil - } sidebarButtons = [] hostingControllers.removeAll() coordinator = nil @@ -548,7 +543,7 @@ extension MainWindowToolbar { guard let coordinator else { return } let tabs: [SidebarTab] = [.tables, .favorites] guard sender.tag >= 0, sender.tag < tabs.count else { return } - coordinator.splitViewController?.setSidebarTab(tabs[sender.tag]) + coordinator.editorWindowState?.setSidebarTab(tabs[sender.tag]) } fileprivate func syncSidebarButtonState(coordinator: MainContentCoordinator) { @@ -556,7 +551,7 @@ extension MainWindowToolbar { let state = coordinator.toolbarState let sidebarState = SharedSidebarState.forConnection(coordinator.connectionId) let isConnected = state.connectionState == .connected || state.connectionState == .executing - let sidebarVisible = !(coordinator.splitViewController?.isSidebarCollapsed ?? true) + let sidebarVisible = !(coordinator.editorWindowState?.isSidebarCollapsed ?? true) let icons = ["list.bullet", "star"] let activeIcons = ["list.bullet", "star.fill"] @@ -573,7 +568,9 @@ extension MainWindowToolbar { fileprivate func startSidebarObservation(coordinator: MainContentCoordinator) { sidebarObservationTask?.cancel() - // Observe @Observable state changes (selected tab, connection state) + // Observe @Observable state changes (selected tab, connection state, + // sidebar column visibility). Replaces the NSSplitView resize KVO that + // was previously scoped to NSSplitViewController. sidebarObservationTask = Task { [weak self, weak coordinator] in guard let coordinator else { return } while !Task.isCancelled { @@ -582,6 +579,7 @@ extension MainWindowToolbar { withObservationTracking { _ = coordinator.toolbarState.connectionState _ = sidebarState.selectedSidebarTab + _ = coordinator.editorWindowState?.sidebarColumnVisibility } onChange: { continuation.resume() } @@ -592,18 +590,5 @@ extension MainWindowToolbar { } } } - - // Observe NSSplitView resize to catch sidebar collapse/expand from - // keyboard shortcut, drag, or any non-button path. - splitViewObserver = NotificationCenter.default.addObserver( - forName: NSSplitView.didResizeSubviewsNotification, - object: coordinator.splitViewController?.splitView, - queue: .main - ) { [weak self, weak coordinator] _ in - MainActor.assumeIsolated { - guard let self, let coordinator else { return } - self.syncSidebarButtonState(coordinator: coordinator) - } - } } } diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift deleted file mode 100644 index 390066c4c..000000000 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// SidebarContainerViewController.swift -// TablePro -// - -import AppKit -import SwiftUI - -@MainActor -internal final class SidebarContainerViewController: NSViewController { - private let searchField = NSSearchField() - private var hostingController: NSHostingController - private var sidebarState: SharedSidebarState? - private var windowState: WindowSidebarState? - private var observationGeneration = 0 - - var rootView: AnyView { - get { hostingController.rootView } - set { hostingController.rootView = newValue } - } - - init(rootView: AnyView) { - self.hostingController = NSHostingController(rootView: rootView) - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("SidebarContainerViewController does not support NSCoder init") - } - - override func loadView() { - view = NSView() - - searchField.translatesAutoresizingMaskIntoConstraints = false - searchField.placeholderString = String(localized: "Filter") - searchField.controlSize = .regular - searchField.sendsSearchStringImmediately = true - searchField.delegate = self - searchField.setAccessibilityIdentifier("sidebar-filter") - view.addSubview(searchField) - - addChild(hostingController) - let hostingView = hostingController.view - hostingView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(hostingView) - - NSLayoutConstraint.activate([ - searchField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5), - searchField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), - searchField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), - - hostingView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 5), - hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } - - func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) { - observationGeneration += 1 - self.sidebarState = state - self.windowState = windowState - guard let state, let windowState else { - searchField.isHidden = true - return - } - searchField.isHidden = false - syncFromState(state, windowState: windowState) - startObserving(state, windowState: windowState, generation: observationGeneration) - } - - private func startObserving( - _ state: SharedSidebarState, - windowState: WindowSidebarState, - generation: Int - ) { - withObservationTracking { - _ = state.searchText - _ = state.selectedSidebarTab - _ = windowState.favoritesSearchText - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self, - generation == self.observationGeneration, - let sidebarState = self.sidebarState, - let windowState = self.windowState else { return } - self.syncFromState(sidebarState, windowState: windowState) - self.startObserving(sidebarState, windowState: windowState, generation: generation) - } - } - } - - private func syncFromState(_ state: SharedSidebarState, windowState: WindowSidebarState) { - let activeText: String - let placeholder: String - switch state.selectedSidebarTab { - case .tables: - activeText = state.searchText - placeholder = String(localized: "Filter") - case .favorites: - activeText = windowState.favoritesSearchText - placeholder = String(localized: "Filter favorites") - } - - if searchField.stringValue != activeText { - searchField.stringValue = activeText - } - searchField.placeholderString = placeholder - } -} - -extension SidebarContainerViewController: NSSearchFieldDelegate { - func controlTextDidChange(_ obj: Notification) { - guard let field = obj.object as? NSSearchField else { return } - writeSearchText(field.stringValue) - } - - func searchFieldDidEndSearching(_ sender: NSSearchField) { - writeSearchText("") - } - - private func writeSearchText(_ text: String) { - guard let sidebarState else { return } - switch sidebarState.selectedSidebarTab { - case .tables: - sidebarState.searchText = text - case .favorites: - windowState?.favoritesSearchText = text - } - } -} diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index aac5df115..1a92d8710 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -2,18 +2,10 @@ // TabWindowController.swift // TablePro // -// NSWindowController for an editor-tab-window. Replaces the SwiftUI -// `WindowGroup(id: "main", for: EditorTabPayload.self)` scene. -// -// Phase 1 scope: window creation, NSHostingView installation, tabbing -// configuration. Existing MainContentView lifecycle hooks (.onAppear, -// .onDisappear, NSWindow notification observers, .userActivity) continue to -// work unchanged — this controller's job in Phase 1 is limited to replacing -// SwiftUI scene-driven window construction. -// -// Phase 2 will migrate lifecycle responsibilities (markActivated, teardown, -// userActivity, didBecomeKey/didResignKey) into NSWindowDelegate methods -// on this controller. +// NSWindowController for an editor-tab-window. Hosts a SwiftUI +// `MainEditorRootView` (NavigationSplitView + .inspector) inside a single +// NSHostingController, replacing the previous NSSplitViewController + 3 +// separate NSHostingControllers (sidebar, detail, inspector). // import AppKit @@ -46,24 +38,26 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { }() internal let payload: EditorTabPayload - - /// Stable identifier for this controller. Distinct from the - /// `MainContentView.@State windowId` used inside WindowLifecycleMonitor — - /// that one remains the authoritative per-view UUID in Phase 1. Phase 2 - /// will unify them on this controller's identifier. internal let controllerId: UUID - /// NSUserActivity published while this window is key, so Handoff and - /// other continuity flows can pick up the connection (and table, if - /// viewing one). Replaces the SwiftUI `.userActivity(...)` modifier we - /// removed in Phase 2 — `.userActivity` requires a Scene context and - /// emitted `Cannot use Scene methods for URL, NSUserActivity...` warnings - /// when used inside an `NSHostingView`. + /// Owns the SwiftUI scene's session state. Lives as long as the window. + private let windowState: MainEditorWindowState + + /// NSToolbar owner. Installed once per window when a coordinator becomes + /// available, invalidated on window close. + private var toolbarOwner: MainWindowToolbar? + + /// Observes `windowState.sessionState` so the toolbar can be installed + /// when a connection completes after window opening (e.g. cold launch + /// reconnect). + private var sessionObservationTask: Task? + private var activity: NSUserActivity? internal init(payload: EditorTabPayload, sessionState: SessionStateFactory.SessionState? = nil) { self.payload = payload self.controllerId = UUID() + self.windowState = MainEditorWindowState(payload: payload, sessionState: sessionState) let window = EditorWindow( contentRect: NSRect(x: 0, y: 0, width: 1_200, height: 800), @@ -76,38 +70,24 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.isRestorable = false window.applyAutosaveName("MainEditorWindow") window.toolbarStyle = .unified - // Hide the window title ("Query 1 / TablePro") embedded in the unified - // toolbar — otherwise it claims leading space and pushes our navigation - // items to the right of it. Tab group's tab bar already shows the same - // "Query N" label, so no information is lost. The Principal toolbar item - // continues to show connection name + DB version. window.titleVisibility = .hidden window.tabbingMode = .preferred window.tabbingIdentifier = WindowManager.tabbingIdentifier(for: payload.connectionId) window.collectionBehavior.insert([.fullScreenPrimary, .managed]) - // NSSplitViewController as contentViewController so .toggleSidebar and - // .sidebarTrackingSeparator find the split view via the responder chain. - let splitVC = MainSplitViewController(payload: payload, sessionState: sessionState) - window.contentViewController = splitVC + let hosting = NSHostingController(rootView: MainEditorRootView(windowState: self.windowState)) + window.contentViewController = hosting super.init(window: window) - // Keep the controller alive after the window closes so NSWindowDelegate - // hooks have time to run teardown. WindowManager drops its strong - // reference on willClose, which triggers dealloc. window.isReleasedWhenClosed = false - - // Become the window's delegate so didBecomeKey/didResignKey/willClose - // dispatch to methods on this controller — eliminates the global - // NotificationCenter fan-out that previously ran every ContentView - // instance's observer per focus change. window.delegate = self - // Toolbar is installed by MainSplitViewController.viewWillAppear when - // the session state is available. NSSplitViewController does not - // overwrite window.toolbar (unlike NavigationSplitView), so no KVO - // workaround is needed. + windowState.attachWindow(window) + windowState.wireCoordinatorIfNeeded() + + installToolbarIfPossible() + startSessionObservation() Self.lifecycleLogger.info( "[open] TabWindowController.init payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) controllerId=\(self.controllerId, privacy: .public) eagerToolbar=\(sessionState != nil)" @@ -119,6 +99,43 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { fatalError("TabWindowController does not support NSCoder init") } + // MARK: - Toolbar + + /// Install the NSToolbar when a coordinator is available. Idempotent. + /// Replaces the old `MainSplitViewController.installToolbar`. + private func installToolbarIfPossible() { + guard let window, let coordinator = windowState.sessionState?.coordinator else { return } + if toolbarOwner == nil { + toolbarOwner = MainWindowToolbar(coordinator: coordinator) + } + if let owner = toolbarOwner, window.toolbar !== owner.managedToolbar { + window.toolbar = owner.managedToolbar + } + } + + /// Watch `windowState.sessionState` for the case where the connection + /// completes after the window has opened (cold launch + reconnect, dock + /// menu open). Installs the toolbar once the coordinator is available. + private func startSessionObservation() { + sessionObservationTask?.cancel() + let state = windowState + sessionObservationTask = Task { [weak self] in + while !Task.isCancelled { + let hasCoordinator = state.sessionState?.coordinator != nil + if hasCoordinator { + self?.installToolbarIfPossible() + } + await withCheckedContinuation { continuation in + withObservationTracking { + _ = state.sessionState?.coordinator + } onChange: { + continuation.resume() + } + } + } + } + } + // MARK: - NSWindowDelegate func windowWillReturnFieldEditor(_ sender: NSWindow, to client: Any?) -> Any? { @@ -135,9 +152,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { Self.lifecycleLogger.debug( "[switch] windowDidBecomeKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public) connId=\(coordinator.connectionId, privacy: .public)" ) - if let splitVC = window.contentViewController as? MainSplitViewController { - splitVC.installToolbar(coordinator: coordinator) - } + installToolbarIfPossible() Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) installToolbar ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") CommandActionsRegistry.shared.current = coordinator.commandActions updateUserActivity(coordinator: coordinator) @@ -170,9 +185,10 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window = notification.object as? NSWindow else { return } Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)") - if let splitVC = window.contentViewController as? MainSplitViewController { - splitVC.invalidateToolbar() - } + toolbarOwner?.invalidate() + toolbarOwner = nil + sessionObservationTask?.cancel() + sessionObservationTask = nil let coordinator = MainContentCoordinator.coordinator(forWindow: window) coordinator?.handleWindowWillClose() @@ -188,10 +204,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { // MARK: - NSUserActivity - /// Publish (or refresh) this window's NSUserActivity. Called by - /// `windowDidBecomeKey` and by `MainContentView` when the selected tab - /// changes — only the second case is a no-op when the window isn't key - /// (Handoff only cares about the active activity). internal func refreshUserActivity() { guard let window, window.isKeyWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) @@ -205,8 +217,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { let tableName: String? = (selectedTab?.tabType == .table) ? selectedTab?.tableContext.tableName : nil let activityType = tableName != nil ? "com.TablePro.viewTable" : "com.TablePro.viewConnection" - // Recreate when the activity type flips between viewConnection and - // viewTable — NSUserActivity.activityType is immutable. if activity?.activityType != activityType { activity?.invalidate() let newActivity = NSUserActivity(activityType: activityType) @@ -221,14 +231,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { info["tableName"] = tableName } activity.userInfo = info - - // Always promote to current. Both call sites (`windowDidBecomeKey` and - // `refreshUserActivity` which guards on `window.isKeyWindow`) only - // invoke this method when the window owns Handoff. The previous - // `becomeCurrent: Bool` parameter dropped Continuity mid-session - // whenever the user switched between table and query tabs in the - // same window — the type-flip branch above invalidated the old - // activity but never promoted the replacement. activity.becomeCurrent() } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index b4344dfc5..2849aec96 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -260,9 +260,10 @@ extension MainContentView { CommandActionsRegistry.shared.current = actions } - if let splitVC = window.contentViewController as? MainSplitViewController { - splitVC.installToolbar(coordinator: coordinator) - } + // Toolbar installation is owned by `TabWindowController` now. + // It observes the coordinator's session state and installs the + // NSToolbar when one becomes available, then re-installs on every + // windowDidBecomeKey. No manual hookup needed here. MainContentView.lifecycleLogger.info( "[open] configureWindow done windowId=\(windowId, privacy: .public) tabbingId=\(resolvedId, privacy: .public) isPreview=\(isPreview) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index a9a298f84..4d4f31955 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -718,7 +718,7 @@ final class MainContentCommandActions { } func toggleRightSidebar() { - coordinator?.inspectorProxy?.toggleInspector() + coordinator?.editorWindowState?.toggleInspector() } func toggleResults() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index ec4b6fb78..4b5341bd6 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -117,11 +117,10 @@ final class MainContentCoordinator { /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. @ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate? - /// Proxy for toggling the inspector NSSplitViewItem from coordinator code - @ObservationIgnored weak var inspectorProxy: InspectorVisibilityProxy? - - /// Direct reference to split view controller for sidebar toggle - @ObservationIgnored weak var splitViewController: MainSplitViewController? + /// Window-level UI state (sidebar collapse, inspector presented, window title). + /// Replaces the old `inspectorProxy` + `splitViewController` weak references. + /// This is the SwiftUI-side state object owned by `TabWindowController`. + @ObservationIgnored weak var editorWindowState: MainEditorWindowState? /// Direct reference to this coordinator's content window, used for presenting alerts. /// Avoids NSApp.keyWindow which may return a sheet window, causing stuck dialogs. @@ -462,7 +461,7 @@ final class MainContentCoordinator { } func showAIChatPanel() { - inspectorProxy?.showInspector() + editorWindowState?.showInspector() rightPanelState?.activeTab = .aiChat } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 939265500..720f4e97a 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -389,7 +389,7 @@ struct MainContentView: View { AppSettingsManager.shared.dataGrid.autoShowInspector, tabManager.selectedTab?.tabType == .table { - coordinator.inspectorProxy?.showInspector() + coordinator.editorWindowState?.showInspector() } scheduleInspectorUpdate(lazyLoadExcludedColumns: true) }, diff --git a/TablePro/Views/Main/MainEditorRootView.swift b/TablePro/Views/Main/MainEditorRootView.swift new file mode 100644 index 000000000..3af9c58ed --- /dev/null +++ b/TablePro/Views/Main/MainEditorRootView.swift @@ -0,0 +1,186 @@ +// +// MainEditorRootView.swift +// TablePro +// + +import SwiftUI + +internal struct MainEditorRootView: View { + @Bindable var windowState: MainEditorWindowState + + var body: some View { + NavigationSplitView(columnVisibility: $windowState.sidebarColumnVisibility) { + sidebarColumn + .navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 600) + } detail: { + detailColumn + } + .inspector(isPresented: $windowState.inspectorPresented) { + inspectorColumn + .inspectorColumnWidth(min: 270, ideal: 320, max: 400) + } + } + + @ViewBuilder + private var sidebarColumn: some View { + if let session = windowState.currentSession, + let coordinator = windowState.sessionState?.coordinator { + let sidebarState = SharedSidebarState.forConnection(session.connection.id) + SidebarView( + sidebarState: sidebarState, + onDoubleClick: { table in + handleSidebarDoubleClick(table: table, coordinator: coordinator) + }, + pendingTruncates: pendingTruncatesBinding, + pendingDeletes: pendingDeletesBinding, + tableOperationOptions: tableOperationOptionsBinding, + databaseType: session.connection.type, + connectionId: session.connection.id, + coordinator: coordinator + ) + .transaction { $0.animation = nil } + .searchable( + text: sidebarSearchBinding(sidebarState: sidebarState, coordinator: coordinator), + placement: .sidebar, + prompt: sidebarSearchPrompt(sidebarState: sidebarState) + ) + } else { + Color.clear + } + } + + private func sidebarSearchBinding( + sidebarState: SharedSidebarState, + coordinator: MainContentCoordinator + ) -> Binding { + Binding( + get: { + switch sidebarState.selectedSidebarTab { + case .tables: sidebarState.searchText + case .favorites: coordinator.windowSidebarState.favoritesSearchText + } + }, + set: { newValue in + switch sidebarState.selectedSidebarTab { + case .tables: + sidebarState.searchText = newValue + case .favorites: + coordinator.windowSidebarState.favoritesSearchText = newValue + } + } + ) + } + + private func sidebarSearchPrompt(sidebarState: SharedSidebarState) -> String { + switch sidebarState.selectedSidebarTab { + case .tables: String(localized: "Filter") + case .favorites: String(localized: "Filter favorites") + } + } + + @ViewBuilder + private var detailColumn: some View { + if let session = windowState.currentSession, + let rightPanelState = windowState.rightPanelState, + let sessionState = windowState.sessionState { + MainContentView( + connection: session.connection, + payload: windowState.payload, + windowTitle: windowTitleBinding, + sidebarState: SharedSidebarState.forConnection(session.connection.id), + pendingTruncates: pendingTruncatesBinding, + pendingDeletes: pendingDeletesBinding, + tableOperationOptions: tableOperationOptionsBinding, + rightPanelState: rightPanelState, + tabManager: sessionState.tabManager, + changeManager: sessionState.changeManager, + toolbarState: sessionState.toolbarState, + coordinator: sessionState.coordinator + ) + .transaction { $0.animation = nil } + } else { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + Text("Connecting...") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var inspectorColumn: some View { + if let session = windowState.currentSession, + let rightPanelState = windowState.rightPanelState { + UnifiedRightPanelView( + state: rightPanelState, + connection: session.connection, + tables: session.tables + ) + } else { + Color.clear + } + } + + private func handleSidebarDoubleClick(table: TableInfo, coordinator: MainContentCoordinator) { + let connectionId = coordinator.connectionId + let isView = table.type == .view + if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId), + let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { + if previewCoordinator.tabManager.selectedTab?.tableContext.tableName == table.name { + previewCoordinator.promotePreviewTab() + } else { + previewCoordinator.promotePreviewTab() + coordinator.openTableTab(table.name, isView: isView) + } + } else { + coordinator.promotePreviewTab() + coordinator.openTableTab(table.name, isView: isView) + } + } + + // MARK: - Session Bindings + + private var pendingTruncatesBinding: Binding> { + sessionBinding(get: { $0.pendingTruncates }, set: { $0.pendingTruncates = $1 }, defaultValue: []) + } + + private var pendingDeletesBinding: Binding> { + sessionBinding(get: { $0.pendingDeletes }, set: { $0.pendingDeletes = $1 }, defaultValue: []) + } + + private var tableOperationOptionsBinding: Binding<[String: TableOperationOptions]> { + sessionBinding(get: { $0.tableOperationOptions }, set: { $0.tableOperationOptions = $1 }, defaultValue: [:]) + } + + private var windowTitleBinding: Binding { + Binding( + get: { windowState.windowTitle }, + set: { windowState.updateWindowTitle($0) } + ) + } + + private func sessionBinding( + get: @escaping (ConnectionSession) -> T, + set: @escaping (inout ConnectionSession, T) -> Void, + defaultValue: T + ) -> Binding { + Binding( + get: { + guard let session = windowState.currentSession else { return defaultValue } + return get(session) + }, + set: { newValue in + guard let sessionId = windowState.payload?.connectionId + ?? windowState.currentSession?.id else { return } + Task { + DatabaseManager.shared.updateSession(sessionId) { session in + set(&session, newValue) + } + } + } + ) + } +} From 32a8d67bf16c8606bbda0ad271a4de72ae3a0aa1 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: Wed, 6 May 2026 16:49:45 +0700 Subject: [PATCH 4/7] fix(window): force balanced split style and restore default 1200x800 size --- .../Infrastructure/TabWindowController.swift | 17 +++++++++++++++++ TablePro/Views/Main/MainEditorRootView.swift | 1 + 2 files changed, 18 insertions(+) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 1a92d8710..f2120fa5a 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -86,6 +86,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { windowState.attachWindow(window) windowState.wireCoordinatorIfNeeded() + applyDefaultWindowSizeIfNeeded(window) installToolbarIfPossible() startSessionObservation() @@ -99,6 +100,22 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { fatalError("TabWindowController does not support NSCoder init") } + // MARK: - Window Sizing + + /// Enforce a 1200x800 minimum content size when the autosaved frame is + /// smaller. Mirrors the previous `MainSplitViewController.viewWillAppear` + /// behavior so connection windows don't open at a tiny restored size. + private func applyDefaultWindowSizeIfNeeded(_ window: NSWindow) { + let defaultSize = NSSize(width: 1_200, height: 800) + if window.frame.width < defaultSize.width || window.frame.height < defaultSize.height { + window.setContentSize(NSSize( + width: max(window.frame.width, defaultSize.width), + height: max(window.frame.height, defaultSize.height) + )) + window.center() + } + } + // MARK: - Toolbar /// Install the NSToolbar when a coordinator is available. Idempotent. diff --git a/TablePro/Views/Main/MainEditorRootView.swift b/TablePro/Views/Main/MainEditorRootView.swift index 3af9c58ed..265833785 100644 --- a/TablePro/Views/Main/MainEditorRootView.swift +++ b/TablePro/Views/Main/MainEditorRootView.swift @@ -15,6 +15,7 @@ internal struct MainEditorRootView: View { } detail: { detailColumn } + .navigationSplitViewStyle(.balanced) .inspector(isPresented: $windowState.inspectorPresented) { inspectorColumn .inspectorColumnWidth(min: 270, ideal: 320, max: 400) From 8ff607e94a8a4d6d3034f58cf6d1bce231a3c414 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: Wed, 6 May 2026 17:05:26 +0700 Subject: [PATCH 5/7] fix(sidebar): treat .idle as loading, add material background, no flicker on connect --- .../Infrastructure/MainEditorWindowState.swift | 11 ++++++----- TablePro/Views/Sidebar/SidebarView.swift | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift index 08cf66ba8..26b95f8a5 100644 --- a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift +++ b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift @@ -52,12 +52,16 @@ internal final class MainEditorWindowState { } else { resolvedState = SessionStateFactory.create(connection: session.connection, payload: payload) } - resolvedVisibility = .all } else { resolvedRightPanel = nil resolvedState = nil - resolvedVisibility = .detailOnly } + // Always start with the sidebar column visible. Content swap from + // empty placeholder to populated SidebarView is instant, but a + // visibility transition from .detailOnly → .all triggers SwiftUI's + // column-slide animation, which exposes the underlying window + // background as a black flash during connect. + resolvedVisibility = .all self.rightPanelState = resolvedRightPanel self.sessionState = resolvedState @@ -177,7 +181,6 @@ internal final class MainEditorWindowState { sessionState?.coordinator.teardown() sessionState = nil currentSession = nil - sidebarColumnVisibility = .detailOnly } return } @@ -201,8 +204,6 @@ internal final class MainEditorWindowState { sessionState = state state.coordinator.editorWindowState = self } - - sidebarColumnVisibility = .all } // MARK: - Static helpers diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index c1c3a6f28..d5b91f6b4 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -92,6 +92,8 @@ struct SidebarView: View { } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) .onChange(of: sidebarState.searchText) { _, newValue in viewModel.searchText = newValue } @@ -126,18 +128,24 @@ struct SidebarView: View { @ViewBuilder private var tablesContent: some View { switch schemaService.state(for: connectionId) { - case .loading where tables.isEmpty: - loadingState case .failed(let message): errorState(message: message) case .loaded where !viewModel.searchText.isEmpty && filteredTables.isEmpty: noMatchState case .loaded(let allTables) where allTables.isEmpty: emptyState - case .loaded, .loading: + case .loaded: tableList - case .idle: - emptyState + case .loading, .idle: + // Both states mean "we don't yet have a confirmed table list". + // Show the loading indicator only when there are no cached + // tables to display; otherwise keep the existing tableList + // visible so a refresh doesn't flash empty content. + if tables.isEmpty { + loadingState + } else { + tableList + } } } From bfcd631225809a7385b7ea0c71475726408c5758 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: Wed, 6 May 2026 17:11:33 +0700 Subject: [PATCH 6/7] fix(sidebar): hide sidebar column until session connects (restore original behavior) --- .../Infrastructure/MainEditorWindowState.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift index 26b95f8a5..19eda8116 100644 --- a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift +++ b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift @@ -52,16 +52,15 @@ internal final class MainEditorWindowState { } else { resolvedState = SessionStateFactory.create(connection: session.connection, payload: payload) } + resolvedVisibility = .all } else { resolvedRightPanel = nil resolvedState = nil + // Hide sidebar until session connects. Mirrors the original + // `sidebarSplitItem.isCollapsed = true when currentSession == nil` + // behavior. Sidebar uncollapses in handleConnectionStatusChange. + resolvedVisibility = .detailOnly } - // Always start with the sidebar column visible. Content swap from - // empty placeholder to populated SidebarView is instant, but a - // visibility transition from .detailOnly → .all triggers SwiftUI's - // column-slide animation, which exposes the underlying window - // background as a black flash during connect. - resolvedVisibility = .all self.rightPanelState = resolvedRightPanel self.sessionState = resolvedState @@ -181,6 +180,7 @@ internal final class MainEditorWindowState { sessionState?.coordinator.teardown() sessionState = nil currentSession = nil + sidebarColumnVisibility = .detailOnly } return } @@ -204,6 +204,7 @@ internal final class MainEditorWindowState { sessionState = state state.coordinator.editorWindowState = self } + sidebarColumnVisibility = .all } // MARK: - Static helpers From 9fa338043988449a2fe02e8d77ae93adbc442d0d 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: Wed, 6 May 2026 17:16:00 +0700 Subject: [PATCH 7/7] fix(sidebar): show only after schema loaded, not just when session connects --- .../MainEditorWindowState.swift | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift index 19eda8116..1accf308d 100644 --- a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift +++ b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift @@ -27,6 +27,7 @@ internal final class MainEditorWindowState { @ObservationIgnored private var closingSessionId: UUID? @ObservationIgnored private var connectionStatusObserver: NSObjectProtocol? @ObservationIgnored private weak var hostWindow: NSWindow? + @ObservationIgnored private var schemaObservationTask: Task? init(payload: EditorTabPayload?, sessionState: SessionStateFactory.SessionState?) { self.payload = payload @@ -75,6 +76,7 @@ internal final class MainEditorWindowState { if let observer = connectionStatusObserver { NotificationCenter.default.removeObserver(observer) } + schemaObservationTask?.cancel() } // MARK: - Lifecycle @@ -86,6 +88,48 @@ internal final class MainEditorWindowState { window.subtitle = session.connection.name } installObservers() + startSchemaObservation() + } + + /// Bind sidebar column visibility to schema-loaded state. The sidebar + /// appears only after `SchemaService` reports `.loaded` for the current + /// connection, mirroring the original behavior where the sidebar's table + /// list was hidden until the first fetch completed. + private func startSchemaObservation() { + schemaObservationTask?.cancel() + schemaObservationTask = Task { [weak self] in + while !Task.isCancelled { + self?.applyVisibilityForSchemaState() + await withCheckedContinuation { continuation in + withObservationTracking { + guard let cid = self?.currentSession?.connection.id else { return } + _ = SchemaService.shared.state(for: cid) + } onChange: { + continuation.resume() + } + } + } + } + } + + private func applyVisibilityForSchemaState() { + guard let connectionId = currentSession?.connection.id else { + if sidebarColumnVisibility != .detailOnly { + sidebarColumnVisibility = .detailOnly + } + return + } + let shouldShow: Bool + switch SchemaService.shared.state(for: connectionId) { + case .loaded: + shouldShow = true + default: + shouldShow = false + } + let target: NavigationSplitViewVisibility = shouldShow ? .all : .detailOnly + if sidebarColumnVisibility != target { + sidebarColumnVisibility = target + } } func wireCoordinatorIfNeeded() { @@ -204,7 +248,9 @@ internal final class MainEditorWindowState { sessionState = state state.coordinator.editorWindowState = self } - sidebarColumnVisibility = .all + // Sidebar visibility is driven by `SchemaService` state via + // `startSchemaObservation` — it appears after schema is loaded, + // not when session is merely connected. } // MARK: - Static helpers