diff --git a/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift new file mode 100644 index 000000000..4944b060e --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainEditorWindowState.swift @@ -0,0 +1,249 @@ +// +// 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) + } + } else { + resolvedRightPanel = nil + resolvedState = nil + } + resolvedVisibility = .all + + 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 + } + 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 + } + } + + // 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..f2120fa5a 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,25 @@ 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() + + applyDefaultWindowSizeIfNeeded(window) + 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 +100,59 @@ 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. + /// 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 +169,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 +202,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 +221,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 +234,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 +248,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/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 6fcf54439..522c9dc0c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -43305,7 +43305,7 @@ } } }, - "SSH agent did not authenticate. Make sure the right key is loaded (try ssh-add -l)." : { + "SSH agent did not authenticate. Run ssh-add -l to check loaded keys." : { }, "SSH authentication failed. Check your credentials or private key." : { @@ -43489,7 +43489,7 @@ } } }, - "SSH password rejected. Check the password for this connection." : { + "SSH password rejected. Check the password and try again." : { }, "SSH Port" : { @@ -43517,7 +43517,7 @@ "SSH port must be between 1 and 65535" : { }, - "SSH private key rejected. Check the key file, passphrase, or pick a different identity." : { + "SSH private key rejected. Check the key file or passphrase." : { }, "SSH Profile" : { @@ -46517,7 +46517,7 @@ } } }, - "The previous code wasn't accepted. Wait for your authenticator to roll over and enter the new code." : { + "The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code." : { }, "The server will be accessible from other devices on your network. Authentication and TLS are enabled automatically." : { @@ -50511,7 +50511,7 @@ "Verification Code Rejected" : { }, - "Verification code rejected. The code may be wrong or expired — try a fresh code from your authenticator." : { + "Verification code rejected. Get a new code from your authenticator app and try again." : { }, "Verification Code Required" : { 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 d8210c794..7c952a5c8 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 82e95757f..f3118f05c 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -120,11 +120,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. @@ -465,7 +464,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..36484ba09 --- /dev/null +++ b/TablePro/Views/Main/MainEditorRootView.swift @@ -0,0 +1,188 @@ +// +// 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 + } + .navigationSplitViewStyle(.balanced) + .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 windowState.inspectorPresented, + 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) + } + } + } + ) + } +} 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 + } } }