diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e98d88..3fef221a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Main editor window rewritten on AppKit (`NSWindowController` + `NSToolbar`) for faster tab opens and correct lifecycle +- Toolbar layout follows Apple HIG (sidebar left, connection center, view actions right) + +### Fixed + +- Cmd+W closing the connection window instead of clearing to empty state +- ER Diagram and Server Dashboard replacing the current tab instead of opening a new one +- Welcome window stealing focus on connect, disabling Cmd+T until manual click +- Toolbar empty on second tab, menu shortcuts disabled after toolbar click + +### Added + +- Handoff via NSUserActivity + ## [0.32.1] - 2026-04-17 ### Changed diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index f5461781..92db40d2 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -325,7 +325,7 @@ extension AppDelegate { NSWindow.allowsAutomaticWindowTabbing = false } let payload = EditorTabPayload(connectionId: connection.id) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } // MARK: - Post-Connect Actions @@ -350,7 +350,7 @@ extension AppDelegate { tableName: tableName, isView: parsed.isView ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) if parsed.filterColumn != nil || parsed.filterCondition != nil { await waitForNotification(.refreshData, timeout: .seconds(3)) diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 2c24d355..0c82da8d 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -35,7 +35,7 @@ extension AppDelegate { if DatabaseManager.shared.activeSessions[connectionId]?.driver != nil { if let tableName { let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } else { for window in NSApp.windows where isMainWindow(window) { window.makeKeyAndOrderFront(nil) @@ -46,7 +46,7 @@ extension AppDelegate { } let initialPayload = EditorTabPayload(connectionId: connectionId) - WindowOpener.shared.openNativeTab(initialPayload) + WindowManager.shared.openTab(payload: initialPayload) Task { @MainActor in do { @@ -56,7 +56,7 @@ extension AppDelegate { } if let tableName { let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } catch { fileOpenLogger.error("Handoff connect failed: \(error.localizedDescription)") @@ -227,7 +227,7 @@ extension AppDelegate { if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { if let payload = makePayload?(connection.id) { - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } else { for window in NSApp.windows where isMainWindow(window) { window.makeKeyAndOrderFront(nil) @@ -243,7 +243,7 @@ extension AppDelegate { } let deeplinkPayload = EditorTabPayload(connectionId: connection.id) - WindowOpener.shared.openNativeTab(deeplinkPayload) + WindowManager.shared.openTab(payload: deeplinkPayload) Task { @MainActor in do { @@ -266,7 +266,7 @@ extension AppDelegate { window.close() } if let payload = makePayload?(connection.id) { - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } catch { fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)") diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index ae1e35a0..51af4c05 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -65,7 +65,7 @@ extension AppDelegate { @objc func newWindowForTab(_ sender: Any?) { guard let keyWindow = NSApp.keyWindow, let connectionId = MainActor.assumeIsolated({ - WindowLifecycleMonitor.shared.connectionId(fromWindow: keyWindow) + WindowLifecycleMonitor.shared.connectionId(forWindow: keyWindow) }) else { return } @@ -74,7 +74,7 @@ extension AppDelegate { intent: .newEmptyTab ) MainActor.assumeIsolated { - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } @@ -84,7 +84,7 @@ extension AppDelegate { guard let connection = connections.first(where: { $0.id == connectionId }) else { return } let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) Task { @MainActor in do { @@ -252,71 +252,34 @@ extension AppDelegate { closeWelcomeWindowIfMainExists() } - if isMainWindow(window) && !configuredWindows.contains(windowId) { - window.tabbingMode = .preferred - window.isRestorable = false - configuredWindows.insert(windowId) - - let pendingConnectionId = MainActor.assumeIsolated { - WindowOpener.shared.consumeOldestPendingConnectionId() - } - - if pendingConnectionId == nil && !isAutoReconnecting { - if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 { - return - } - window.orderOut(nil) - return - } - - if let connectionId = pendingConnectionId { - let groupAll = MainActor.assumeIsolated { AppSettingsManager.shared.tabs.groupAllConnectionTabs } - let resolvedIdentifier = WindowOpener.tabbingIdentifier(for: connectionId) - window.tabbingIdentifier = resolvedIdentifier - - if !NSWindow.allowsAutomaticWindowTabbing { - NSWindow.allowsAutomaticWindowTabbing = true - } - - let matchingWindow: NSWindow? - if groupAll { - let existingMainWindows = NSApp.windows.filter { - $0 !== window && isMainWindow($0) && $0.isVisible - } - for existing in existingMainWindows { - existing.tabbingIdentifier = resolvedIdentifier - } - matchingWindow = existingMainWindows.first - } else { - matchingWindow = NSApp.windows.first { - $0 !== window && isMainWindow($0) && $0.isVisible - && $0.tabbingIdentifier == resolvedIdentifier - } - } - if let existingWindow = matchingWindow { - let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow - targetWindow.addTabbedWindow(window, ordered: .above) - window.makeKeyAndOrderFront(nil) - } - } - } + // Phase 5: removed legacy main-window tabbing block. `WindowManager.openTab` + // now performs the tab-group merge at creation time with the correct + // ordering, and pre-marks `configuredWindows` so this method is a no-op + // for main windows. The old block consumed `WindowOpener.pendingPayloads` + // and called `addTabbedWindow` mid-`windowDidBecomeKey`, which produced + // the 200–7000 ms grace-period delay we removed in Phase 2. } @objc func windowWillClose(_ notification: Notification) { + let seq = MainContentCoordinator.nextSwitchSeq() + let t0 = Date() guard let window = notification.object as? NSWindow else { return } + let isMain = isMainWindow(window) configuredWindows.remove(ObjectIdentifier(window)) - if isMainWindow(window) { + if isMain { let remainingMainWindows = NSApp.windows.filter { $0 !== window && isMainWindow($0) && $0.isVisible }.count + windowLogger.info("[close] AppDelegate.windowWillClose seq=\(seq) isMain=true remaining=\(remainingMainWindows)") if remainingMainWindows == 0 { NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) openWelcomeWindow() } } + windowLogger.info("[close] AppDelegate.windowWillClose seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } @objc func windowDidChangeOcclusionState(_ notification: Notification) { @@ -355,7 +318,7 @@ extension AppDelegate { for connection in validConnections { let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) do { try await DatabaseManager.shared.connectToSession(connection) @@ -400,7 +363,7 @@ extension AppDelegate { Task { @MainActor [weak self] in guard let self else { return } let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) defer { self.isAutoReconnecting = false } do { diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 7b7cef60..ea33d786 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -25,6 +25,7 @@ internal extension URL { @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private static let logger = Logger(subsystem: "com.TablePro", category: "AppDelegate") + static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") /// Track windows that have been configured to avoid re-applying styles var configuredWindows = Set() diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index abddd800..d5ed5959 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -12,6 +12,7 @@ import TableProPluginKit struct ContentView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ContentView") + private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") /// Payload identifying what this native window-tab should display. /// nil = default empty query tab (first window on connection). @@ -35,10 +36,18 @@ struct ContentView: View { private let storage = ConnectionStorage.shared init(payload: EditorTabPayload?) { + let initStart = Date() + Self.lifecycleLogger.info( + "[open] ContentView.init start payloadId=\(payload?.id.uuidString ?? "nil", privacy: .public) connId=\(payload?.connectionId.uuidString ?? "nil", privacy: .public) tabType=\(String(describing: payload?.tabType), privacy: .public)" + ) 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 let tabTitle = payload?.tabTitle { defaultTitle = tabTitle } else if let tableName = payload?.tableName { @@ -65,9 +74,27 @@ struct ContentView: View { if let session = resolvedSession { _rightPanelState = State(initialValue: RightPanelState()) - let state = SessionStateFactory.create( - connection: session.connection, payload: payload - ) + let factoryStart = Date() + // Prefer the SessionState that `WindowManager.openTab` created + // eagerly (so the NSToolbar could be installed in + // `TabWindowController.init` without a flash). Fall back to + // creating one here for code paths that bypass WindowManager + // (currently none in production — kept defensively). + let state: SessionStateFactory.SessionState + if let payloadId = payload?.id, + let pending = SessionStateFactory.consumePending(for: payloadId) { + state = pending + Self.lifecycleLogger.info( + "[open] ContentView.init SessionStateFactory consumed pending payloadId=\(payloadId, privacy: .public) connId=\(session.connection.id, privacy: .public)" + ) + } else { + state = SessionStateFactory.create( + connection: session.connection, payload: payload + ) + Self.lifecycleLogger.info( + "[open] ContentView.init SessionStateFactory.create elapsedMs=\(Int(Date().timeIntervalSince(factoryStart) * 1_000)) connId=\(session.connection.id, privacy: .public)" + ) + } _sessionState = State(initialValue: state) if payload?.intent == .newEmptyTab, let tabTitle = state.coordinator.tabManager.selectedTab?.title { @@ -77,6 +104,9 @@ struct ContentView: View { _rightPanelState = State(initialValue: nil) _sessionState = State(initialValue: nil) } + Self.lifecycleLogger.info( + "[open] ContentView.init done payloadId=\(payload?.id.uuidString ?? "nil", privacy: .public) hasSession=\(resolvedSession != nil) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))" + ) } var body: some View { @@ -117,10 +147,12 @@ struct ContentView: View { rightPanelState = RightPanelState() } if sessionState == nil { + let t0 = Date() sessionState = SessionStateFactory.create( connection: session.connection, payload: payload ) + Self.lifecycleLogger.info("[open] ContentView.onChange(currentSessionId) created SessionState connId=\(session.connection.id, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } } } else { @@ -132,29 +164,10 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .connectionStatusDidChange)) { _ in handleConnectionStatusChange() } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in - // Only process notifications for our own window to avoid every - // ContentView instance re-rendering on every window focus change. - // Match by checking if the window is registered for our connectionId - // in WindowLifecycleMonitor (subtitle may not be set yet on first appear). - guard let notificationWindow = notification.object as? NSWindow, - let windowId = notificationWindow.identifier?.rawValue, - windowId == "main" || windowId.hasPrefix("main-"), - let connectionId = payload?.connectionId - else { return } - - // Verify this notification is for our window. Check WindowLifecycleMonitor - // first (reliable after onAppear registers), fall back to subtitle match - // for the brief window before registration completes. - let isOurWindow = WindowLifecycleMonitor.shared.windows(for: connectionId) - .contains(where: { $0 === notificationWindow }) - || { - guard let name = currentSession?.connection.name, !name.isEmpty else { return false } - return notificationWindow.subtitle == name - || notificationWindow.subtitle == "\(name) — Preview" - }() - guard isOurWindow else { return } - } + // Phase 2: removed global `NSWindow.didBecomeKeyNotification` observer. + // Window focus is now routed via `TabWindowController` NSWindowDelegate + // directly into `MainContentCoordinator.handleWindowDidBecomeKey`, + // eliminating the per-ContentView-instance fan-out. } // MARK: - View Components @@ -326,6 +339,7 @@ struct ContentView: View { } guard let newSession = sessions[sid] else { if currentSession?.id == sid { + Self.lifecycleLogger.info("[close] ContentView.handleConnectionStatusChange session removed connId=\(sid, privacy: .public)") closingSessionId = sid rightPanelState?.teardown() rightPanelState = nil @@ -353,10 +367,12 @@ struct ContentView: View { rightPanelState = RightPanelState() } if sessionState == nil { + let t0 = Date() sessionState = SessionStateFactory.create( connection: newSession.connection, payload: payload ) + Self.lifecycleLogger.info("[open] ContentView.handleConnectionStatusChange created SessionState connId=\(newSession.connection.id, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index dce5ff97..292ba95e 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -22,6 +22,7 @@ actor SQLSchemaProvider { private var lastLoadError: Error? private var lastRetryAttempt: Date? private let retryCooldown: TimeInterval = 30 + private var loadTask: Task? // Store a weak driver reference to avoid retaining it after disconnect (MEM-9) private weak var cachedDriver: (any DatabaseDriver)? @@ -31,23 +32,46 @@ actor SQLSchemaProvider { // MARK: - Public API - /// Load schema from the database (driver should already be connected) + /// Load schema from the database (driver should already be connected). + /// Concurrent callers await the same in-flight Task instead of firing duplicate queries. func loadSchema(using driver: DatabaseDriver, connection: DatabaseConnection? = nil) async { - guard !isLoading else { return } + if let existing = loadTask { + Self.logger.debug("[schema] loadSchema awaiting existing in-flight task") + let t0 = Date() + await existing.value + Self.logger.debug("[schema] loadSchema coalesced — awaited existing task ms=\(Int(Date().timeIntervalSince(t0) * 1_000)) tableCount=\(self.tables.count)") + return + } - // Store driver reference for later column fetching + Self.logger.info("[schema] loadSchema starting new fetch") + let t0 = Date() self.cachedDriver = driver self.connectionInfo = connection isLoading = true lastLoadError = nil - do { - tables = try await driver.fetchTables() - isLoading = false - } catch { - lastLoadError = error - isLoading = false + let task = Task { + do { + let fetched = try await driver.fetchTables() + await self.setLoadedTables(fetched) + } catch { + await self.setLoadError(error) + } } + loadTask = task + await task.value + loadTask = nil + Self.logger.info("[schema] loadSchema done ms=\(Int(Date().timeIntervalSince(t0) * 1_000)) tableCount=\(self.tables.count) error=\(self.lastLoadError != nil)") + } + + private func setLoadedTables(_ newTables: [TableInfo]) { + tables = newTables + isLoading = false + } + + private func setLoadError(_ error: Error) { + lastLoadError = error + isLoading = false } /// Get all tables diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 69812257..b14fb2b6 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -241,21 +241,43 @@ extension DatabaseManager { /// Disconnect a specific session func disconnectSession(_ sessionId: UUID) async { - guard let session = activeSessions[sessionId] else { return } + let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + guard let session = activeSessions[sessionId] else { + lifecycleLogger.info( + "[close] disconnectSession: no session found connId=\(sessionId, privacy: .public)" + ) + return + } + let totalStart = Date() + lifecycleLogger.info( + "[close] disconnectSession start connId=\(sessionId, privacy: .public) name=\(session.connection.name, privacy: .public) hasSSH=\(session.connection.resolvedSSHConfig.enabled)" + ) // Close SSH tunnel if exists if session.connection.resolvedSSHConfig.enabled { + let sshStart = Date() do { try await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id) } catch { Self.logger.warning("SSH tunnel cleanup failed for \(session.connection.name): \(error.localizedDescription)") } + lifecycleLogger.info( + "[close] disconnectSession SSH tunnel close done connId=\(sessionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(sshStart) * 1_000))" + ) } // Stop health monitoring + let hmStart = Date() await stopHealthMonitor(for: sessionId) + lifecycleLogger.info( + "[close] disconnectSession stopHealthMonitor done connId=\(sessionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(hmStart) * 1_000))" + ) + let driverStart = Date() session.driver?.disconnect() + lifecycleLogger.info( + "[close] disconnectSession driver.disconnect done connId=\(sessionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(driverStart) * 1_000))" + ) removeSessionEntry(for: sessionId) // Clean up shared schema cache for this connection @@ -274,6 +296,9 @@ extension DatabaseManager { AppSettingsStorage.shared.saveLastConnectionId(nil) } } + lifecycleLogger.info( + "[close] disconnectSession done connId=\(sessionId, privacy: .public) totalMs=\(Int(Date().timeIntervalSince(totalStart) * 1_000))" + ) } /// Disconnect all sessions diff --git a/TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift b/TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift new file mode 100644 index 00000000..d81ca5ef --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift @@ -0,0 +1,29 @@ +// +// CommandActionsRegistry.swift +// TablePro +// +// Singleton that tracks the `MainContentCommandActions` of the currently +// key main window. Exists because `@FocusedValue(\.commandActions)` is not +// reliable in our NSHostingView-hosted setup: each `NSHostingController` +// (toolbar items + main content) is its own SwiftUI scene context, and +// focus-scene-value propagation breaks once a toolbar Button takes scene +// focus. The registry is updated on `windowDidBecomeKey` from +// `TabWindowController`, then read by `AppMenuCommands` as a fallback when +// `@FocusedValue` returns nil — so menu shortcuts (Cmd+T, Cmd+1...9, etc.) +// stay live regardless of which sub-NSHostingController holds focus. +// + +import Foundation +import Observation + +@MainActor +@Observable +final class CommandActionsRegistry { + static let shared = CommandActionsRegistry() + + /// The actions belonging to the currently key main window. `nil` when the + /// key window is not a main window (welcome / connection-form / settings). + var current: MainContentCommandActions? + + private init() {} +} diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift new file mode 100644 index 00000000..4b334fe6 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -0,0 +1,507 @@ +// +// MainWindowToolbar.swift +// TablePro +// +// NSToolbar + NSToolbarDelegate for the main editor window. Replaces the +// SwiftUI `.toolbar { ... }` modifier (`TableProToolbarView.openTableToolbar`) +// which only produces a visible toolbar inside a SwiftUI WindowGroup scene. +// Under AppKit-imperative window management (TabWindowController hosting +// ContentView via NSHostingView), SwiftUI has no scene to attach its toolbar +// items to — NSToolbar must be constructed directly on NSWindow. +// +// Each item's content is still authored in SwiftUI (`NSHostingView(rootView:)`) +// so existing subviews (ConnectionStatusView, SafeModeBadgeView, popovers, +// etc.) are reused verbatim. +// + +import AppKit +import os +import SwiftUI +import TableProPluginKit + +@MainActor +internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { + private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + + /// The coordinator whose toolbar state drives every item. Held weak so a + /// closed window's delegate doesn't retain a torn-down coordinator. + private weak var coordinator: MainContentCoordinator? + + /// The NSToolbar this delegate manages. Exposed so the controller can + /// verify `window.toolbar === managedToolbar` after install — macOS may + /// silently discard an assignment made during tab-group merge. + internal let managedToolbar: NSToolbar + + /// Retain the hosting controllers — without this, NSHostingController + /// deallocs immediately and its view becomes orphaned, producing zero-size + /// items that get pushed right by flexibleSpace. + private var hostingControllers: [NSToolbarItem.Identifier: NSHostingController] = [:] + + internal init(coordinator: MainContentCoordinator) { + self.coordinator = coordinator + // Unique identifier per toolbar instance. With a shared identifier + // across tab-group members, macOS collapses them into one toolbar and + // only the first window's items render — subsequent tabs show an + // empty toolbar. + self.managedToolbar = NSToolbar(identifier: "com.TablePro.main.toolbar.\(UUID().uuidString)") + super.init() + self.managedToolbar.delegate = self + self.managedToolbar.displayMode = .iconOnly + self.managedToolbar.allowsUserCustomization = false + self.managedToolbar.autosavesConfiguration = false + // Per WWDC 2023 / Apple Music pattern: do NOT use + // `centeredItemIdentifiers` together with a right cluster that should + // justify against `inspectorTrackingSeparator`. The centered API + // anchors the principal to region center and collapses any trailing + // flex to zero — so right items end up packed just right of the + // principal instead of at the inspector edge. With plain + // `[flex, principal, flex, …rightItems, inspectorSep, inspector]` + // and NO centered identifier, the two flexes balance naturally: + // principal floats to center, right items pack against the + // inspectorTrackingSeparator (right edge). + + } + + /// Release all hosted toolbar views and sever the coordinator reference. + /// Called by TabWindowController.windowWillClose before coordinator teardown. + func invalidate() { + hostingControllers.removeAll() + coordinator = nil + } + + // MARK: - Identifiers + + private static let connection = NSToolbarItem.Identifier("com.TablePro.toolbar.connection") + private static let database = NSToolbarItem.Identifier("com.TablePro.toolbar.database") + private static let refresh = NSToolbarItem.Identifier("com.TablePro.toolbar.refresh") + private static let saveChanges = NSToolbarItem.Identifier("com.TablePro.toolbar.saveChanges") + private static let principal = NSToolbarItem.Identifier("com.TablePro.toolbar.principal") + private static let quickSwitcher = NSToolbarItem.Identifier("com.TablePro.toolbar.quickSwitcher") + private static let newTab = NSToolbarItem.Identifier("com.TablePro.toolbar.newTab") + private static let filters = NSToolbarItem.Identifier("com.TablePro.toolbar.filters") + private static let previewSQL = NSToolbarItem.Identifier("com.TablePro.toolbar.previewSQL") + private static let results = NSToolbarItem.Identifier("com.TablePro.toolbar.results") + private static let inspector = NSToolbarItem.Identifier("com.TablePro.toolbar.inspector") + private static let dashboard = NSToolbarItem.Identifier("com.TablePro.toolbar.dashboard") + private static let history = NSToolbarItem.Identifier("com.TablePro.toolbar.history") + private static let exportTables = NSToolbarItem.Identifier("com.TablePro.toolbar.export") + private static let importTables = NSToolbarItem.Identifier("com.TablePro.toolbar.import") + private static let refreshSaveGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.refreshSaveGroup") + private static let exportImportGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.exportImportGroup") + + // MARK: - NSToolbarDelegate + + internal func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + // Layout: [sidebar][sidebar sep] [left actions] [flex] [Principal] + // [flex] [right actions] [Inspector] + // + // No `inspectorTrackingSeparator`: with NSHostingView setup (no + // NSSplitViewItem with `.inspector` behavior), the separator creates + // a separate trailing region that ABSORBS flex space, leaving right + // items pinned next to the principal instead of at the right edge. + // Plain `[flex, principal, flex, …rightItems, inspector]` justifies + // right items against the inspector toggle (Apple Music-style). + [ + .toggleSidebar, + .sidebarTrackingSeparator, + Self.connection, + Self.database, + Self.refreshSaveGroup, + .flexibleSpace, + Self.principal, + .flexibleSpace, + Self.quickSwitcher, + Self.newTab, + Self.filters, + Self.previewSQL, + Self.inspector, + ] + } + + internal func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + // Default + secondary actions hidden by default. Available via menus + // and keyboard shortcuts: + // - Results toggle (Cmd+Opt+R) — contextual to query tabs only + // (invisible on table tabs, disabled with no tabs); auto-expands + // when a query produces new results, so the manual toggle is + // rarely needed. + // - Export/Import (File menu, Cmd+Shift+E/I) + // - Dashboard/History (View menu, Cmd+Y for history) + toolbarDefaultItemIdentifiers(toolbar) + [ + Self.results, + Self.exportImportGroup, + Self.dashboard, + Self.history, + ] + } + + internal func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + Self.lifecycleLogger.info( + "[open] toolbar delegate buildItem id=\(itemIdentifier.rawValue, privacy: .public) hasCoordinator=\(self.coordinator != nil)" + ) + guard let coordinator else { return nil } + + switch itemIdentifier { + case Self.connection: + return hostingItem(id: itemIdentifier, label: String(localized: "Connection"), + content: ConnectionToolbarButton(coordinator: coordinator)) + case Self.database: + return hostingItem(id: itemIdentifier, label: String(localized: "Database"), + content: DatabaseToolbarButton(coordinator: coordinator)) + case Self.refresh: + return hostingItem(id: itemIdentifier, label: String(localized: "Refresh"), + content: RefreshToolbarButton(coordinator: coordinator)) + case Self.saveChanges: + return hostingItem(id: itemIdentifier, label: String(localized: "Save Changes"), + content: SaveChangesToolbarButton(coordinator: coordinator)) + case Self.principal: + return hostingItem(id: itemIdentifier, label: "", + content: ToolbarPrincipalContent(state: coordinator.toolbarState)) + case Self.quickSwitcher: + return hostingItem(id: itemIdentifier, label: String(localized: "Quick Switcher"), + content: QuickSwitcherToolbarButton(coordinator: coordinator)) + case Self.newTab: + return hostingItem(id: itemIdentifier, label: String(localized: "New Tab"), + content: NewTabToolbarButton(coordinator: coordinator)) + case Self.filters: + return hostingItem(id: itemIdentifier, label: String(localized: "Filters"), + content: FiltersToolbarButton(coordinator: coordinator)) + case Self.previewSQL: + return hostingItem(id: itemIdentifier, label: String(localized: "Preview"), + content: PreviewSQLToolbarButton(coordinator: coordinator)) + case Self.results: + return hostingItem(id: itemIdentifier, label: String(localized: "Results"), + content: ResultsToolbarButton(coordinator: coordinator)) + case Self.inspector: + return hostingItem(id: itemIdentifier, label: String(localized: "Inspector"), + content: InspectorToolbarButton(coordinator: coordinator)) + case Self.dashboard: + return hostingItem(id: itemIdentifier, label: String(localized: "Dashboard"), + content: DashboardToolbarButton(coordinator: coordinator)) + case Self.history: + return hostingItem(id: itemIdentifier, label: String(localized: "History"), + content: HistoryToolbarButton(coordinator: coordinator)) + case Self.exportTables: + return hostingItem(id: itemIdentifier, label: String(localized: "Export"), + content: ExportToolbarButton(coordinator: coordinator)) + case Self.importTables: + return hostingItem(id: itemIdentifier, label: String(localized: "Import"), + content: ImportToolbarButton(coordinator: coordinator)) + case Self.refreshSaveGroup: + return hostingItem(id: itemIdentifier, label: String(localized: "Refresh & Save"), + content: HStack(spacing: 4) { + RefreshToolbarButton(coordinator: coordinator) + SaveChangesToolbarButton(coordinator: coordinator) + }) + case Self.exportImportGroup: + return hostingItem(id: itemIdentifier, label: String(localized: "Export & Import"), + content: HStack(spacing: 4) { + ExportToolbarButton(coordinator: coordinator) + ImportToolbarButton(coordinator: coordinator) + }) + default: + return nil + } + } + + // MARK: - Helpers + + private func hostingItem( + id: NSToolbarItem.Identifier, + label: String, + content: Content + ) -> NSToolbarItem { + let item = NSToolbarItem(itemIdentifier: id) + item.label = label + item.paletteLabel = label + // NSHostingController drives its view's `intrinsicContentSize` from the + // SwiftUI body (via `sizingOptions = .intrinsicContentSize`). A bare + // `NSHostingView` returns intrinsicContentSize = 0 for not-yet-rendered + // SwiftUI content, causing NSToolbar to collapse the item to width 0 — + // the symptom was "items all jammed to the right edge by flexibleSpace". + // + // The controller MUST be retained by us (kept in `hostingControllers`); + // otherwise it deallocs immediately and its hosted view becomes orphaned. + // + // `.focusable(false)` keeps SwiftUI from claiming "scene focus" inside + // this NSHostingController when its Button is clicked. Without it, + // each toolbar button click made @FocusedValue(\.commandActions) + // resolve from the toolbar's empty SwiftUI scene → menu shortcuts + // (Cmd+1...9, Cmd+R, etc.) became disabled until the user clicked + // back into the editor. + let controller = NSHostingController(rootView: AnyView(content.focusable(false))) + controller.sizingOptions = .intrinsicContentSize + hostingControllers[id] = controller + item.view = controller.view + return item + } +} + +// MARK: - Item SwiftUI Views +// +// Each view reads state from `coordinator.toolbarState` (@Observable → automatic +// re-render) and invokes actions via `coordinator.commandActions` (set by +// MainContentView.onAppear). SQLReviewPopover + ConnectionSwitcherPopover are +// re-used verbatim from the SwiftUI toolbar. + +private struct ConnectionToolbarButton: View { + let coordinator: MainContentCoordinator + @State private var showSwitcher = false + + var body: some View { + Button { + showSwitcher.toggle() + } label: { + Label("Connection", systemImage: "network") + } + .help(String(localized: "Switch Connection (⌘⌥C)")) + .popover(isPresented: $showSwitcher) { + ConnectionSwitcherPopover { + showSwitcher = false + } + } + .onReceive(NotificationCenter.default.publisher(for: .openConnectionSwitcher)) { _ in + showSwitcher = true + } + } +} + +private struct DatabaseToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + let supportsSwitch = PluginManager.shared.supportsDatabaseSwitching(for: state.databaseType) + Button { + coordinator.commandActions?.openDatabaseSwitcher() + } label: { + Label("Database", systemImage: "cylinder") + } + .help(String(localized: "Open Database (⌘K)")) + .disabled( + !supportsSwitch + || state.connectionState != .connected + || PluginManager.shared.connectionMode(for: state.databaseType) == .fileBased + ) + .opacity(supportsSwitch ? 1 : 0) + .allowsHitTesting(supportsSwitch) + } +} + +private struct RefreshToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + NotificationCenter.default.post(name: .refreshData, object: nil) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .help(String(localized: "Refresh (⌘R)")) + .disabled(state.connectionState != .connected) + } +} + +private struct SaveChangesToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + coordinator.commandActions?.saveChanges() + } label: { + Label("Save Changes", systemImage: "checkmark.circle.fill") + } + .help(String(localized: "Save Changes (⌘S)")) + // Match menu: also disable when read-only (safe mode blocks writes). + .disabled( + !state.hasPendingChanges + || state.connectionState != .connected + || state.safeModeLevel.blocksAllWrites + ) + .tint(.accentColor) + } +} + +private struct QuickSwitcherToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + coordinator.commandActions?.openQuickSwitcher() + } label: { + Label("Quick Switcher", systemImage: "magnifyingglass") + } + .help(String(localized: "Quick Switcher (⇧⌘O)")) + .disabled(state.connectionState != .connected) + } +} + +private struct NewTabToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + coordinator.commandActions?.newTab() + // Defensive: a new window will become key. Restore its first + // responder so AppKit's responder chain — which SwiftUI uses to + // resolve `@FocusedValue` — points back at MainContentView. + // Belt-and-suspenders for the `.focusable(false)` fix in + // `hostingItem`; covers any path where SwiftUI might still + // briefly retain scene focus on the toolbar's hosting controller. + DispatchQueue.main.async { + if let key = NSApp.keyWindow { + key.makeFirstResponder(key.contentView) + } + } + } label: { + Label("New Tab", systemImage: "plus.rectangle") + } + .help(String(localized: "New Query Tab (⌘T)")) + .disabled(state.connectionState != .connected) + } +} + +private struct FiltersToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + coordinator.commandActions?.toggleFilterPanel() + } label: { + Label("Filters", systemImage: "line.3.horizontal.decrease.circle") + } + .help(String(localized: "Toggle Filters (⇧⌘F)")) + .disabled(state.connectionState != .connected || !state.isTableTab) + } +} + +private struct PreviewSQLToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + @Bindable var state = coordinator.toolbarState + Button { + coordinator.commandActions?.previewSQL() + } label: { + let langName = PluginManager.shared.queryLanguageName(for: state.databaseType) + Label(String(format: String(localized: "Preview %@"), langName), systemImage: "eye") + } + .help(String(format: String(localized: "Preview %@ (⌘⇧P)"), PluginManager.shared.queryLanguageName(for: state.databaseType))) + .disabled(!state.hasDataPendingChanges || state.connectionState != .connected) + .popover(isPresented: $state.showSQLReviewPopover) { + SQLReviewPopover(statements: state.previewStatements, databaseType: state.databaseType) + } + } +} + +private struct ResultsToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + coordinator.commandActions?.toggleResults() + } label: { + Label( + "Results", + systemImage: state.isResultsCollapsed + ? "rectangle.bottomhalf.inset.filled" + : "rectangle.inset.filled" + ) + } + .help(String(localized: "Toggle Results (⌘⌥R)")) + .disabled(state.connectionState != .connected) + .opacity(state.isTableTab ? 0 : 1) + .allowsHitTesting(!state.isTableTab) + } +} + +private struct InspectorToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + coordinator.commandActions?.toggleRightSidebar() + } label: { + Label("Inspector", systemImage: "sidebar.trailing") + } + .help(String(localized: "Toggle Inspector (⌘⌥I)")) + .disabled(state.connectionState != .connected) + } +} + +private struct DashboardToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + let supportsDashboard = coordinator.commandActions?.supportsServerDashboard ?? false + Button { + coordinator.commandActions?.showServerDashboard() + } label: { + Label(String(localized: "Dashboard"), systemImage: "gauge.with.dots.needle.33percent") + } + .help(String(localized: "Server Dashboard")) + .disabled(state.connectionState != .connected || !supportsDashboard) + } +} + +private struct HistoryToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + Button { + coordinator.commandActions?.toggleHistoryPanel() + } label: { + Label("History", systemImage: "clock") + } + .help(String(localized: "Toggle Query History (⌘Y)")) + } +} + +private struct ExportToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + Button { + coordinator.commandActions?.exportTables() + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + .help(String(localized: "Export Data (⌘⇧E)")) + .disabled(state.connectionState != .connected) + } +} + +private struct ImportToolbarButton: View { + let coordinator: MainContentCoordinator + + var body: some View { + let state = coordinator.toolbarState + let supportsImport = PluginManager.shared.supportsImport(for: state.databaseType) + Button { + coordinator.commandActions?.importTables() + } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .help(String(localized: "Import Data (⌘⇧I)")) + .disabled( + state.connectionState != .connected + || state.safeModeLevel.blocksAllWrites + || !supportsImport + ) + .opacity(supportsImport ? 1 : 0) + .allowsHitTesting(supportsImport) + } +} diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 7ab63275..ffed7071 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -19,6 +19,26 @@ enum SessionStateFactory { let coordinator: MainContentCoordinator } + /// Hand-off registry for SessionState created eagerly by `WindowManager.openTab`. + /// `WindowManager` creates the coordinator BEFORE `TabWindowController.init` so the + /// NSToolbar can be installed synchronously in init (eliminating the toolbar flash + /// caused by lazy install via `WindowAccessor → configureWindow` after the window + /// is already on-screen). `ContentView.init` consumes the same SessionState here so + /// only one coordinator exists per window — no duplicate-tab side effects. + private static var pendingSessionStates: [UUID: SessionState] = [:] + + static func registerPending(_ state: SessionState, for payloadId: UUID) { + pendingSessionStates[payloadId] = state + } + + static func consumePending(for payloadId: UUID) -> SessionState? { + pendingSessionStates.removeValue(forKey: payloadId) + } + + static func removePending(for payloadId: UUID) { + pendingSessionStates.removeValue(forKey: payloadId) + } + static func create( connection: DatabaseConnection, payload: EditorTabPayload? diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index f2266d48..e8bc381a 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -8,6 +8,7 @@ import Foundation import Observation +import os /// Result of tab restoration from disk internal struct RestoreResult { @@ -26,6 +27,7 @@ internal struct RestoreResult { /// no isDismissing/isRestoringTabs flag state machine. @MainActor @Observable internal final class TabPersistenceCoordinator { + private static let logger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") let connectionId: UUID init(connectionId: UUID) { @@ -46,10 +48,13 @@ internal final class TabPersistenceCoordinator { let connId = connectionId let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId }) ? selectedTabId : nonPreviewTabs.first?.id + Self.logger.debug("[persist] saveNow queued tabCount=\(nonPreviewTabs.count) connId=\(connId, privacy: .public)") Task { + let t0 = Date() do { try await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: normalizedSelectedId) + Self.logger.debug("[persist] saveNow written tabCount=\(persisted.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } catch { TabDiskActor.logSaveError(connectionId: connId, error: error) } diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift new file mode 100644 index 00000000..13bd9af2 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -0,0 +1,312 @@ +// +// 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. +// + +import AppKit +import os +import SwiftUI + +/// NSWindow subclass that routes Cmd+W (performClose:) through the coordinator's +/// closeTab() instead of AppKit's default close. This ensures the last tab clears +/// to the empty "No tabs open" state instead of closing the entire window. +@MainActor +private final class EditorWindow: NSWindow { + override func performClose(_ sender: Any?) { + if let coordinator = MainContentCoordinator.coordinator(forWindow: self), + let actions = coordinator.commandActions { + actions.closeTab() + } else { + super.performClose(sender) + } + } +} + +@MainActor +internal final class TabWindowController: NSWindowController, NSWindowDelegate { + private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + + /// Payload identifying what content this window should display. + 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 + + /// Owns the NSToolbar delegate. Created lazily once the coordinator is + /// available (from MainContentView.onAppear → `installToolbar(coordinator:)`). + /// Held strongly so the delegate doesn't dealloc while the toolbar is live. + private var toolbarOwner: MainWindowToolbar? + + /// KVO observation that re-claims `window.toolbar` if anything (typically + /// SwiftUI's `NavigationSplitView`, which installs its own toolbar during + /// initial layout) replaces our managed toolbar. Without this, the user + /// sees an empty toolbar from connect until the next `windowDidBecomeKey`. + private var toolbarKVO: NSKeyValueObservation? + + /// 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`. + private var activity: NSUserActivity? + + internal init(payload: EditorTabPayload, sessionState: SessionStateFactory.SessionState? = nil) { + self.payload = payload + self.controllerId = UUID() + + let window = EditorWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_200, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("main") + window.minSize = NSSize(width: 720, height: 480) + window.isRestorable = false + 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]) + + // NSHostingView embeds SwiftUI as a plain NSView without scene semantics. + // Unlike NSHostingController, it does not bridge scene methods (no + // sceneBridgingOptions warnings) and does not force a synchronous + // content-size measurement. Layout happens lazily after orderFront. + let hosting = NSHostingView(rootView: ContentView(payload: payload)) + hosting.autoresizingMask = [.width, .height] + window.contentView = 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 + + // Install NSToolbar BEFORE WindowManager calls makeKeyAndOrderFront so + // the window's first paint already has the toolbar — eliminates the + // visible "toolbar flash" (window briefly rendered without toolbar, + // then toolbar appears). Requires the coordinator to exist now, which + // is why WindowManager pre-creates SessionState and passes it in. + // Falls back to lazy install (configureWindow / windowDidBecomeKey) if + // session isn't available yet (welcome → connect race). + if let sessionState { + let owner = MainWindowToolbar(coordinator: sessionState.coordinator) + self.toolbarOwner = owner + window.toolbar = owner.managedToolbar + startObservingToolbar(window: window, owner: owner) + } + + Self.lifecycleLogger.info( + "[open] TabWindowController.init payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) controllerId=\(self.controllerId, privacy: .public) eagerToolbar=\(sessionState != nil)" + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("TabWindowController does not support NSCoder init") + } + + // MARK: - Toolbar Installation + + /// Install NSToolbar on the window. Called once from MainContentView.onAppear + /// when the coordinator is guaranteed set up + registered. Toolbar items need + /// `coordinator.commandActions` and `coordinator.toolbarState`, both ready + /// at that point. + /// Install (or re-install) the toolbar on this controller's window. Safe + /// to call multiple times from different lifecycle triggers: + /// - If no toolbar has been installed yet: create + assign. + /// - If our previously-installed toolbar was discarded by macOS (can + /// happen during tab-group merge when called mid-transition): re-assign + /// the same managed instance to the window. + /// Both Cmd+T (via menu) and the toolbar + button path exercise different + /// lifecycle orderings; this lets either one end up with a populated + /// toolbar regardless of whether `windowDidBecomeKey` fires. + internal func installToolbar(coordinator: MainContentCoordinator) { + guard let window else { return } + if toolbarOwner == nil { + toolbarOwner = MainWindowToolbar(coordinator: coordinator) + } + guard let owner = toolbarOwner else { return } + // Synchronous assign — async dispatch caused a visible "toolbar flash" + // (window briefly rendered with no toolbar before the async block ran + // on the next runloop tick). If macOS discards the assignment during + // `addTabbedWindow`'s mid-merge, the `windowDidBecomeKey` trigger + // re-runs this method and the `window.toolbar !==` check re-assigns. + if window.toolbar !== owner.managedToolbar { + window.toolbar = owner.managedToolbar + } + startObservingToolbar(window: window, owner: owner) + } + + /// Re-claim `window.toolbar` whenever something replaces it after our + /// install. Empirically, SwiftUI's `NavigationSplitView` installs its own + /// toolbar during initial layout — overwriting what we set in + /// `configureWindow`. Without this KVO claim-back the user sees an empty + /// toolbar from connect until they cmd-tab away and back (which fires + /// `windowDidBecomeKey` and re-attaches via the `!==` check there). + private func startObservingToolbar(window: NSWindow, owner: MainWindowToolbar) { + toolbarKVO?.invalidate() + toolbarKVO = nil + toolbarKVO = window.observe(\.toolbar, options: [.new]) { [weak self] window, _ in + // KVO callbacks for AppKit properties run on the main thread; safe + // to assume isolation. Guard re-checks owner since reassigning + // `window.toolbar = owner.managedToolbar` below re-fires KVO. + MainActor.assumeIsolated { + guard let self, + let owner = self.toolbarOwner, + window.toolbar !== owner.managedToolbar + else { return } + let wasKey = window.isKeyWindow + Self.lifecycleLogger.debug( + "[switch] KVO toolbar replaced — re-claiming controllerId=\(self.controllerId, privacy: .public) wasKey=\(wasKey)" + ) + window.toolbar = owner.managedToolbar + // Reassigning `window.toolbar` mid-flight (especially during a + // SwiftUI view rebuild that happens AFTER the window became + // key — observed in the toolbar "+" button path) makes AppKit + // silently resign key with `newKeyWindow=nil`, leaving the + // app focusless and disabling all menu shortcuts (Cmd+T, + // Cmd+1...9). Restore key status if we just lost it. + if wasKey && !window.isKeyWindow { + Self.lifecycleLogger.info( + "[focus] toolbar re-claim caused key loss — re-keying controllerId=\(self.controllerId, privacy: .public)" + ) + window.makeKeyAndOrderFront(nil) + } + } + } + } + + // MARK: - NSWindowDelegate + + internal func windowDidBecomeKey(_ notification: Notification) { + let seq = MainContentCoordinator.nextSwitchSeq() + let t0 = Date() + guard let window = notification.object as? NSWindow, + let coordinator = MainContentCoordinator.coordinator(forWindow: window) + else { return } + Self.lifecycleLogger.debug( + "[switch] windowDidBecomeKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public) connId=\(coordinator.connectionId, privacy: .public)" + ) + installToolbar(coordinator: coordinator) + Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) installToolbar ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + CommandActionsRegistry.shared.current = coordinator.commandActions + updateUserActivity(coordinator: coordinator) + Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) userActivity ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + coordinator.handleWindowDidBecomeKey() + Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + } + + internal func windowDidResignKey(_ notification: Notification) { + let seq = MainContentCoordinator.nextSwitchSeq() + let t0 = Date() + guard let window = notification.object as? NSWindow, + let coordinator = MainContentCoordinator.coordinator(forWindow: window) + else { return } + Self.lifecycleLogger.debug( + "[switch] windowDidResignKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public)" + ) + activity?.resignCurrent() + coordinator.handleWindowDidResignKey() + Self.lifecycleLogger.debug("[switch] windowDidResignKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + } + + internal func windowWillClose(_ notification: Notification) { + let seq = MainContentCoordinator.nextSwitchSeq() + let t0 = Date() + guard let window = notification.object as? NSWindow else { return } + Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)") + + toolbarOwner?.invalidate() + toolbarOwner = nil + toolbarKVO?.invalidate() + toolbarKVO = nil + + let coordinator = MainContentCoordinator.coordinator(forWindow: window) + coordinator?.handleWindowWillClose() + Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) handleWindowWillClose ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + if let actions = coordinator?.commandActions, + CommandActionsRegistry.shared.current === actions { + CommandActionsRegistry.shared.current = nil + } + activity?.invalidate() + activity = nil + Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + } + + // 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) + else { return } + updateUserActivity(coordinator: coordinator) + } + + private func updateUserActivity(coordinator: MainContentCoordinator) { + let connection = coordinator.connection + let selectedTab = coordinator.tabManager.selectedTab + let tableName: String? = (selectedTab?.tabType == .table) ? selectedTab?.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) + newActivity.isEligibleForHandoff = true + activity = newActivity + } + + guard let activity else { return } + activity.title = tableName ?? connection.name + var info: [String: Any] = ["connectionId": connection.id.uuidString] + if let tableName { + 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/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index fdc6adf7..6ebeb8af 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -14,6 +14,7 @@ import OSLog @MainActor internal final class WindowLifecycleMonitor { private static let logger = Logger(subsystem: "com.TablePro", category: "WindowLifecycleMonitor") + private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") internal static let shared = WindowLifecycleMonitor() private struct Entry { @@ -41,6 +42,9 @@ internal final class WindowLifecycleMonitor { /// Register a window and start observing its willCloseNotification. internal func register(window: NSWindow, connectionId: UUID, windowId: UUID, isPreview: Bool = false) { + Self.lifecycleLogger.info( + "[open] WindowLifecycleMonitor.register windowId=\(windowId, privacy: .public) connId=\(connectionId, privacy: .public) isPreview=\(isPreview) registeredBefore=\(self.entries.count)" + ) // Remove any existing entry for this windowId to avoid duplicate observers if let existing = entries[windowId] { if existing.window !== window { @@ -120,11 +124,17 @@ internal final class WindowLifecycleMonitor { } /// Returns the connectionId associated with the given NSWindow, if registered. - internal func connectionId(fromWindow window: NSWindow) -> UUID? { + internal func connectionId(forWindow window: NSWindow) -> UUID? { purgeStaleEntries() return entries.values.first(where: { $0.window === window })?.connectionId } + /// Returns the internal windowId for a given NSWindow, if registered. + internal func windowId(forWindow window: NSWindow) -> UUID? { + purgeStaleEntries() + return entries.first(where: { $0.value.window === window })?.key + } + /// Check if any windows are registered for a connection. internal func hasWindows(for connectionId: UUID) -> Bool { purgeStaleEntries() @@ -200,10 +210,16 @@ internal final class WindowLifecycleMonitor { private func handleWindowClose(_ closedWindow: NSWindow) { guard let (windowId, entry) = entries.first(where: { $0.value.window === closedWindow }) else { + Self.lifecycleLogger.info( + "[close] handleWindowClose: unknown window (not in registry)" + ) return } let closedConnectionId = entry.connectionId + Self.lifecycleLogger.info( + "[close] willCloseNotification -> handleWindowClose windowId=\(windowId, privacy: .public) connId=\(closedConnectionId, privacy: .public)" + ) if let observer = entry.observer { NotificationCenter.default.removeObserver(observer) @@ -214,9 +230,16 @@ internal final class WindowLifecycleMonitor { let hasRemainingWindows = entries.values.contains { $0.connectionId == closedConnectionId && $0.window != nil } + Self.lifecycleLogger.info( + "[close] handleWindowClose post-remove windowId=\(windowId, privacy: .public) remainingForConn=\(hasRemainingWindows) totalEntries=\(self.entries.count)" + ) if !hasRemainingWindows { Task { + let t0 = Date() await DatabaseManager.shared.disconnectSession(closedConnectionId) + Self.lifecycleLogger.info( + "[close] (from handleWindowClose) disconnectSession done connId=\(closedConnectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" + ) } } } diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift new file mode 100644 index 00000000..d4a18eed --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -0,0 +1,203 @@ +// +// WindowManager.swift +// TablePro +// +// Imperative AppKit window management for main editor tabs. +// Phase 1 scope: create TabWindowController, install into tab group with +// correct ordering (orderFront before addTabbedWindow — avoids the synchronous +// full-tree layout that slowed the earlier prototype 4–5×), retain strong +// reference, release on willClose. +// +// In later phases WindowManager will also absorb the lookup API currently +// on WindowLifecycleMonitor (windows(for:), previewWindow(for:), etc.). +// In Phase 1, WindowLifecycleMonitor keeps that responsibility — this +// manager only owns window creation + controller lifetime. +// + +import AppKit +import os +import SwiftUI + +@MainActor +internal final class WindowManager { + private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + + internal static let shared = WindowManager() + + /// Strong refs keyed by NSWindow identity. Because + /// `NSWindow.isReleasedWhenClosed = false` on our windows, this is the + /// only owner — dropping the entry deallocates controller + window. + private var controllers: [ObjectIdentifier: TabWindowController] = [:] + private var closeObservers: [ObjectIdentifier: NSObjectProtocol] = [:] + + private init() {} + + // MARK: - Open + + /// Creates and shows a new main-editor window hosting ContentView(payload:). + /// If a sibling window with the same tabbingIdentifier is already visible, + /// the new window joins its tab group. + internal func openTab(payload: EditorTabPayload) { + let t0 = Date() + Self.lifecycleLogger.info( + "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" + ) + + // Eagerly create SessionState (coordinator + tab manager + toolbar state) + // BEFORE constructing the controller. This lets `TabWindowController.init` + // install the NSToolbar synchronously — so the window's first paint + // already has it, eliminating the toolbar-flash that occurs when the + // toolbar is installed later via `configureWindow` (which runs only + // after the window is on-screen). + // + // The same SessionState is handed off to ContentView via + // `SessionStateFactory.consumePending` so only ONE coordinator exists + // per window — no duplicate tabs. + let resolvedConnection = DatabaseManager.shared.activeSessions[payload.connectionId]?.connection + let preCreatedSessionState: SessionStateFactory.SessionState? + if let resolvedConnection { + let state = SessionStateFactory.create(connection: resolvedConnection, payload: payload) + SessionStateFactory.registerPending(state, for: payload.id) + preCreatedSessionState = state + } else { + // Connection not ready yet (welcome → connect race). Fall back to + // lazy SessionState creation inside ContentView.init + lazy toolbar + // install via configureWindow. + preCreatedSessionState = nil + } + + let controller = TabWindowController(payload: payload, sessionState: preCreatedSessionState) + guard let window = controller.window else { + Self.lifecycleLogger.error( + "[open] WindowManager.openTab failed: controller has no window payloadId=\(payload.id, privacy: .public)" + ) + // Clean up the pending state we registered above so it doesn't leak. + SessionStateFactory.removePending(for: payload.id) + return + } + + retain(controller: controller, window: window) + + // Pre-mark so AppDelegate.windowDidBecomeKey skips its tabbing-merge + // block (we do the merge here, at creation, with the correct ordering). + if let appDelegate = NSApp.delegate as? AppDelegate { + appDelegate.configuredWindows.insert(ObjectIdentifier(window)) + } + + // --- Tab-group merge, correctly ordered --- + // + // The earlier prototype called `addTabbedWindow(window, …)` before + // the window was visible. AppKit responded by synchronously flushing + // the NSHostingView's SwiftUI layout (NavigationSplitView + editor + + // TreeSitterClient warmup) on the main thread — observed cost + // 800–960 ms per open. + // + // Ordering `orderFront(nil)` first makes the window visible and lets + // SwiftUI render asynchronously via its normal display cycle. Then + // `addTabbedWindow` re-parents an already-visible window into the + // tab group, which is a cheap AppKit-level operation. + let tabbingId = window.tabbingIdentifier ?? "" + let groupAll = AppSettingsManager.shared.tabs.groupAllConnectionTabs + let sibling = findSibling( + tabbingIdentifier: tabbingId, groupAll: groupAll, excluding: window + ) + + if let sibling { + // Tab-merge: `addTabbedWindow(_:ordered:)` both adds the window to + // the group AND orders it — calling orderFront separately beforehand + // triggers a redundant layout pass on NSHostingView (observed cost + // 700-900ms vs. 75ms standalone). Let addTabbedWindow do both at once. + if groupAll { + // groupAll mode: retag every visible main window with the unified + // identifier so addTabbedWindow is willing to merge. + let otherMains = NSApp.windows.filter { + $0 !== window && Self.isMainWindow($0) && $0.isVisible + } + for existing in otherMains { + existing.tabbingIdentifier = tabbingId + } + } + let target = sibling.tabbedWindows?.last ?? sibling + target.addTabbedWindow(window, ordered: .above) + // `addTabbedWindow(_:ordered:)` only inserts — it doesn't select + // the new tab in the group. `makeKeyAndOrderFront` brings this + // window to the front of the group AND makes it key, which is + // what the user expects on Cmd+T. + window.makeKeyAndOrderFront(nil) + Self.lifecycleLogger.info( + "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" + ) + } else { + // Standalone case: center the frame BEFORE showing so the window + // doesn't flash at the default (0,0) position before jumping. + // `makeKeyAndOrderFront` is the standard AppKit idiom for this. + window.center() + window.makeKeyAndOrderFront(nil) + // Ensure the app is active when opening from a background context + // (e.g. Welcome window's Connect button races with welcome close). + NSApp.activate(ignoringOtherApps: true) + Self.lifecycleLogger.info( + "[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" + ) + } + + + Self.lifecycleLogger.info( + "[open] WindowManager.openTab done payloadId=\(payload.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" + ) + } + + // MARK: - Retention + + private func retain(controller: TabWindowController, window: NSWindow) { + let key = ObjectIdentifier(window) + controllers[key] = controller + closeObservers[key] = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.release(windowKey: key) + } + } + } + + private func release(windowKey: ObjectIdentifier) { + if let observer = closeObservers.removeValue(forKey: windowKey) { + NotificationCenter.default.removeObserver(observer) + } + controllers.removeValue(forKey: windowKey) + } + + // MARK: - Helpers + + private static func isMainWindow(_ window: NSWindow) -> Bool { + guard let raw = window.identifier?.rawValue else { return false } + return raw == "main" || raw.hasPrefix("main-") + } + + /// Tabbing identifier for a connection. Per-connection by default; + /// shared "com.TablePro.main" when the user enables Group All Connection + /// Tabs in Settings → Tabs. Used by `TabWindowController.init` and by + /// AppDelegate's pre-Phase-1 fallback in `windowDidBecomeKey`. + internal static func tabbingIdentifier(for connectionId: UUID) -> String { + if AppSettingsManager.shared.tabs.groupAllConnectionTabs { + return "com.TablePro.main" + } + return "com.TablePro.main.\(connectionId.uuidString)" + } + + private func findSibling( + tabbingIdentifier: String, + groupAll: Bool, + excluding: NSWindow + ) -> NSWindow? { + NSApp.windows.first { candidate in + candidate !== excluding + && Self.isMainWindow(candidate) + && candidate.isVisible + && (groupAll || candidate.tabbingIdentifier == tabbingIdentifier) + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index c75ca71f..e8a74691 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -2,8 +2,10 @@ // WindowOpener.swift // TablePro // -// Bridges SwiftUI's openWindow environment action to imperative code. -// Stored on appear by ContentView, WelcomeViewModel, or ConnectionFormView. +// Bridges SwiftUI's `OpenWindowAction` to imperative call sites for the +// remaining SwiftUI scenes (Welcome, Connection Form, Settings). The main +// editor windows no longer use this — they go through `WindowManager.openTab` +// directly. // import os @@ -17,8 +19,9 @@ internal final class WindowOpener { private var readyContinuations: [CheckedContinuation] = [] - /// Set on appear by ContentView, WelcomeViewModel, or ConnectionFormView. - /// Safe to store — OpenWindowAction is app-scoped, not view-scoped. + /// Set on appear by `OpenWindowHandler` (TableProApp). Used to open the + /// welcome window, connection form, and settings from imperative code. + /// Safe to store — `OpenWindowAction` is app-scoped, not view-scoped. internal var openWindow: OpenWindowAction? { didSet { if openWindow != nil { @@ -30,7 +33,9 @@ internal final class WindowOpener { } } - /// Suspends until openWindow is set. Returns immediately if already available. + /// Suspends until `openWindow` is set. Returns immediately if available. + /// Used by Dock-menu / URL-scheme cold-launch paths that fire before any + /// SwiftUI view has appeared. internal func waitUntilReady() async { if openWindow != nil { return } await withCheckedContinuation { continuation in @@ -41,46 +46,4 @@ internal final class WindowOpener { } } } - - /// Ordered queue of pending payloads — windows requested via openNativeTab - /// but not yet acknowledged by MainContentView.configureWindow. - /// Ordered so consumeOldestPendingConnectionId returns the correct entry - /// when multiple windows open in quick succession (e.g., tab restore). - internal private(set) var pendingPayloads: [(id: UUID, connectionId: UUID)] = [] - - /// Whether any payloads are pending — used for orphan detection in windowDidBecomeKey. - internal var hasPendingPayloads: Bool { !pendingPayloads.isEmpty } - - /// Opens a new native window tab with the given payload. - /// Falls back to .openMainWindow notification if openWindow is not yet available - /// (cold launch from Dock menu before any SwiftUI view has appeared). - internal func openNativeTab(_ payload: EditorTabPayload) { - pendingPayloads.append((id: payload.id, connectionId: payload.connectionId)) - if let openWindow { - openWindow(id: "main", value: payload) - } else { - Self.logger.info("openWindow not set — falling back to .openMainWindow notification") - NotificationCenter.default.post(name: .openMainWindow, object: payload) - } - } - - /// Called by MainContentView.configureWindow after the window is fully set up. - internal func acknowledgePayload(_ id: UUID) { - pendingPayloads.removeAll { $0.id == id } - } - - /// Consumes and returns the connectionId for the oldest pending payload. - /// Removes the entry so subsequent calls return the next payload in order. - internal func consumeOldestPendingConnectionId() -> UUID? { - guard !pendingPayloads.isEmpty else { return nil } - return pendingPayloads.removeFirst().connectionId - } - - /// Returns the tabbingIdentifier for a connection. - internal static func tabbingIdentifier(for connectionId: UUID) -> String { - if AppSettingsManager.shared.tabs.groupAllConnectionTabs { - return "com.TablePro.main" - } - return "com.TablePro.main.\(connectionId.uuidString)" - } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d93e02e0..e788e56f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -22,6 +22,9 @@ } } } + }, + "\n\n… (%d more characters not shown)" : { + }, " — %@" : { "localizations" : { @@ -1229,6 +1232,9 @@ } } } + }, + "%d plugin(s) could not be loaded" : { + }, "%d-%d of %@%@ rows" : { "localizations" : { @@ -4437,6 +4443,9 @@ } } } + }, + "An external link wants to apply a filter:\n\n%@" : { + }, "An external link wants to open a query on connection \"%@\":\n\n%@" : { "localizations" : { @@ -4803,6 +4812,12 @@ } } } + }, + "Apply Filter" : { + + }, + "Apply Filter from Link" : { + }, "Apply filters" : { "localizations" : { @@ -13448,6 +13463,9 @@ } } } + }, + "Enter the passphrase for SSH key \"%@\":" : { + }, "Enter the passphrase to decrypt and import connections." : { "localizations" : { @@ -14204,6 +14222,9 @@ } } } + }, + "Export & Import" : { + }, "Export %d Connections..." : { "localizations" : { @@ -16541,8 +16562,12 @@ }, "Format Query" : { + }, + "Format Query (⇧⌘L)" : { + }, "Format Query (⌥⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -26344,6 +26369,9 @@ } } } + }, + "Preferred" : { + }, "Preserve all values as strings" : { "extractionState" : "stale", @@ -27622,8 +27650,12 @@ } } } + }, + "Quick Switcher (⇧⌘O)" : { + }, "Quick Switcher (⌘P)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27666,6 +27698,9 @@ } } } + }, + "Quit Anyway" : { + }, "Quote" : { "extractionState" : "stale", @@ -28296,6 +28331,9 @@ } } } + }, + "Refresh & Save" : { + }, "Refresh data" : { "extractionState" : "stale", @@ -30185,6 +30223,9 @@ } } } + }, + "Save passphrase in Keychain" : { + }, "Save Sidebar Changes" : { "localizations" : { @@ -32493,6 +32534,9 @@ } } } + }, + "Some tabs have unsaved edits. Quitting will discard these changes." : { + }, "Something went wrong (error %d). Try again in a moment." : { "localizations" : { @@ -33093,6 +33137,9 @@ } } } + }, + "SSH Key Passphrase Required" : { + }, "SSH Port" : { "localizations" : { @@ -35194,6 +35241,9 @@ } } } + }, + "The following plugins were rejected:\n\n%@\n\nPlease update them from the plugin registry." : { + }, "The license has been suspended." : { "localizations" : { @@ -36305,8 +36355,12 @@ } } } + }, + "Toggle Filters (⇧⌘F)" : { + }, "Toggle Filters (⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39107,6 +39161,9 @@ } } } + }, + "You have unsaved changes" : { + }, "You have unsaved changes to the table structure. Refreshing will discard these changes." : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 838e2e34..8eec8312 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -7,6 +7,7 @@ import CodeEditTextView import Observation +import os import Sparkle import SwiftUI import TableProPluginKit @@ -102,12 +103,43 @@ struct PasteboardCommands: Commands { struct AppMenuCommands: Commands { var settingsManager: AppSettingsManager var updaterBridge: UpdaterBridge - @FocusedValue(\.commandActions) var actions: MainContentCommandActions? + @FocusedValue(\.commandActions) var focusedActions: MainContentCommandActions? + /// @Observable singleton — passed in from TableProApp via @Bindable so + /// SwiftUI re-evaluates the menu when the current key window's actions + /// change. Fallback for when `@FocusedValue` returns nil (e.g. after + /// clicking a toolbar Button whose NSHostingController claims SwiftUI + /// scene focus instead of MainContentView's). + @Bindable var commandRegistry: CommandActionsRegistry + + /// Effective actions used by every menu item. Prefers @FocusedValue when + /// it resolves (correct for in-content focus); falls back to the registry + /// otherwise (covers toolbar-click + welcome→connect race scenarios). + private var actions: MainContentCommandActions? { + focusedActions ?? commandRegistry.current + } private func shortcut(for action: ShortcutAction) -> KeyboardShortcut? { settingsManager.keyboard.keyboardShortcut(for: action) } + /// Prefers the focused scene value; falls back to the coordinator back-reference + /// so Cmd+W still routes through `closeTab()` (with its unsaved-changes dialog) + /// when focus is inside an AppKit subview and `@FocusedValue` has not resolved. + private var resolvedCloseTabActions: MainContentCommandActions? { + if let actions { return actions } + guard let window = NSApp.keyWindow, + window.identifier?.rawValue.hasPrefix("main") == true + else { return nil } + if let coordinator = MainContentCoordinator.coordinator(forWindow: window) { + return coordinator.commandActions + } + if let windowId = WindowLifecycleMonitor.shared.windowId(forWindow: window), + let coordinator = MainContentCoordinator.coordinator(for: windowId) { + return coordinator.commandActions + } + return nil + } + var body: some Commands { // Custom About window + Check for Updates CommandGroup(replacing: .appInfo) { @@ -171,7 +203,13 @@ struct AppMenuCommands: Commands { actions?.saveChanges() } .optionalKeyboardShortcut(shortcut(for: .saveChanges)) - .disabled(!(actions?.isConnected ?? false) || actions?.isReadOnly ?? false) + // Match toolbar: also disable when no pending changes — avoids + // a no-op Cmd+S when nothing has been edited. + .disabled( + !(actions?.isConnected ?? false) + || actions?.isReadOnly ?? false + || !(actions?.hasPendingChanges ?? false) + ) Button(String(localized: "Save As...")) { actions?.saveFileAs() @@ -180,15 +218,13 @@ struct AppMenuCommands: Commands { .disabled(!(actions?.isConnected ?? false)) Button(actions != nil ? "Close Tab" : "Close") { - if let actions { - actions.closeTab() + if let resolved = resolvedCloseTabActions { + resolved.closeTab() } else if let window = NSApp.keyWindow { - // Only performClose for non-main windows (Settings, Welcome, Connection Form). - // For main windows where @FocusedValue hasn't resolved yet, do nothing — - // prevents accidentally closing the connection window when user intended - // to close a tab. - let isMainWindow = window.identifier?.rawValue.hasPrefix("main") == true - if !isMainWindow { + if window.identifier?.rawValue.hasPrefix("main") == true, + let coordinator = MainContentCoordinator.coordinator(forWindow: window) { + coordinator.commandActions?.closeTab() + } else { window.performClose(nil) } } @@ -259,7 +295,10 @@ struct AppMenuCommands: Commands { } } .optionalKeyboardShortcut(shortcut(for: .previewSQL)) - .disabled(!(actions?.isConnected ?? false)) + // Same disabled condition as the toolbar button so Cmd+Shift+P + // doesn't open an empty preview popover when there are no + // pending data changes to preview. + .disabled(!(actions?.isConnected ?? false) || !(actions?.hasDataPendingChanges ?? false)) Divider() @@ -507,6 +546,7 @@ struct TableProApp: App { @State private var settingsManager = AppSettingsManager.shared @State private var updaterBridge = UpdaterBridge() + @State private var commandRegistry = CommandActionsRegistry.shared init() { // Perform startup cleanup of query history if auto-cleanup is enabled @@ -532,15 +572,12 @@ struct TableProApp: App { } .windowResizability(.contentSize) - // Main Window - opens when connecting to database - // Each native window-tab gets its own ContentView with independent state. - WindowGroup(id: "main", for: EditorTabPayload.self) { $payload in - ContentView(payload: payload) - .background(OpenWindowHandler()) - } - .windowStyle(.automatic) - .windowToolbarStyle(.unified) - .defaultSize(width: 1_200, height: 800) + // NOTE (prototype): main windows are now created imperatively via + // MainWindowFactory → NSWindow + NSHostingController. The retired + // `WindowGroup(id:"main", for: EditorTabPayload.self)` caused SwiftUI to + // re-instantiate ContentView for every historical payload on every scene + // phase diff (5-7 phantom inits per open). AppKit-native windows avoid + // that and eliminate the 68-437ms openWindow() latency. // Settings Window - opens with Cmd+, Settings { @@ -551,7 +588,8 @@ struct TableProApp: App { .commands { AppMenuCommands( settingsManager: AppSettingsManager.shared, - updaterBridge: updaterBridge + updaterBridge: updaterBridge, + commandRegistry: commandRegistry ) } } @@ -625,9 +663,9 @@ private struct OpenWindowHandler: View { } .onReceive(NotificationCenter.default.publisher(for: .openMainWindow)) { notification in if let payload = notification.object as? EditorTabPayload { - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } else if let connectionId = notification.object as? UUID { - WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connectionId)) + WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connectionId)) } } .onReceive(NotificationCenter.default.publisher(for: .openSettingsWindow)) { _ in diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 870ce4d9..75908138 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -212,8 +212,14 @@ final class WelcomeViewModel { if WindowOpener.shared.openWindow == nil { WindowOpener.shared.openWindow = openWindow } - WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) + // Close welcome BEFORE opening the new editor window. Otherwise the + // welcome window (still key + visible) reasserts itself during the + // new window's `makeKeyAndOrderFront` — the new window briefly + // becomes key, immediately resigns, welcome retakes key, and the + // app is left with no key window after welcome closes → menu + // @FocusedValue nil → Cmd+T/1...9 disabled. NSApplication.shared.closeWindows(withId: "welcome") + WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) Task { do { @@ -240,8 +246,10 @@ final class WelcomeViewModel { if WindowOpener.shared.openWindow == nil { WindowOpener.shared.openWindow = openWindow } - WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) + // Close welcome before opening editor — see connectToDatabase above + // for the welcome-reasserts-key race that disabled menu shortcuts. NSApplication.shared.closeWindows(withId: "welcome") + WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) Task { do { diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index 82365638..f0aa205a 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -279,8 +279,11 @@ extension ConnectionFormView { if WindowOpener.shared.openWindow == nil { WindowOpener.shared.openWindow = openWindow } - WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) + // Close welcome BEFORE opening the editor window so it can't reassert + // key status during the new window's `makeKeyAndOrderFront`. See + // WelcomeViewModel.connectToDatabase for the diagnosed race. NSApplication.shared.closeWindows(withId: "welcome") + WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) Task { do { @@ -322,8 +325,10 @@ extension ConnectionFormView { if WindowOpener.shared.openWindow == nil { WindowOpener.shared.openWindow = openWindow } - WindowOpener.shared.openNativeTab(EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) + // Close welcome before opening editor — see connectToDatabase above + // for the welcome-reasserts-key race that disabled menu shortcuts. NSApplication.shared.closeWindows(withId: "welcome") + WindowManager.shared.openTab(payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)) Task { do { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift index 64f99f40..4a89d6fd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift @@ -1,12 +1,41 @@ +import AppKit import Foundation extension MainContentCoordinator { - func openERDiagramTab() { + /// Open (or focus) an ER Diagram tab for the current database/schema. + /// + /// Resolution order: + /// 1. If another window for this connection already hosts an ER Diagram + /// tab with the same schema key, focus that window. + /// 2. If this window's tabManager is empty (fresh window with no restored + /// tabs yet), add the ER Diagram tab locally. + /// 3. Otherwise open a new native window tab so the current tab's content + /// (unsaved queries, filters, etc.) is preserved. + func showERDiagram() { let session = DatabaseManager.shared.session(for: connectionId) let dbName = session?.activeDatabase ?? connection.database let schemaName = session?.currentSchema let schemaKey = "\(dbName).\(schemaName ?? "default")" - tabManager.addERDiagramTab(schemaKey: schemaKey, databaseName: dbName) + if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { + $0.tabType == .erDiagram && $0.erDiagramSchemaKey == schemaKey + }) { + existing.contentWindow?.makeKeyAndOrderFront(nil) + return + } + + if tabManager.tabs.isEmpty { + tabManager.addERDiagramTab(schemaKey: schemaKey, databaseName: dbName) + return + } + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .erDiagram, + databaseName: dbName, + schemaName: schemaName, + erDiagramSchemaKey: schemaKey + ) + WindowManager.shared.openTab(payload: payload) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 7c2e8f1c..019773ae 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -70,7 +70,7 @@ extension MainContentCoordinator { isView: false, initialFilterState: fkFilterState ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index bd7f1ce0..c8108e37 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -48,6 +48,6 @@ extension MainContentCoordinator { databaseName: connection.database, initialQuery: favorite.query ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 08827eba..57649f8b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -150,7 +150,7 @@ extension MainContentCoordinator { isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) return } @@ -170,7 +170,7 @@ extension MainContentCoordinator { isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } // MARK: - Preview Tabs @@ -258,7 +258,7 @@ extension MainContentCoordinator { isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) return } if let oldTableName = selectedTab.tableName { @@ -295,7 +295,7 @@ extension MainContentCoordinator { showStructure: showStructure, isPreview: true ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } func promotePreviewTab() { @@ -317,7 +317,7 @@ extension MainContentCoordinator { tabType: .query, initialQuery: sql ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } private func currentSchemaName(fallback: String) -> String { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift index d6fb31ca..71dc5b84 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift @@ -1,7 +1,33 @@ +import AppKit import Foundation extension MainContentCoordinator { - func openServerDashboardTab() { - tabManager.addServerDashboardTab() + /// Open (or focus) the Server Dashboard tab for this connection. + /// + /// Singleton per connection. Resolution order: + /// 1. If any window for this connection already hosts a Server Dashboard + /// tab, focus that window. + /// 2. If this window's tabManager is empty, add the dashboard tab locally. + /// 3. Otherwise open a new native window tab so the current tab's content + /// is preserved. + func showServerDashboard() { + if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { + $0.tabType == .serverDashboard + }) { + existing.contentWindow?.makeKeyAndOrderFront(nil) + return + } + + if tabManager.tabs.isEmpty { + tabManager.addServerDashboardTab() + return + } + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .serverDashboard, + databaseName: connection.database + ) + WindowManager.shared.openTab(payload: payload) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e5dacead..93d39975 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -48,14 +48,10 @@ extension MainContentCoordinator { tabType: .createTable, databaseName: connection.database ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } - func showERDiagram() { - openERDiagramTab() - } - // MARK: - View Operations func createView() { @@ -71,7 +67,7 @@ extension MainContentCoordinator { databaseName: connection.database, initialQuery: template ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } func editViewDefinition(_ viewName: String) { @@ -85,7 +81,7 @@ extension MainContentCoordinator { tabType: .query, initialQuery: definition ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } catch { let driver = DatabaseManager.shared.driver(for: self.connection.id) let template = driver?.editViewFallbackTemplate(viewName: viewName) @@ -97,7 +93,7 @@ extension MainContentCoordinator { tabType: .query, initialQuery: fallbackSQL ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 952483f5..b214c587 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -7,6 +7,7 @@ // import Foundation +import os extension MainContentCoordinator { func handleTabChange( @@ -15,10 +16,20 @@ extension MainContentCoordinator { selectedRowIndices: inout Set, tabs: [QueryTab] ) { + let start = Date() + Self.lifecycleLogger.debug( + "[switch] handleTabChange start from=\(oldTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) connId=\(self.connectionId, privacy: .public) tabsCount=\(self.tabManager.tabs.count)" + ) isHandlingTabSwitch = true - defer { isHandlingTabSwitch = false } + defer { + isHandlingTabSwitch = false + Self.lifecycleLogger.debug( + "[switch] handleTabChange done to=\(newTabId?.uuidString ?? "nil", privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) + } - // Persist the outgoing tab's unsaved changes and filter state so they survive the switch + // Phase: save outgoing tab state + let saveStart = Date() if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) { @@ -32,12 +43,18 @@ extension MainContentCoordinator { saveColumnVisibilityToTab() saveColumnLayoutForTable() } + let saveMs = Int(Date().timeIntervalSince(saveStart) * 1_000) + // Phase: evict inactive tabs + let evictStart = Date() if tabManager.tabs.count > 2 { let activeIds: Set = Set([oldTabId, newTabId].compactMap { $0 }) evictInactiveTabs(excluding: activeIds) } + let evictMs = Int(Date().timeIntervalSince(evictStart) * 1_000) + // Phase: restore incoming tab state + let restoreStart = Date() if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { let newTab = tabManager.tabs[newIndex] @@ -69,10 +86,10 @@ extension MainContentCoordinator { ) } - // Defer reloadVersion bump — only needed when we won't run a query. - // When a query runs, executeQueryInternal Phase 1 sets new result data - // that triggers its own SwiftUI update; bumping beforehand causes a - // redundant re-evaluation that blocks the Task executor (15-40ms). + let restoreMs = Int(Date().timeIntervalSince(restoreStart) * 1_000) + Self.lifecycleLogger.debug( + "[switch] handleTabChange phases: saveOutgoing=\(saveMs)ms evict=\(evictMs)ms restoreIncoming=\(restoreMs)ms" + ) if !newTab.databaseName.isEmpty { let currentDatabase: String @@ -83,6 +100,9 @@ extension MainContentCoordinator { } if newTab.databaseName != currentDatabase { + Self.lifecycleLogger.debug( + "[switch] handleTabChange triggering switchDatabase from=\(currentDatabase, privacy: .public) to=\(newTab.databaseName, privacy: .public)" + ) changeManager.reloadVersion += 1 Task { @MainActor in await switchDatabase(to: newTab.databaseName) @@ -113,8 +133,14 @@ extension MainContentCoordinator { if needsLazyQuery { if let session = DatabaseManager.shared.session(for: connectionId), session.isConnected { + Self.lifecycleLogger.debug( + "[switch] handleTabChange lazy query executing (eviction=\(isEvicted)) tabId=\(newId, privacy: .public)" + ) executeTableTabQueryDirectly() } else { + Self.lifecycleLogger.debug( + "[switch] handleTabChange lazy query deferred (not connected) tabId=\(newId, privacy: .public)" + ) changeManager.reloadVersion += 1 needsLazyLoad = true } @@ -129,6 +155,7 @@ extension MainContentCoordinator { } private func evictInactiveTabs(excluding activeTabIds: Set) { + let start = Date() let candidates = tabManager.tabs.filter { !activeTabIds.contains($0.id) && !$0.rowBuffer.isEvicted @@ -154,11 +181,19 @@ extension MainContentCoordinator { } let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs() - guard sorted.count > maxInactiveLoaded else { return } + guard sorted.count > maxInactiveLoaded else { + Self.lifecycleLogger.debug( + "[switch] evictInactiveTabs no-op candidates=\(sorted.count) budget=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) + return + } let toEvict = sorted.dropLast(maxInactiveLoaded) for tab in toEvict { tab.rowBuffer.evict() } + Self.lifecycleLogger.debug( + "[switch] evictInactiveTabs evicted=\(toEvict.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift new file mode 100644 index 00000000..ee7f1b80 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -0,0 +1,135 @@ +// +// MainContentCoordinator+WindowLifecycle.swift +// TablePro +// +// Window-lifecycle handlers invoked by TabWindowController's NSWindowDelegate +// methods. Replaces the global `NotificationCenter.default.publisher(for: +// NSWindow.didBecomeKeyNotification)` observers previously in MainContentView +// (one fired per ContentView instance, producing 10-14 handler invocations +// per focus change). Each window's TabWindowController now dispatches to the +// matching coordinator exactly once. +// + +import AppKit +import os +import SwiftUI +import TableProPluginKit + +extension MainContentCoordinator { + // MARK: - Window Delegate Dispatch + + /// Called from `TabWindowController.windowDidBecomeKey(_:)`. + /// Runs lazy-load + file-based schema refresh, then invokes the view-layer + /// sidebar-sync callback set by MainContentView. + func handleWindowDidBecomeKey() { + let t0 = Date() + Self.lifecycleLogger.debug( + "[switch] coordinator.handleWindowDidBecomeKey connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" + ) + isKeyWindow = true + evictionTask?.cancel() + evictionTask = nil + + // Lazy-load: execute query for restored tabs that skipped auto-execute, + // or re-query tabs whose row data was evicted while inactive. + // Skip if the user has unsaved changes (in-memory or tab-level). + let hasPendingEdits = + changeManager.hasChanges + || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) + let isConnected = + DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false + let needsLazyLoad = + tabManager.selectedTab.map { tab in + tab.tabType == .table + && (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted) + && (tab.lastExecutedAt == nil || tab.rowBuffer.isEvicted) + && tab.errorMessage == nil + && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } ?? false + // Skip lazy-load if this is a menu-interaction bounce (resign+become within 200ms). + let isMenuBounce = Date().timeIntervalSince(lastResignKeyDate) < 0.2 + if needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce { + Self.lifecycleLogger.debug( + "[switch] coordinator triggering lazy runQuery connId=\(self.connectionId, privacy: .public)" + ) + runQuery() + } + let t1 = Date() + + if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { + Task { await self.refreshTablesIfStale() } + } + let t2 = Date() + + onWindowBecameKey?() + let t3 = Date() + + Self.lifecycleLogger.debug( + "[switch] coordinator.handleWindowDidBecomeKey done connId=\(self.connectionId, privacy: .public) lazyQuery=\(Int(t1.timeIntervalSince(t0) * 1_000))ms schemaRefresh=\(Int(t2.timeIntervalSince(t1) * 1_000))ms sidebarSync=\(Int(t3.timeIntervalSince(t2) * 1_000))ms totalMs=\(Int(Date().timeIntervalSince(t0) * 1_000)) lazyLoad=\(needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce) menuBounce=\(isMenuBounce)" + ) + } + + /// Called from `TabWindowController.windowDidResignKey(_:)`. + /// Schedules a 5s-delayed eviction of row data in inactive tabs; a fresh + /// `windowDidBecomeKey` cancels the eviction before it fires. + func handleWindowDidResignKey() { + Self.lifecycleLogger.debug( + "[switch] coordinator.handleWindowDidResignKey connId=\(self.connectionId, privacy: .public)" + ) + isKeyWindow = false + lastResignKeyDate = Date() + + evictionTask?.cancel() + evictionTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard let self, !Task.isCancelled else { return } + Self.lifecycleLogger.debug( + "[switch] coordinator evictInactiveRowData firing (5s after resignKey) connId=\(self.connectionId, privacy: .public)" + ) + self.evictInactiveRowData() + } + } + + /// Called from `TabWindowController.windowWillClose(_:)`. + /// Synchronous teardown — no grace period, no delayed Task. Writes tab + /// state to disk, invokes view-layer teardown callback, then disconnects + /// the session if this was the last window for the connection. + func handleWindowWillClose() { + let t0 = Date() + Self.lifecycleLogger.info( + "[close] coordinator.handleWindowWillClose connId=\(self.connectionId, privacy: .public) tabs=\(self.tabManager.tabs.count)" + ) + + // Persist remaining non-preview tabs synchronously. saveNowSync writes + // directly without spawning a Task — required here because the window + // is closing and we cannot rely on async tasks being serviced. + let persistableTabs = tabManager.tabs.filter { !$0.isPreview } + if persistableTabs.isEmpty { + // Empty → clear saved state so next open shows a default empty window. + persistence.saveNowSync(tabs: [], selectedTabId: nil) + } else { + let normalizedSelectedId = + persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) + ? tabManager.selectedTabId : persistableTabs.first?.id + persistence.saveNowSync(tabs: persistableTabs, selectedTabId: normalizedSelectedId) + } + + // Cancel the pending eviction task before teardown drops it. + evictionTask?.cancel() + evictionTask = nil + + // View-layer teardown (e.g. rightPanelState cleanup) before coordinator + // teardown so its SwiftUI state is released first. + onWindowWillClose?() + + teardown() + + // Disconnect is handled by WindowLifecycleMonitor.handleWindowClose, + // which fires after this delegate method. It removes the window entry + // first, then checks if any remain for the connection, then disconnects. + + Self.lifecycleLogger.info( + "[close] coordinator.handleWindowWillClose done connId=\(self.connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" + ) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 57a0d458..4b92ffbf 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -6,48 +6,56 @@ // Extracted to reduce main view complexity. // +import os import SwiftUI extension MainContentView { // MARK: - Event Handlers func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { + guard !coordinator.isTearingDown else { + MainContentView.lifecycleLogger.debug("[switch] handleTabSelectionChange SKIPPED (tearingDown) connId=\(coordinator.connectionId, privacy: .public)") + return + } + let t0 = Date() coordinator.handleTabChange( from: oldTabId, to: newTabId, selectedRowIndices: &selectedRowIndices, tabs: tabManager.tabs ) + let t1 = Date() updateWindowTitleAndFileState() + let t2 = Date() - // Sync sidebar selection to match the newly selected tab. - // Critical for new native windows: localSelectedTables starts empty, - // and this is the only place that can seed it from the restored tab. syncSidebarToCurrentTab() + let t3 = Date() - // Persist tab selection explicitly (skip during teardown) guard !coordinator.isTearingDown else { return } coordinator.persistence.saveNow( tabs: tabManager.tabs, selectedTabId: newTabId ) + MainContentView.lifecycleLogger.debug( + "[switch] handleTabSelectionChange breakdown: tabChange=\(Int(t1.timeIntervalSince(t0) * 1_000))ms windowTitle=\(Int(t2.timeIntervalSince(t1) * 1_000))ms sidebarSync=\(Int(t3.timeIntervalSince(t2) * 1_000))ms persistSave=\(Int(Date().timeIntervalSince(t3) * 1_000))ms" + ) } func handleTabsChange(_ newTabs: [QueryTab]) { + guard !coordinator.isTearingDown else { + MainContentView.lifecycleLogger.debug("[switch] handleTabsChange SKIPPED (tearingDown) tabCount=\(newTabs.count) connId=\(coordinator.connectionId, privacy: .public)") + return + } + let t0 = Date() updateWindowTitleAndFileState() - // Don't persist during teardown — SwiftUI may fire onChange with empty tabs - // as the view is being deallocated - guard !coordinator.isTearingDown else { return } guard !coordinator.isUpdatingColumnLayout else { return } - // Promote preview tab if user has interacted with it if let tab = tabManager.selectedTab, tab.isPreview, tab.hasUserInteraction { coordinator.promotePreviewTab() } - // Persist tab changes (exclude preview tabs from persistence) let persistableTabs = newTabs.filter { !$0.isPreview } if persistableTabs.isEmpty { coordinator.persistence.clearSavedState() @@ -60,6 +68,9 @@ extension MainContentView { selectedTabId: normalizedSelectedId ) } + MainContentView.lifecycleLogger.debug( + "[switch] handleTabsChange tabCount=\(newTabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" + ) } func handleColumnsChange(newColumns: [String]?) { @@ -101,7 +112,7 @@ extension MainContentView { // Only navigate when this is the focused window. // Prevents feedback loops when shared sidebar state syncs across native tabs. - guard isKeyWindow else { + guard coordinator.isKeyWindow else { return } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 38b8e02c..04f23cfc 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -6,21 +6,37 @@ // for MainContentView. Extracted to reduce main view complexity. // +import os import SwiftUI extension MainContentView { // MARK: - Initialization func initializeAndRestoreTabs() async { - guard !hasInitialized else { return } + guard !hasInitialized else { + MainContentView.lifecycleLogger.info( + "[open] initializeAndRestoreTabs skipped (already initialized) windowId=\(windowId, privacy: .public)" + ) + return + } hasInitialized = true - Task { await coordinator.loadSchemaIfNeeded() } + let schemaTaskStart = Date() + Task { + await coordinator.loadSchemaIfNeeded() + MainContentView.lifecycleLogger.info( + "[open] loadSchemaIfNeeded done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(schemaTaskStart) * 1_000))" + ) + } guard let payload else { await handleRestoreOrDefault() return } + MainContentView.lifecycleLogger.info( + "[open] initializeAndRestoreTabs intent=\(String(describing: payload.intent), privacy: .public) windowId=\(windowId, privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" + ) + switch payload.intent { case .openContent: if payload.skipAutoExecute { return } @@ -79,10 +95,17 @@ extension MainContentView { let title = QueryTabManager.nextQueryTitle(existingTabs: allTabs) tabManager.addTab(title: title, databaseName: connection.database) } + MainContentView.lifecycleLogger.info( + "[open] handleRestoreOrDefault short-circuit (other windows exist) windowId=\(windowId, privacy: .public)" + ) return } + let restoreStart = Date() let result = await coordinator.persistence.restoreFromDisk() + MainContentView.lifecycleLogger.info( + "[open] restoreFromDisk done windowId=\(windowId, privacy: .public) tabsRestored=\(result.tabs.count) source=\(String(describing: result.source), privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(restoreStart) * 1_000))" + ) if !result.tabs.isEmpty { var restoredTabs = result.tabs for i in restoredTabs.indices where restoredTabs[i].tabType == .table { @@ -111,7 +134,7 @@ extension MainContentView { for tab in remainingTabs { let restorePayload = EditorTabPayload( from: tab, connectionId: connection.id, skipAutoExecute: true) - WindowOpener.shared.openNativeTab(restorePayload) + WindowManager.shared.openTab(payload: restorePayload) } // Bring the first window to front only if it had the selected tab. // Otherwise let the last restored window stay focused. @@ -164,6 +187,8 @@ extension MainContentView { windowTitle = String(localized: "Server Dashboard") } else if selectedTab?.tabType == .createTable { windowTitle = String(localized: "Create Table") + } else if selectedTab?.tabType == .erDiagram { + windowTitle = String(localized: "ER Diagram") } else if let fileURL = selectedTab?.sourceFileURL { windowTitle = fileURL.deletingPathExtension().lastPathComponent } else { @@ -179,6 +204,10 @@ extension MainContentView { /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. func configureWindow(_ window: NSWindow) { + let start = Date() + MainContentView.lifecycleLogger.info( + "[open] configureWindow start windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public)" + ) let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false if isPreview { window.subtitle = "\(connection.name) — Preview" @@ -186,7 +215,7 @@ extension MainContentView { window.subtitle = connection.name } - let resolvedId = WindowOpener.tabbingIdentifier(for: connection.id) + let resolvedId = WindowManager.tabbingIdentifier(for: connection.id) window.tabbingIdentifier = resolvedId window.tabbingMode = .preferred coordinator.windowId = windowId @@ -199,11 +228,7 @@ extension MainContentView { ) viewWindow = window coordinator.contentWindow = window - isKeyWindow = window.isKeyWindow - - if let payloadId = payload?.id { - WindowOpener.shared.acknowledgePayload(payloadId) - } + coordinator.isKeyWindow = window.isKeyWindow // Native proxy icon (Cmd+click shows path in Finder) and dirty dot window.representedURL = tabManager.selectedTab?.sourceFileURL @@ -211,6 +236,36 @@ extension MainContentView { // Update command actions window reference now that it's available commandActions?.window = window + + // Publish command actions to the registry NOW. `windowDidBecomeKey` + // also publishes, but for the first window after welcome→connect the + // coordinator's `contentWindow` isn't set when AppKit's first + // becomeKey fires — `coordinator(forWindow:)` returns nil and the + // publish is skipped. configureWindow IS the moment the coordinator + // gets linked to its NSWindow, so this is the earliest reliable + // point to publish. + // + // No `window.isKeyWindow` guard: when this method runs, the window + // has been ordered front but isn't yet key (becomeKey fires after + // a runloop tick). We trust that newly opened windows will become + // key shortly; overwriting from a non-key window is acceptable + // because the next becomeKey on any window will rewrite the + // registry anyway. + if let actions = commandActions { + CommandActionsRegistry.shared.current = actions + } + + // Install NSToolbar. `installToolbar` is idempotent — safe to call + // from multiple lifecycle triggers. Called from both here AND + // `TabWindowController.windowDidBecomeKey` because the two tab-open + // paths (Cmd+T menu vs. toolbar "+" button click) have different + // calling contexts, and each hits one trigger but not the other. + if let controller = window.windowController as? TabWindowController { + controller.installToolbar(coordinator: coordinator) + } + MainContentView.lifecycleLogger.info( + "[open] configureWindow done windowId=\(windowId, privacy: .public) tabbingId=\(resolvedId, privacy: .public) isPreview=\(isPreview) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) } func setupCommandActions() { @@ -230,6 +285,7 @@ extension MainContentView { editingCell: $editingCell ) actions.window = viewWindow + coordinator.commandActions = actions commandActions = actions } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 3254a4fe..27f144a3 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -302,6 +302,19 @@ final class MainContentCommandActions { !(coordinator?.tabManager.selectedTab?.query.isEmpty ?? true) } + /// Whether there are pending data changes that the SQL preview can show. + /// Mirrors the toolbar Preview SQL button's enabled condition so the + /// menu shortcut (Cmd+Shift+P) doesn't open an empty preview popover. + var hasDataPendingChanges: Bool { + coordinator?.toolbarState.hasDataPendingChanges ?? false + } + + /// Any pending changes (data edits OR file edits). Mirrors the toolbar + /// Save Changes button's enabled condition. + var hasPendingChanges: Bool { + coordinator?.toolbarState.hasPendingChanges ?? false + } + var hasStructureChanges: Bool { coordinator?.toolbarState.hasStructureChanges ?? false } @@ -342,10 +355,12 @@ final class MainContentCommandActions { initialQuery: initialQuery, intent: .newEmptyTab ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } func closeTab() { + let seq = MainContentCoordinator.nextSwitchSeq() + Self.logger.info("[close] closeTab seq=\(seq) hasUnsavedChanges=\(self.hasUnsavedChanges)") if hasUnsavedChanges { Task { @MainActor in let keyWindow = NSApp.keyWindow @@ -369,13 +384,15 @@ final class MainContentCommandActions { } private func performClose() { - guard let keyWindow = NSApp.keyWindow else { return } - let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] + let t0 = Date() + guard let window = coordinator?.contentWindow ?? NSApp.keyWindow else { return } + let visibleTabbedWindows = (window.tabbedWindows ?? [window]).filter(\.isVisible) + Self.logger.info("[close] performClose visibleTabs=\(visibleTabbedWindows.count) tabManagerTabs=\(self.coordinator?.tabManager.tabs.count ?? 0)") - if tabbedWindows.count > 1 { - keyWindow.close() + if visibleTabbedWindows.count > 1 { + window.close() } else if coordinator?.tabManager.tabs.isEmpty == true { - keyWindow.close() + window.close() } else { for tab in coordinator?.tabManager.tabs ?? [] { tab.rowBuffer.evict() @@ -384,6 +401,7 @@ final class MainContentCommandActions { coordinator?.tabManager.selectedTabId = nil coordinator?.toolbarState.isTableTab = false } + Self.logger.info("[close] performClose done ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } private func saveAndClose() async { @@ -479,7 +497,7 @@ final class MainContentCommandActions { } func showServerDashboard() { - coordinator?.openServerDashboardTab() + coordinator?.showServerDashboard() } var supportsServerDashboard: Bool { @@ -805,7 +823,7 @@ final class MainContentCommandActions { initialQuery: content, sourceFileURL: url ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1622a8d9..972e7967 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -63,6 +63,14 @@ enum ActiveSheet: Identifiable { @MainActor @Observable final class MainContentCoordinator { static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator") + static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + + /// Monotonic counter for correlating rapid tab-switch/close log entries. + static var switchSeq: Int = 0 + static func nextSwitchSeq() -> Int { + switchSeq += 1 + return switchSeq + } /// Posted during teardown so DataGridView coordinators can release cell views. /// Object is the connection UUID. @@ -108,6 +116,10 @@ final class MainContentCoordinator { /// Avoids NSApp.keyWindow which may return a sheet window, causing stuck dialogs. @ObservationIgnored weak var contentWindow: NSWindow? + /// Back-reference to this coordinator's command actions, enabling window → coordinator → actions + /// lookup when `@FocusedValue(\.commandActions)` has not resolved (e.g. focus in an AppKit subview). + @ObservationIgnored weak var commandActions: MainContentCommandActions? + // MARK: - Published State var schemaProvider: SQLSchemaProvider @@ -157,6 +169,30 @@ final class MainContentCoordinator { /// Called during teardown to let the view layer release cached row providers and sort data. @ObservationIgnored var onTeardown: (() -> Void)? + // MARK: - Window Lifecycle (Phase 2: driven by TabWindowController NSWindowDelegate) + + /// Whether this coordinator's window is the key (focused) window. + /// Updated by TabWindowController delegate methods; consumed by + /// event handlers (e.g. sidebar table-selection navigation filter). + @ObservationIgnored var isKeyWindow = false + + /// Timestamp of the most recent resignKey. Used by `handleWindowDidBecomeKey` + /// to detect menu-interaction bounces (resign + become within 200ms). + @ObservationIgnored var lastResignKeyDate = Date.distantPast + + /// Eviction task scheduled in `handleWindowDidResignKey` (fires 5s later). + @ObservationIgnored var evictionTask: Task? + + /// View-layer callback invoked from `handleWindowDidBecomeKey` (e.g. sync + /// SwiftUI-scoped sidebar selection to the current tab). Set by MainContentView + /// in `.onAppear`. The callback closes over view state (@Binding tables, + /// SharedSidebarState) that isn't available to the coordinator. + @ObservationIgnored var onWindowBecameKey: (() -> Void)? + + /// View-layer callback invoked from `handleWindowWillClose` before teardown + /// (e.g. `rightPanelState.teardown()` releases SwiftUI-scoped subviewmodels). + @ObservationIgnored var onWindowWillClose: (() -> Void)? + /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never /// adopts into @State are silently discarded — no teardown warning needed. @@ -200,6 +236,13 @@ final class MainContentCoordinator { activeCoordinators.values.first { $0.windowId == windowId } } + /// Find the coordinator whose `contentWindow` matches the given NSWindow. + /// Used by `TabWindowController` to dispatch NSWindowDelegate callbacks + /// to the correct coordinator without needing a shared registry key. + static func coordinator(forWindow window: NSWindow) -> MainContentCoordinator? { + activeCoordinators.values.first { $0.contentWindow === window } + } + /// Check whether any active coordinator has unsaved edits. static func hasAnyUnsavedChanges() -> Bool { activeCoordinators.values.contains { coordinator in @@ -214,6 +257,18 @@ final class MainContentCoordinator { .flatMap { $0.tabManager.tabs } } + /// Find the first coordinator for `connectionId` that owns a tab matching `predicate`. + /// Used to dedup cross-window tabs (Server Dashboard singleton, ER Diagram reuse). + static func coordinator( + forConnection connectionId: UUID, + tabMatching predicate: (QueryTab) -> Bool + ) -> MainContentCoordinator? { + activeCoordinators.values.first { coordinator in + coordinator.connectionId == connectionId + && coordinator.tabManager.tabs.contains(where: predicate) + } + } + /// Collect non-preview tabs for persistence. private static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { let coordinators = activeCoordinators.values @@ -300,6 +355,7 @@ final class MainContentCoordinator { columnVisibilityManager: ColumnVisibilityManager, toolbarState: ConnectionToolbarState ) { + let initStart = Date() self.connection = connection self.tabManager = tabManager self.changeManager = changeManager @@ -345,9 +401,13 @@ final class MainContentCoordinator { } _ = Self.registerTerminationObserver + Self.lifecycleLogger.info( + "[open] MainContentCoordinator.init done connId=\(connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))" + ) } func markActivated() { + let start = Date() _didActivate.withLock { $0 = true } registerForPersistence() setupPluginDriver() @@ -362,6 +422,9 @@ final class MainContentCoordinator { } } } + Self.lifecycleLogger.info( + "[open] MainContentCoordinator.markActivated done connId=\(self.connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) } /// Start watching the database file for external changes (SQLite, DuckDB). @@ -458,6 +521,10 @@ final class MainContentCoordinator { /// Explicit cleanup called from `onDisappear`. Releases schema provider /// synchronously on MainActor so we don't depend on deinit + Task scheduling. func teardown() { + let start = Date() + Self.lifecycleLogger.info( + "[close] MainContentCoordinator.teardown start connId=\(self.connection.id, privacy: .public) tabs=\(self.tabManager.tabs.count) windowId=\(self.windowId?.uuidString ?? "nil", privacy: .public)" + ) _didTeardown.withLock { $0 = true } unregisterFromPersistence() @@ -519,6 +586,9 @@ final class MainContentCoordinator { SchemaProviderRegistry.shared.release(for: connection.id) SchemaProviderRegistry.shared.purgeUnused() + Self.lifecycleLogger.info( + "[close] MainContentCoordinator.teardown done connId=\(self.connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) } deinit { @@ -797,7 +867,7 @@ final class MainContentCoordinator { tabType: .query, initialQuery: query ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } @@ -820,7 +890,7 @@ final class MainContentCoordinator { tabType: .query, initialQuery: query ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index bb7a46d3..a2d5f408 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -14,11 +14,14 @@ // import Combine +import os import SwiftUI import TableProPluginKit /// Main content view - thin presentation layer struct MainContentView: View { + static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") + // MARK: - Properties let connection: DatabaseConnection @@ -54,20 +57,12 @@ struct MainContentView: View { @State var inspectorUpdateTask: Task? @State var lazyLoadTask: Task? @State var pendingTabSwitch: Task? - @State var evictionTask: Task? /// Stable identifier for this window in WindowLifecycleMonitor @State var windowId = UUID() @State var hasInitialized = false - /// Tracks whether this view's window is the key (focused) window - @State var isKeyWindow = false - @State var lastResignKeyDate = Date.distantPast /// Reference to this view's NSWindow for filtering notifications @State var viewWindow: NSWindow? - /// Grace period for onDisappear: SwiftUI fires onDisappear transiently - /// during tab group merges, then re-fires onAppear shortly after. - private static let tabGroupMergeGracePeriod: Duration = .milliseconds(200) - // MARK: - Environment @@ -237,6 +232,10 @@ struct MainContentView: View { scheduleInspectorUpdate() } .onAppear { + let start = Date() + Self.lifecycleLogger.info( + "[open] MainContentView.onAppear start windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public) tabs=\(tabManager.tabs.count)" + ) coordinator.markActivated() // Set window title for empty state (no tabs restored) @@ -250,83 +249,102 @@ struct MainContentView: View { coordinator.aiViewModel = rightPanelState.aiViewModel coordinator.rightPanelState = rightPanelState - // Window registration is handled by WindowAccessor in .background - } - .onDisappear { - // Mark teardown intent synchronously so deinit doesn't warn - // if SwiftUI deallocates the coordinator before the delayed Task fires - coordinator.markTeardownScheduled() + // (NSToolbar install moved to `configureWindow(_:)` — at onAppear + // time `viewWindow` is still nil because WindowAccessor fires its + // callback on viewDidMoveToWindow, which runs AFTER SwiftUI's + // onAppear in NSHostingView-hosted content.) - let capturedWindowId = windowId + // Wire view-layer callbacks invoked by TabWindowController's + // NSWindowDelegate → coordinator lifecycle methods. The closures + // capture SwiftUI-scoped state (tables binding, sidebarState, + // rightPanelState) that the coordinator can't reach directly. let connectionId = connection.id - Task { @MainActor in - // Grace period: SwiftUI fires onDisappear transiently during tab group - // merges/splits, then re-fires onAppear shortly after. The onAppear - // handler re-registers via WindowLifecycleMonitor on DispatchQueue.main.async, - // so this delay must exceed that dispatch latency to avoid tearing down - // a window that's about to reappear. - try? await Task.sleep(for: Self.tabGroupMergeGracePeriod) - - // If this window re-registered (temporary disappear during tab group merge), skip cleanup - if WindowLifecycleMonitor.shared.isRegistered(windowId: capturedWindowId) { - coordinator.clearTeardownScheduled() - return + coordinator.onWindowBecameKey = { [tabManager, sidebarState] in + // Read tables fresh from DatabaseManager every invocation — + // capturing the @Binding's wrappedValue (or `tables` + // shorthand) snapshots an empty array at onAppear time + // because the schema load is async, and the closure is + // installed once but invoked on every windowDidBecomeKey. + let liveTables = DatabaseManager.shared + .session(for: connectionId)?.tables ?? [] + let target: Set + if let currentTableName = tabManager.selectedTab?.tableName, + let match = liveTables.first(where: { $0.name == currentTableName }) { + target = [match] + } else { + target = [] } - - // Window truly closed — teardown coordinator - coordinator.teardown() - rightPanelState.teardown() - - // If no more windows for this connection, disconnect. - // Tab state is NOT cleared here — it's preserved for next reconnect. - // Only handleTabsChange(count=0) clears state (user explicitly closed all tabs). - guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { - return + if sidebarState.selectedTables != target { + // Don't clear sidebar selection while tables still loading — + // avoids double-navigation race against SidebarSyncAction. + if target.isEmpty && liveTables.isEmpty { return } + sidebarState.selectedTables = target } - await DatabaseManager.shared.disconnectSession(connectionId) - - // Give SwiftUI/AppKit time to deallocate view hierarchies, - // then hint malloc to return freed pages to the OS - try? await Task.sleep(for: .seconds(2)) - malloc_zone_pressure_relief(nil, 0) } + coordinator.onWindowWillClose = { [rightPanelState] in + rightPanelState.teardown() + } + + Self.lifecycleLogger.info( + "[open] MainContentView.onAppear done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) } .onChange(of: pendingChangeTrigger) { updateToolbarPendingState() } - .userActivity("com.TablePro.viewConnection") { activity in - activity.title = connection.name.isEmpty - ? connection.host - : connection.name - activity.isEligibleForHandoff = true - activity.userInfo = ["connectionId": connection.id.uuidString] - } - .userActivity("com.TablePro.viewTable") { activity in - guard let tableName = tabManager.selectedTab?.tableName else { - activity.invalidate() - return - } - activity.title = tableName - activity.isEligibleForHandoff = true - activity.userInfo = [ - "connectionId": connection.id.uuidString, - "tableName": tableName - ] - } } private var bodyContentCore: some View { mainContentView - .openTableToolbar(state: toolbarState) - .modifier(ToolbarTintModifier(connectionColor: connection.color)) - .task { await initializeAndRestoreTabs() } + // Phase 3: SwiftUI `.toolbar { ... }` removed — NSToolbar is now + // installed directly on NSWindow by TabWindowController (see + // `MainWindowToolbar`). Reuses every existing SwiftUI subview + // (ConnectionStatusView, SafeModeBadgeView, popovers, etc.) via + // `NSHostingView` inside `NSToolbarItem.view`. Connection color + // tint is not yet ported; `ToolbarTintModifier` no-ops under + // NSHostingView so leaving the modifier off has no visible loss. + .task { + let start = Date() + Self.lifecycleLogger.info( + "[open] bodyContentCore.task initializeAndRestoreTabs start windowId=\(windowId, privacy: .public)" + ) + await initializeAndRestoreTabs() + Self.lifecycleLogger.info( + "[open] bodyContentCore.task initializeAndRestoreTabs done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + ) + } .onChange(of: tabManager.selectedTabId) { _, newTabId in + guard !coordinator.isTearingDown else { + Self.lifecycleLogger.debug("[switch] selectedTabId SKIPPED (tearingDown) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)") + return + } + guard previousSelectedTabId != nil || newTabId != nil else { + Self.lifecycleLogger.debug("[switch] selectedTabId SKIPPED (nil→nil) windowId=\(windowId, privacy: .public)") + return + } + let seq = MainContentCoordinator.nextSwitchSeq() + let switchQueued = Date() + Self.lifecycleLogger.debug( + "[switch] selectedTabId changed seq=\(seq) from=\(previousSelectedTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)" + ) + (viewWindow?.windowController as? TabWindowController)?.refreshUserActivity() + if pendingTabSwitch != nil { + Self.lifecycleLogger.debug("[switch] cancelling previous pendingTabSwitch seq=\(seq)") + } pendingTabSwitch?.cancel() pendingTabSwitch = Task { @MainActor in await Task.yield() - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + Self.lifecycleLogger.debug("[switch] pendingTabSwitch CANCELLED seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") + return + } + let handleStart = Date() + Self.lifecycleLogger.debug("[switch] pendingTabSwitch executing seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) previousSelectedTabId = newTabId + Self.lifecycleLogger.debug( + "[switch] handleTabSelectionChange done seq=\(seq) handleMs=\(Int(Date().timeIntervalSince(handleStart) * 1_000)) totalMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))" + ) } } .onChange(of: tabManager.tabs) { _, newTabs in @@ -344,67 +362,17 @@ struct MainContentView: View { } .onChange(of: sidebarState.selectedTables) { _, newTables in + guard !coordinator.isTearingDown else { + Self.lifecycleLogger.debug("[switch] sidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") + return + } handleTableSelectionChange(from: previousSelectedTables, to: newTables) previousSelectedTables = newTables } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) - { notification in - guard let notificationWindow = notification.object as? NSWindow, - notificationWindow === viewWindow - else { return } - isKeyWindow = true - evictionTask?.cancel() - evictionTask = nil - Task { @MainActor in - syncSidebarToCurrentTab() - } - // Lazy-load: execute query for restored tabs that skipped auto-execute, - // or re-query tabs whose row data was evicted while inactive. - // Skip if the user has unsaved changes (in-memory or tab-level). - let hasPendingEdits = - changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) - let isConnected = - DatabaseManager.shared.activeSessions[connection.id]?.isConnected ?? false - let needsLazyLoad = - tabManager.selectedTab.map { tab in - tab.tabType == .table - && (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted) - && (tab.lastExecutedAt == nil || tab.rowBuffer.isEvicted) - && tab.errorMessage == nil - && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } ?? false - // Skip lazy-load if this is a menu-interaction bounce (resign+become within 200ms) - let isMenuBounce = Date().timeIntervalSince(lastResignKeyDate) < 0.2 - if needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce { - coordinator.runQuery() - } - - // Auto-refresh schema for file-based connections (SQLite, DuckDB) - // when window regains focus — catches external modifications. - if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { - Task { await coordinator.refreshTablesIfStale() } - } - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) - { notification in - guard let notificationWindow = notification.object as? NSWindow, - notificationWindow === viewWindow - else { return } - isKeyWindow = false - lastResignKeyDate = Date() - - // Schedule row data eviction for inactive native window-tabs. - // 5s delay avoids thrashing when quickly switching between tabs. - // Per-tab pendingChanges checks inside evictInactiveRowData() protect - // tabs with unsaved changes from eviction. - evictionTask?.cancel() - evictionTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled else { return } - coordinator.evictInactiveRowData() - } - } + // Phase 2: NSWindow.didBecomeKey / .didResignKey observers removed. + // TabWindowController's NSWindowDelegate dispatches to + // MainContentCoordinator.handleWindowDidBecomeKey / handleWindowDidResignKey + // directly — window-scoped, fires once per focus change. .onChange(of: tables) { _, newTables in let syncAction = SidebarSyncAction.resolveOnTablesLoad( newTables: newTables, diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index ccaccd5b..154f8c8e 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -329,13 +329,13 @@ struct ConnectionSwitcherPopover: View { /// (unless the user opted to group all connections in one window). private func openWindowForDifferentConnection(_ payload: EditorTabPayload) { if AppSettingsManager.shared.tabs.groupAllConnectionTabs { - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } else { // Temporarily disable tab merging so the new window opens independently let currentWindow = NSApp.keyWindow let previousMode = currentWindow?.tabbingMode ?? .preferred currentWindow?.tabbingMode = .disallowed - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) DispatchQueue.main.async { currentWindow?.tabbingMode = previousMode } diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index cec244e2..9fcb4c10 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -2,10 +2,13 @@ // WindowTabGroupingTests.swift // TableProTests // -// Tests for correct window tab grouping behavior: -// - Same-connection tabs merge into the same window -// - Different-connection tabs stay in separate windows -// - WindowOpener tracks pending payloads for tab-group attachment +// Tests for `WindowManager.tabbingIdentifier(for:)` — the static helper that +// drives macOS native window tab grouping for main editor windows. +// +// The earlier `WindowOpener.pendingPayloads` / `acknowledgePayload` / +// `consumeOldestPendingConnectionId` queue was removed when +// `WindowManager.openTab` started performing tab-group merge synchronously +// at window-creation time. The corresponding tests have been removed. // import Foundation @@ -16,86 +19,28 @@ import Testing @Suite("WindowTabGrouping") @MainActor struct WindowTabGroupingTests { - // MARK: - WindowOpener pending payload tracking - - @Test("openNativeTab without openWindow falls back to notification and keeps pending") - func openNativeTabWithoutOpenWindowFallsBack() { - let connectionId = UUID() - let opener = WindowOpener.shared - - opener.openWindow = nil - let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: "users") - opener.openNativeTab(payload) - - // Payload stays pending (notification handler will create the window) - #expect(opener.pendingPayloads.contains { $0.id == payload.id }) - // Clean up - opener.acknowledgePayload(payload.id) + init() { + // Tests assume per-connection grouping; reset in case a prior suite changed it. + AppSettingsManager.shared.tabs.groupAllConnectionTabs = false } - @Test("pendingPayloads is empty initially") - func pendingPayloadsEmptyInitially() { - let opener = WindowOpener.shared - for entry in opener.pendingPayloads { - opener.acknowledgePayload(entry.id) - } - - #expect(opener.pendingPayloads.isEmpty) - } - - @Test("acknowledgePayload removes the id from pending") - func acknowledgePayloadRemovesId() { - let opener = WindowOpener.shared - let payloadId = UUID() - - opener.acknowledgePayload(payloadId) - #expect(!opener.pendingPayloads.contains { $0.id == payloadId }) - } - - @Test("consumeOldestPendingConnectionId returns in FIFO order") - func consumeOldestReturnsFIFO() { - let opener = WindowOpener.shared - // Clear any stale state - while opener.consumeOldestPendingConnectionId() != nil {} - - let idA = UUID() - let idB = UUID() - let payloadA = EditorTabPayload(connectionId: idA, tabType: .query) - let payloadB = EditorTabPayload(connectionId: idB, tabType: .query) - - opener.openWindow = nil - opener.openNativeTab(payloadA) - opener.openNativeTab(payloadB) - - let first = opener.consumeOldestPendingConnectionId() - let second = opener.consumeOldestPendingConnectionId() - - #expect(first == idA) - #expect(second == idB) - #expect(opener.consumeOldestPendingConnectionId() == nil) - } - - // MARK: - TabbingIdentifier resolution - - @Test("tabbingIdentifier produces connection-specific identifier") + @Test("tabbingIdentifier produces a connection-specific identifier") func tabbingIdentifierUsesConnectionId() { let connectionId = UUID() let expected = "com.TablePro.main.\(connectionId.uuidString)" - let result = WindowOpener.tabbingIdentifier(for: connectionId) + let result = WindowManager.tabbingIdentifier(for: connectionId) #expect(result == expected) } - // MARK: - Multi-connection tab grouping scenarios - @Test("Two connections produce different tabbingIdentifiers") func twoConnectionsProduceDifferentIdentifiers() { let connectionA = UUID() let connectionB = UUID() - let idA = WindowOpener.tabbingIdentifier(for: connectionA) - let idB = WindowOpener.tabbingIdentifier(for: connectionB) + let idA = WindowManager.tabbingIdentifier(for: connectionA) + let idB = WindowManager.tabbingIdentifier(for: connectionB) #expect(idA != idB) #expect(idA.contains(connectionA.uuidString)) @@ -106,8 +51,8 @@ struct WindowTabGroupingTests { func sameConnectionProducesSameIdentifier() { let connectionId = UUID() - let id1 = WindowOpener.tabbingIdentifier(for: connectionId) - let id2 = WindowOpener.tabbingIdentifier(for: connectionId) + let id1 = WindowManager.tabbingIdentifier(for: connectionId) + let id2 = WindowManager.tabbingIdentifier(for: connectionId) #expect(id1 == id2) }