From 705e351c2b23d33d67d59219bd834b2678f133cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 10:22:32 +0700 Subject: [PATCH 01/18] fix: Cmd+W clears last tab instead of closing connection window --- CHANGELOG.md | 4 ++ TablePro/AppDelegate+WindowConfig.swift | 2 +- TablePro/ContentView.swift | 15 ++++ .../WindowLifecycleMonitor.swift | 8 ++- .../Infrastructure/WindowOpener.swift | 15 ++++ TablePro/Resources/Localizable.xcstrings | 51 ++++++++++++++ TablePro/TableProApp.swift | 29 +++++--- .../Extensions/MainContentView+Setup.swift | 34 ++++++++- .../Main/MainContentCommandActions.swift | 10 +-- .../Views/Main/MainContentCoordinator.swift | 20 ++++++ TablePro/Views/Main/MainContentView.swift | 69 ++++++++++++++++++- 11 files changed, 236 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e98d885..88f0e58d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Cmd+W closing the entire connection window instead of clearing the current tab to the empty state when only one tab was open + ## [0.32.1] - 2026-04-17 ### Changed diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index ae1e35a01..f6188abc2 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 } diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index abddd800d..ce5e70a61 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,6 +36,10 @@ 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 { @@ -65,9 +70,13 @@ struct ContentView: View { if let session = resolvedSession { _rightPanelState = State(initialValue: RightPanelState()) + let factoryStart = Date() let state = SessionStateFactory.create( connection: session.connection, payload: payload ) + Self.lifecycleLogger.info( + "[open] ContentView.init SessionStateFactory.create elapsedMs=\(Int(Date().timeIntervalSince(factoryStart) * 1000)) connId=\(session.connection.id, privacy: .public)" + ) _sessionState = State(initialValue: state) if payload?.intent == .newEmptyTab, let tabTitle = state.coordinator.tabManager.selectedTab?.title { @@ -77,6 +86,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) * 1000))" + ) } var body: some View { @@ -154,6 +166,9 @@ struct ContentView: View { || notificationWindow.subtitle == "\(name) — Preview" }() guard isOurWindow else { return } + Self.lifecycleLogger.info( + "[switch] ContentView.didBecomeKey connId=\(connectionId, privacy: .public) subtitle=\(notificationWindow.subtitle, privacy: .public)" + ) } } diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index fdc6adf78..39f80a0db 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -120,11 +120,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() diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index c75ca71fb..70764a518 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -12,6 +12,7 @@ import SwiftUI @MainActor internal final class WindowOpener { private static let logger = Logger(subsystem: "com.TablePro", category: "WindowOpener") + private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") internal static let shared = WindowOpener() @@ -55,18 +56,32 @@ internal final class WindowOpener { /// 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) { + Self.lifecycleLogger.info( + "[open] t0 WindowOpener.openNativeTab payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute) pendingBefore=\(self.pendingPayloads.count)" + ) pendingPayloads.append((id: payload.id, connectionId: payload.connectionId)) if let openWindow { + let t0 = Date() openWindow(id: "main", value: payload) + Self.lifecycleLogger.info( + "[open] WindowOpener.openWindow() returned payloadId=\(payload.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1000))" + ) } else { Self.logger.info("openWindow not set — falling back to .openMainWindow notification") + Self.lifecycleLogger.info( + "[open] fallback to .openMainWindow notification payloadId=\(payload.id, privacy: .public)" + ) NotificationCenter.default.post(name: .openMainWindow, object: payload) } } /// Called by MainContentView.configureWindow after the window is fully set up. internal func acknowledgePayload(_ id: UUID) { + let before = pendingPayloads.count pendingPayloads.removeAll { $0.id == id } + Self.lifecycleLogger.info( + "[open] WindowOpener.acknowledgePayload payloadId=\(id, privacy: .public) pending=\(before)->\(self.pendingPayloads.count)" + ) } /// Consumes and returns the connectionId for the oldest pending payload. diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d93e02e0d..fdbd71b71 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" : { @@ -16541,8 +16559,12 @@ }, "Format Query" : { + }, + "Format Query (⇧⌘L)" : { + }, "Format Query (⌥⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -26344,6 +26366,9 @@ } } } + }, + "Preferred" : { + }, "Preserve all values as strings" : { "extractionState" : "stale", @@ -27622,8 +27647,12 @@ } } } + }, + "Quick Switcher (⇧⌘O)" : { + }, "Quick Switcher (⌘P)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27666,6 +27695,9 @@ } } } + }, + "Quit Anyway" : { + }, "Quote" : { "extractionState" : "stale", @@ -30185,6 +30217,9 @@ } } } + }, + "Save passphrase in Keychain" : { + }, "Save Sidebar Changes" : { "localizations" : { @@ -32493,6 +32528,9 @@ } } } + }, + "Some tabs have unsaved edits. Quitting will discard these changes." : { + }, "Something went wrong (error %d). Try again in a moment." : { "localizations" : { @@ -33093,6 +33131,9 @@ } } } + }, + "SSH Key Passphrase Required" : { + }, "SSH Port" : { "localizations" : { @@ -35194,6 +35235,9 @@ } } } + }, + "The following plugins were rejected:\n\n%@\n\nPlease update them from the plugin registry." : { + }, "The license has been suspended." : { "localizations" : { @@ -36305,8 +36349,12 @@ } } } + }, + "Toggle Filters (⇧⌘F)" : { + }, "Toggle Filters (⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39107,6 +39155,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 838e2e34a..762795f1b 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -108,6 +108,19 @@ struct AppMenuCommands: Commands { 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, + let windowId = WindowLifecycleMonitor.shared.windowId(forWindow: window), + let coordinator = MainContentCoordinator.coordinator(for: windowId) + else { return nil } + return coordinator.commandActions + } + var body: some Commands { // Custom About window + Check for Updates CommandGroup(replacing: .appInfo) { @@ -180,17 +193,11 @@ struct AppMenuCommands: Commands { .disabled(!(actions?.isConnected ?? false)) Button(actions != nil ? "Close Tab" : "Close") { - if let actions { - actions.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 { - window.performClose(nil) - } + if let resolved = resolvedCloseTabActions { + resolved.closeTab() + } else if let window = NSApp.keyWindow, + window.identifier?.rawValue.hasPrefix("main") != true { + window.performClose(nil) } } .optionalKeyboardShortcut(shortcut(for: .closeTab)) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 38b8e02c4..efe1dccae 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -12,15 +12,30 @@ 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) * 1000))" + ) + } 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 +94,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) * 1000))" + ) if !result.tabs.isEmpty { var restoredTabs = result.tabs for i in restoredTabs.indices where restoredTabs[i].tabType == .table { @@ -179,6 +201,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" @@ -211,6 +237,9 @@ extension MainContentView { // Update command actions window reference now that it's available commandActions?.window = window + MainContentView.lifecycleLogger.info( + "[open] configureWindow done windowId=\(windowId, privacy: .public) tabbingId=\(resolvedId, privacy: .public) isPreview=\(isPreview) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1000))" + ) } func setupCommandActions() { @@ -230,6 +259,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 3254a4fe7..7667ece96 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -369,13 +369,13 @@ final class MainContentCommandActions { } private func performClose() { - guard let keyWindow = NSApp.keyWindow else { return } - let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] + guard let window = coordinator?.contentWindow ?? NSApp.keyWindow else { return } + let visibleTabbedWindows = (window.tabbedWindows ?? [window]).filter(\.isVisible) - 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() diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1622a8d94..fbe75c2f7 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -63,6 +63,7 @@ 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") /// Posted during teardown so DataGridView coordinators can release cell views. /// Object is the connection UUID. @@ -108,6 +109,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 @@ -300,6 +305,7 @@ final class MainContentCoordinator { columnVisibilityManager: ColumnVisibilityManager, toolbarState: ConnectionToolbarState ) { + let initStart = Date() self.connection = connection self.tabManager = tabManager self.changeManager = changeManager @@ -345,9 +351,13 @@ final class MainContentCoordinator { } _ = Self.registerTerminationObserver + Self.lifecycleLogger.info( + "[open] MainContentCoordinator.init done connId=\(connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1000))" + ) } func markActivated() { + let start = Date() _didActivate.withLock { $0 = true } registerForPersistence() setupPluginDriver() @@ -362,6 +372,9 @@ final class MainContentCoordinator { } } } + Self.lifecycleLogger.info( + "[open] MainContentCoordinator.markActivated done connId=\(self.connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1000))" + ) } /// Start watching the database file for external changes (SQLite, DuckDB). @@ -458,6 +471,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 +536,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) * 1000))" + ) } deinit { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index bb7a46d3b..b5843f759 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 @@ -237,6 +240,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) @@ -251,8 +258,15 @@ struct MainContentView: View { coordinator.rightPanelState = rightPanelState // Window registration is handled by WindowAccessor in .background + Self.lifecycleLogger.info( + "[open] MainContentView.onAppear done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1000))" + ) } .onDisappear { + let onDisappearStart = Date() + Self.lifecycleLogger.info( + "[close] MainContentView.onDisappear windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public) tabs=\(tabManager.tabs.count)" + ) // Mark teardown intent synchronously so deinit doesn't warn // if SwiftUI deallocates the coordinator before the delayed Task fires coordinator.markTeardownScheduled() @@ -266,29 +280,49 @@ struct MainContentView: View { // 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) + Self.lifecycleLogger.info( + "[close] grace period done windowId=\(capturedWindowId, privacy: .public) sinceOnDisappearMs=\(Int(Date().timeIntervalSince(onDisappearStart) * 1000))" + ) // If this window re-registered (temporary disappear during tab group merge), skip cleanup if WindowLifecycleMonitor.shared.isRegistered(windowId: capturedWindowId) { + Self.lifecycleLogger.info( + "[close] skipped (tab-group merge, window re-registered) windowId=\(capturedWindowId, privacy: .public)" + ) coordinator.clearTeardownScheduled() return } // Window truly closed — teardown coordinator + let teardownStart = Date() coordinator.teardown() + Self.lifecycleLogger.info( + "[close] coordinator.teardown done windowId=\(capturedWindowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(teardownStart) * 1000))" + ) 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 { + Self.lifecycleLogger.info( + "[close] sibling windows remain — skipping disconnect connId=\(connectionId, privacy: .public)" + ) return } + let disconnectStart = Date() await DatabaseManager.shared.disconnectSession(connectionId) + Self.lifecycleLogger.info( + "[close] DatabaseManager.disconnectSession done connId=\(connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(disconnectStart) * 1000))" + ) // 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) + Self.lifecycleLogger.info( + "[close] full teardown done windowId=\(capturedWindowId, privacy: .public) totalMs=\(Int(Date().timeIntervalSince(onDisappearStart) * 1000))" + ) } } .onChange(of: pendingChangeTrigger) { @@ -319,14 +353,31 @@ struct MainContentView: View { mainContentView .openTableToolbar(state: toolbarState) .modifier(ToolbarTintModifier(connectionColor: connection.color)) - .task { await initializeAndRestoreTabs() } + .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) * 1000))" + ) + } .onChange(of: tabManager.selectedTabId) { _, newTabId in + let switchQueued = Date() + Self.lifecycleLogger.info( + "[switch] tabManager.selectedTabId changed from=\(previousSelectedTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)" + ) pendingTabSwitch?.cancel() pendingTabSwitch = Task { @MainActor in await Task.yield() guard !Task.isCancelled else { return } + let handleStart = Date() handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) previousSelectedTabId = newTabId + Self.lifecycleLogger.info( + "[switch] handleTabSelectionChange done windowId=\(windowId, privacy: .public) handleMs=\(Int(Date().timeIntervalSince(handleStart) * 1000)) queueToDoneMs=\(Int(Date().timeIntervalSince(switchQueued) * 1000))" + ) } } .onChange(of: tabManager.tabs) { _, newTabs in @@ -352,6 +403,10 @@ struct MainContentView: View { guard let notificationWindow = notification.object as? NSWindow, notificationWindow === viewWindow else { return } + let becomeKeyStart = Date() + Self.lifecycleLogger.info( + "[switch] MainContentView.didBecomeKey windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public) selectedTabId=\(tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" + ) isKeyWindow = true evictionTask?.cancel() evictionTask = nil @@ -377,6 +432,9 @@ struct MainContentView: View { // 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.info( + "[switch] didBecomeKey triggering lazy runQuery windowId=\(windowId, privacy: .public)" + ) coordinator.runQuery() } @@ -385,12 +443,18 @@ struct MainContentView: View { if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { Task { await coordinator.refreshTablesIfStale() } } + Self.lifecycleLogger.info( + "[switch] didBecomeKey handler done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(becomeKeyStart) * 1000)) lazyLoadQueued=\(needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce) menuBounce=\(isMenuBounce)" + ) } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { notification in guard let notificationWindow = notification.object as? NSWindow, notificationWindow === viewWindow else { return } + Self.lifecycleLogger.info( + "[switch] MainContentView.didResignKey windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public)" + ) isKeyWindow = false lastResignKeyDate = Date() @@ -402,6 +466,9 @@ struct MainContentView: View { evictionTask = Task { @MainActor in try? await Task.sleep(for: .seconds(5)) guard !Task.isCancelled else { return } + Self.lifecycleLogger.info( + "[switch] evictInactiveRowData firing (5s after resignKey) windowId=\(windowId, privacy: .public)" + ) coordinator.evictInactiveRowData() } } From cd58a604fd0f18d85fa0c97b2b62dad3c527d75e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 10:23:01 +0700 Subject: [PATCH 02/18] chore: add lifecycle logging to WindowLifecycleMonitor --- .../Infrastructure/WindowLifecycleMonitor.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 39f80a0db..02ab5514d 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 { @@ -206,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) @@ -220,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) * 1000))" + ) } } } From b8fb4c66c0ffcf66d30c4b29195916eabcc43a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 16:10:25 +0700 Subject: [PATCH 03/18] refactor: replace SwiftUI WindowGroup with NSWindowController for main editor windows --- CHANGELOG.md | 16 + TablePro/AppDelegate+ConnectionHandler.swift | 4 +- TablePro/AppDelegate+FileOpen.swift | 12 +- TablePro/AppDelegate+WindowConfig.swift | 62 +-- TablePro/AppDelegate.swift | 1 + TablePro/ContentView.swift | 58 +- .../Database/DatabaseManager+Sessions.swift | 27 +- .../CommandActionsRegistry.swift | 29 + .../Infrastructure/MainWindowToolbar.swift | 507 ++++++++++++++++++ .../Infrastructure/SessionStateFactory.swift | 16 + .../Infrastructure/TabWindowController.swift | 274 ++++++++++ .../WindowLifecycleMonitor.swift | 2 +- .../Infrastructure/WindowManager.swift | 203 +++++++ .../Infrastructure/WindowOpener.swift | 72 +-- TablePro/Resources/Localizable.xcstrings | 6 + TablePro/TableProApp.swift | 52 +- TablePro/ViewModels/WelcomeViewModel.swift | 12 +- .../ConnectionFormView+Helpers.swift | 9 +- .../MainContentCoordinator+FKNavigation.swift | 2 +- .../MainContentCoordinator+Favorites.swift | 2 +- .../MainContentCoordinator+Navigation.swift | 10 +- ...ainContentCoordinator+SidebarActions.swift | 8 +- .../MainContentCoordinator+TabSwitch.swift | 32 +- ...inContentCoordinator+WindowLifecycle.swift | 143 +++++ .../MainContentView+EventHandlers.swift | 2 +- .../Extensions/MainContentView+Setup.swift | 26 +- .../Main/MainContentCommandActions.swift | 17 +- .../Views/Main/MainContentCoordinator.swift | 41 +- TablePro/Views/Main/MainContentView.swift | 213 ++------ .../Toolbar/ConnectionSwitcherPopover.swift | 4 +- .../Services/WindowTabGroupingTests.swift | 6 +- 31 files changed, 1487 insertions(+), 381 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift create mode 100644 TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift create mode 100644 TablePro/Core/Services/Infrastructure/TabWindowController.swift create mode 100644 TablePro/Core/Services/Infrastructure/WindowManager.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f0e58d2..530f71f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Replaced SwiftUI `WindowGroup(for: EditorTabPayload)` with imperative `NSWindowController` (`TabWindowController` + `WindowManager`) for the main editor window — eliminates phantom `ContentView.init` (5–7×) per tab open, removes 200–7000 ms close grace-period delay, and reduces per-focus `windowDidBecomeKey` fan-out from 10–14 handlers to 1 +- Toolbar moved from SwiftUI `.toolbar { ... }` modifier to AppKit `NSToolbar` (`MainWindowToolbar`) so it renders correctly in `NSHostingView`-hosted content; eliminates `Cannot use Scene methods for URL, NSUserActivity...` console warnings +- Toolbar layout matches Apple HIG (Mail / Notes / Music): native `.toggleSidebar` + `sidebarTrackingSeparator` on the left, principal centered via balanced flexible spaces, view actions packed to the right, dedicated Inspector toggle at far right +- Toolbar density reduced to 5 right-side actions (Quick Switcher, New Tab, Filters, Preview SQL, Inspector); Results, Dashboard, History, Export, Import remain accessible via menus and keyboard shortcuts + ### Fixed - Cmd+W closing the entire connection window instead of clearing the current tab to the empty state when only one tab was open +- Welcome window stealing focus during connect, leaving the new editor window with no key window and disabling all menu shortcuts (Cmd+T, Cmd+1...9) until the user clicked back into the content +- Toolbar appearing empty on tabs 2+ in a tab group due to mid-merge `NSToolbar` discard; now re-claimed via KVO and re-keyed if AppKit drops key state during the swap +- Menu shortcuts (Cmd+T, Cmd+1...9) becoming disabled after clicking a toolbar button due to `@FocusedValue(\.commandActions)` resolving nil from the toolbar's NSHostingController scene; new `CommandActionsRegistry` provides a fallback published from `windowDidBecomeKey` +- Inconsistent disabled state between menu shortcuts and toolbar buttons: Cmd+Shift+P now also requires pending data changes; Cmd+S now also requires pending changes; toolbar New Tab / Inspector / Save Changes now check the same conditions as their menu counterparts + +### Added + +- NSUserActivity (`com.TablePro.viewConnection` / `com.TablePro.viewTable`) published from `TabWindowController` for Handoff and Continuity, refreshed on tab-selection change +- Sidebar toggle, principal connection status, and inspector toggle laid out in the unified toolbar following the same patterns as Mail and Apple Music ## [0.32.1] - 2026-04-17 diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index f54617817..92db40d27 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 2c24d3554..0c82da8d5 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 f6188abc2..cb13d0961 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -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,54 +252,12 @@ 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) { @@ -355,7 +313,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 +358,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 7b7cef60d..ea33d7860 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 ce5e70a61..b83396910 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -71,12 +71,26 @@ struct ContentView: View { if let session = resolvedSession { _rightPanelState = State(initialValue: RightPanelState()) let factoryStart = Date() - let state = SessionStateFactory.create( - connection: session.connection, payload: payload - ) - Self.lifecycleLogger.info( - "[open] ContentView.init SessionStateFactory.create elapsedMs=\(Int(Date().timeIntervalSince(factoryStart) * 1000)) connId=\(session.connection.id, privacy: .public)" - ) + // 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 { @@ -87,7 +101,7 @@ struct ContentView: View { _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) * 1000))" + "[open] ContentView.init done payloadId=\(payload?.id.uuidString ?? "nil", privacy: .public) hasSession=\(resolvedSession != nil) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))" ) } @@ -144,32 +158,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 } - Self.lifecycleLogger.info( - "[switch] ContentView.didBecomeKey connId=\(connectionId, privacy: .public) subtitle=\(notificationWindow.subtitle, privacy: .public)" - ) - } + // 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 diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 69812257a..b14fb2b6f 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 000000000..d81ca5eff --- /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 000000000..68b8758e6 --- /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). + + } + + // 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: - Factory + + internal func makeToolbar() -> NSToolbar { + Self.lifecycleLogger.info("[open] MainWindowToolbar.makeToolbar returning managed instance") + return managedToolbar + } + + // 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("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 7ab632752..9715fd81c 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -19,6 +19,22 @@ 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 create( connection: DatabaseConnection, payload: EditorTabPayload? diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift new file mode 100644 index 000000000..daf1bb098 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -0,0 +1,274 @@ +// +// 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 + +@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 = NSWindow( + 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) { + guard toolbarKVO == nil else { return } + 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.info( + "[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) { + guard let window = notification.object as? NSWindow, + let coordinator = MainContentCoordinator.coordinator(forWindow: window) + else { return } + installToolbar(coordinator: coordinator) + // Publish the current key window's command actions so menu shortcuts + // (Cmd+T, Cmd+1...9, etc.) stay live even when SwiftUI's + // `@FocusedValue(\.commandActions)` resolves to nil — happens when a + // toolbar Button's NSHostingController holds scene focus instead of + // MainContentView's. + CommandActionsRegistry.shared.current = coordinator.commandActions + updateUserActivity(coordinator: coordinator, becomeCurrent: true) + coordinator.handleWindowDidBecomeKey() + } + + internal func windowDidResignKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let coordinator = MainContentCoordinator.coordinator(forWindow: window) + else { return } + activity?.resignCurrent() + coordinator.handleWindowDidResignKey() + } + + internal func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + // Coordinator may be nil during startup races; guard defensively. + let coordinator = MainContentCoordinator.coordinator(forWindow: window) + coordinator?.handleWindowWillClose() + // Clear the registry only if our actions were the published ones — + // otherwise we'd nil out actions that another window just published. + if let actions = coordinator?.commandActions, + CommandActionsRegistry.shared.current === actions { + CommandActionsRegistry.shared.current = nil + } + activity?.invalidate() + activity = nil + } + + // 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, becomeCurrent: false) + } + + private func updateUserActivity(coordinator: MainContentCoordinator, becomeCurrent: Bool) { + 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 + + if becomeCurrent { + activity.becomeCurrent() + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 02ab5514d..6ebeb8af2 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -238,7 +238,7 @@ internal final class WindowLifecycleMonitor { 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) * 1000))" + "[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 000000000..ebd8c4b35 --- /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.consumePending(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 70764a518..e8a746911 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 @@ -12,14 +14,14 @@ import SwiftUI @MainActor internal final class WindowOpener { private static let logger = Logger(subsystem: "com.TablePro", category: "WindowOpener") - private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") internal static let shared = 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 { @@ -31,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 @@ -42,60 +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) { - Self.lifecycleLogger.info( - "[open] t0 WindowOpener.openNativeTab payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute) pendingBefore=\(self.pendingPayloads.count)" - ) - pendingPayloads.append((id: payload.id, connectionId: payload.connectionId)) - if let openWindow { - let t0 = Date() - openWindow(id: "main", value: payload) - Self.lifecycleLogger.info( - "[open] WindowOpener.openWindow() returned payloadId=\(payload.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1000))" - ) - } else { - Self.logger.info("openWindow not set — falling back to .openMainWindow notification") - Self.lifecycleLogger.info( - "[open] fallback to .openMainWindow notification payloadId=\(payload.id, privacy: .public)" - ) - NotificationCenter.default.post(name: .openMainWindow, object: payload) - } - } - - /// Called by MainContentView.configureWindow after the window is fully set up. - internal func acknowledgePayload(_ id: UUID) { - let before = pendingPayloads.count - pendingPayloads.removeAll { $0.id == id } - Self.lifecycleLogger.info( - "[open] WindowOpener.acknowledgePayload payloadId=\(id, privacy: .public) pending=\(before)->\(self.pendingPayloads.count)" - ) - } - - /// 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 fdbd71b71..e788e56f2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -14222,6 +14222,9 @@ } } } + }, + "Export & Import" : { + }, "Export %d Connections..." : { "localizations" : { @@ -28328,6 +28331,9 @@ } } } + }, + "Refresh & Save" : { + }, "Refresh data" : { "extractionState" : "stale", diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 762795f1b..6982e3e93 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,7 +103,20 @@ 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) @@ -184,7 +198,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() @@ -266,7 +286,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() @@ -514,6 +537,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 @@ -539,15 +563,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 { @@ -558,7 +579,8 @@ struct TableProApp: App { .commands { AppMenuCommands( settingsManager: AppSettingsManager.shared, - updaterBridge: updaterBridge + updaterBridge: updaterBridge, + commandRegistry: commandRegistry ) } } @@ -632,9 +654,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 870ce4d98..759081382 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 823656385..f0aa205a3 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+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 7c2e8f1c7..019773ae1 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 bd7f1ce0d..c8108e373 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 08827eba1..57649f8b3 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+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e5dacead0..e805a09a5 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -48,7 +48,7 @@ extension MainContentCoordinator { tabType: .createTable, databaseName: connection.database ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } @@ -71,7 +71,7 @@ extension MainContentCoordinator { databaseName: connection.database, initialQuery: template ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } func editViewDefinition(_ viewName: String) { @@ -85,7 +85,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 +97,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 952483f54..94f8a5fad 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,8 +16,17 @@ extension MainContentCoordinator { selectedRowIndices: inout Set, tabs: [QueryTab] ) { + let start = Date() + Self.lifecycleLogger.info( + "[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.info( + "[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 if let oldId = oldTabId, @@ -83,6 +93,9 @@ extension MainContentCoordinator { } if newTab.databaseName != currentDatabase { + Self.lifecycleLogger.info( + "[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 +126,14 @@ extension MainContentCoordinator { if needsLazyQuery { if let session = DatabaseManager.shared.session(for: connectionId), session.isConnected { + Self.lifecycleLogger.info( + "[switch] handleTabChange lazy query executing (eviction=\(isEvicted)) tabId=\(newId, privacy: .public)" + ) executeTableTabQueryDirectly() } else { + Self.lifecycleLogger.info( + "[switch] handleTabChange lazy query deferred (not connected) tabId=\(newId, privacy: .public)" + ) changeManager.reloadVersion += 1 needsLazyLoad = true } @@ -129,6 +148,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 +174,19 @@ extension MainContentCoordinator { } let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs() - guard sorted.count > maxInactiveLoaded else { return } + guard sorted.count > maxInactiveLoaded else { + Self.lifecycleLogger.info( + "[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.info( + "[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 000000000..19f9e76bc --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -0,0 +1,143 @@ +// +// 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.info( + "[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.info( + "[switch] coordinator triggering lazy runQuery connId=\(self.connectionId, privacy: .public)" + ) + runQuery() + } + + // Auto-refresh schema for file-based connections (SQLite, DuckDB) when the + // window regains focus — catches external modifications. + if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { + Task { await self.refreshTablesIfStale() } + } + + // View-layer: sync sidebar selection (requires access to @Binding tables). + onWindowBecameKey?() + + Self.lifecycleLogger.info( + "[switch] coordinator.handleWindowDidBecomeKey done connId=\(self.connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000)) lazyLoadQueued=\(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.info( + "[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.info( + "[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() + + let closedConnectionId = connectionId + // Disconnect the session if no other windows remain for this connection. + if !WindowLifecycleMonitor.shared.hasWindows(for: closedConnectionId) { + Task { + let t1 = Date() + await DatabaseManager.shared.disconnectSession(closedConnectionId) + Self.lifecycleLogger.info( + "[close] coordinator disconnected last session connId=\(closedConnectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t1) * 1_000))" + ) + } + } + + 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 57a0d4586..f8470c5b5 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -101,7 +101,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 efe1dccae..165532a8c 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -6,6 +6,7 @@ // for MainContentView. Extracted to reduce main view complexity. // +import os import SwiftUI extension MainContentView { @@ -23,7 +24,7 @@ extension MainContentView { Task { await coordinator.loadSchemaIfNeeded() MainContentView.lifecycleLogger.info( - "[open] loadSchemaIfNeeded done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(schemaTaskStart) * 1000))" + "[open] loadSchemaIfNeeded done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(schemaTaskStart) * 1_000))" ) } @@ -103,7 +104,7 @@ extension MainContentView { 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) * 1000))" + "[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 @@ -133,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. @@ -212,7 +213,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 @@ -225,11 +226,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 @@ -237,8 +234,17 @@ extension MainContentView { // Update command actions window reference now that it's available commandActions?.window = window + + // 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) * 1000))" + "[open] configureWindow done windowId=\(windowId, privacy: .public) tabbingId=\(resolvedId, privacy: .public) isPreview=\(isPreview) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 7667ece96..1a56f7606 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,7 +355,7 @@ final class MainContentCommandActions { initialQuery: initialQuery, intent: .newEmptyTab ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } func closeTab() { @@ -805,7 +818,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 fbe75c2f7..9f5654a56 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -162,6 +162,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. @@ -205,6 +229,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 @@ -352,7 +383,7 @@ final class MainContentCoordinator { _ = Self.registerTerminationObserver Self.lifecycleLogger.info( - "[open] MainContentCoordinator.init done connId=\(connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1000))" + "[open] MainContentCoordinator.init done connId=\(connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))" ) } @@ -373,7 +404,7 @@ final class MainContentCoordinator { } } Self.lifecycleLogger.info( - "[open] MainContentCoordinator.markActivated done connId=\(self.connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1000))" + "[open] MainContentCoordinator.markActivated done connId=\(self.connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } @@ -537,7 +568,7 @@ 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) * 1000))" + "[close] MainContentCoordinator.teardown done connId=\(self.connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } @@ -817,7 +848,7 @@ final class MainContentCoordinator { tabType: .query, initialQuery: query ) - WindowOpener.shared.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) } } @@ -840,7 +871,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 b5843f759..6781c5081 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -57,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 @@ -257,102 +249,52 @@ struct MainContentView: View { coordinator.aiViewModel = rightPanelState.aiViewModel coordinator.rightPanelState = rightPanelState - // Window registration is handled by WindowAccessor in .background - Self.lifecycleLogger.info( - "[open] MainContentView.onAppear done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1000))" - ) - } - .onDisappear { - let onDisappearStart = Date() - Self.lifecycleLogger.info( - "[close] MainContentView.onDisappear windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public) tabs=\(tabManager.tabs.count)" - ) - // Mark teardown intent synchronously so deinit doesn't warn - // if SwiftUI deallocates the coordinator before the delayed Task fires - coordinator.markTeardownScheduled() - - let capturedWindowId = windowId - 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) - Self.lifecycleLogger.info( - "[close] grace period done windowId=\(capturedWindowId, privacy: .public) sinceOnDisappearMs=\(Int(Date().timeIntervalSince(onDisappearStart) * 1000))" - ) - - // If this window re-registered (temporary disappear during tab group merge), skip cleanup - if WindowLifecycleMonitor.shared.isRegistered(windowId: capturedWindowId) { - Self.lifecycleLogger.info( - "[close] skipped (tab-group merge, window re-registered) windowId=\(capturedWindowId, privacy: .public)" - ) - coordinator.clearTeardownScheduled() - return + // (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.) + + // 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. + coordinator.onWindowBecameKey = { [tabManager, sidebarState, tables] in + let target: Set + if let currentTableName = tabManager.selectedTab?.tableName, + let match = tables.first(where: { $0.name == currentTableName }) { + target = [match] + } else { + target = [] } - - // Window truly closed — teardown coordinator - let teardownStart = Date() - coordinator.teardown() - Self.lifecycleLogger.info( - "[close] coordinator.teardown done windowId=\(capturedWindowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(teardownStart) * 1000))" - ) - 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 { - Self.lifecycleLogger.info( - "[close] sibling windows remain — skipping disconnect connId=\(connectionId, privacy: .public)" - ) - return + if sidebarState.selectedTables != target { + // Don't clear sidebar selection while tables still loading — + // avoids double-navigation race against SidebarSyncAction. + if target.isEmpty && tables.isEmpty { return } + sidebarState.selectedTables = target } - let disconnectStart = Date() - await DatabaseManager.shared.disconnectSession(connectionId) - Self.lifecycleLogger.info( - "[close] DatabaseManager.disconnectSession done connId=\(connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(disconnectStart) * 1000))" - ) - - // 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) - Self.lifecycleLogger.info( - "[close] full teardown done windowId=\(capturedWindowId, privacy: .public) totalMs=\(Int(Date().timeIntervalSince(onDisappearStart) * 1000))" - ) } + 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)) + // 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( @@ -360,7 +302,7 @@ struct MainContentView: View { ) await initializeAndRestoreTabs() Self.lifecycleLogger.info( - "[open] bodyContentCore.task initializeAndRestoreTabs done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1000))" + "[open] bodyContentCore.task initializeAndRestoreTabs done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } .onChange(of: tabManager.selectedTabId) { _, newTabId in @@ -368,6 +310,9 @@ struct MainContentView: View { Self.lifecycleLogger.info( "[switch] tabManager.selectedTabId changed from=\(previousSelectedTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)" ) + // Refresh Handoff activity (viewConnection ↔ viewTable + tableName) + // when the selected tab changes while this window is key. + (viewWindow?.windowController as? TabWindowController)?.refreshUserActivity() pendingTabSwitch?.cancel() pendingTabSwitch = Task { @MainActor in await Task.yield() @@ -376,7 +321,7 @@ struct MainContentView: View { handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) previousSelectedTabId = newTabId Self.lifecycleLogger.info( - "[switch] handleTabSelectionChange done windowId=\(windowId, privacy: .public) handleMs=\(Int(Date().timeIntervalSince(handleStart) * 1000)) queueToDoneMs=\(Int(Date().timeIntervalSince(switchQueued) * 1000))" + "[switch] handleTabSelectionChange done windowId=\(windowId, privacy: .public) handleMs=\(Int(Date().timeIntervalSince(handleStart) * 1_000)) queueToDoneMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))" ) } } @@ -398,80 +343,10 @@ struct MainContentView: View { 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 } - let becomeKeyStart = Date() - Self.lifecycleLogger.info( - "[switch] MainContentView.didBecomeKey windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public) selectedTabId=\(tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" - ) - 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 { - Self.lifecycleLogger.info( - "[switch] didBecomeKey triggering lazy runQuery windowId=\(windowId, privacy: .public)" - ) - 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() } - } - Self.lifecycleLogger.info( - "[switch] didBecomeKey handler done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(becomeKeyStart) * 1000)) lazyLoadQueued=\(needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce) menuBounce=\(isMenuBounce)" - ) - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) - { notification in - guard let notificationWindow = notification.object as? NSWindow, - notificationWindow === viewWindow - else { return } - Self.lifecycleLogger.info( - "[switch] MainContentView.didResignKey windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public)" - ) - 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 } - Self.lifecycleLogger.info( - "[switch] evictInactiveRowData firing (5s after resignKey) windowId=\(windowId, privacy: .public)" - ) - 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 ccaccd5bf..154f8c8e8 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 cec244e2f..b4424e2c1 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -25,7 +25,7 @@ struct WindowTabGroupingTests { opener.openWindow = nil let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: "users") - opener.openNativeTab(payload) + WindowManager.shared.openTab(payload: payload) // Payload stays pending (notification handler will create the window) #expect(opener.pendingPayloads.contains { $0.id == payload.id }) @@ -64,8 +64,8 @@ struct WindowTabGroupingTests { let payloadB = EditorTabPayload(connectionId: idB, tabType: .query) opener.openWindow = nil - opener.openNativeTab(payloadA) - opener.openNativeTab(payloadB) + WindowManager.shared.openTab(payload: payloadA) + WindowManager.shared.openTab(payload: payloadB) let first = opener.consumeOldestPendingConnectionId() let second = opener.consumeOldestPendingConnectionId() From 921e8c838c3eff9b014bfbcc89c931a8828d8ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 16:18:57 +0700 Subject: [PATCH 04/18] fix: publish command actions on configureWindow so Cmd+T works after first connect --- .../Extensions/MainContentView+Setup.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 165532a8c..a511b2044 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -235,6 +235,24 @@ 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 From 734ad92d95d31a0d638dd1b8b5c826fdd6bbd617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 16:57:18 +0700 Subject: [PATCH 05/18] fix: address ultrareview findings on window/toolbar refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug 004: NSUserActivity becomeCurrent() was skipped on type-flip (viewConnection ↔ viewTable), silently dropping Handoff mid-session. Drop the becomeCurrent: Bool parameter; both call sites already guard on key-window so always promote the activity. - Bug 005: onWindowBecameKey closure captured @Binding tables as a frozen value at onAppear time (an empty array, since schema load is async). Read tables fresh from DatabaseManager.session each invocation. - Bug 001: WindowTabGroupingTests still referenced removed APIs (pendingPayloads, acknowledgePayload, consumeOldestPendingConnectionId, WindowOpener.tabbingIdentifier). Drop the obsolete tests; keep the tabbingIdentifier coverage on WindowManager. --- .../Infrastructure/TabWindowController.swift | 17 ++-- TablePro/Views/Main/MainContentView.swift | 14 ++- .../Services/WindowTabGroupingTests.swift | 86 +++---------------- 3 files changed, 35 insertions(+), 82 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index daf1bb098..e7191053c 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -204,7 +204,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { // toolbar Button's NSHostingController holds scene focus instead of // MainContentView's. CommandActionsRegistry.shared.current = coordinator.commandActions - updateUserActivity(coordinator: coordinator, becomeCurrent: true) + updateUserActivity(coordinator: coordinator) coordinator.handleWindowDidBecomeKey() } @@ -241,10 +241,10 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window, window.isKeyWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } - updateUserActivity(coordinator: coordinator, becomeCurrent: false) + updateUserActivity(coordinator: coordinator) } - private func updateUserActivity(coordinator: MainContentCoordinator, becomeCurrent: Bool) { + private func updateUserActivity(coordinator: MainContentCoordinator) { let connection = coordinator.connection let selectedTab = coordinator.tabManager.selectedTab let tableName: String? = (selectedTab?.tabType == .table) ? selectedTab?.tableName : nil @@ -267,8 +267,13 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { } activity.userInfo = info - if becomeCurrent { - activity.becomeCurrent() - } + // Always promote to current. Both call sites (`windowDidBecomeKey` and + // `refreshUserActivity` which guards on `window.isKeyWindow`) only + // invoke this method when the window owns Handoff. The previous + // `becomeCurrent: Bool` parameter dropped Continuity mid-session + // whenever the user switched between table and query tabs in the + // same window — the type-flip branch above invalidated the old + // activity but never promoted the replacement. + activity.becomeCurrent() } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 6781c5081..9956cdd15 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -258,10 +258,18 @@ struct MainContentView: View { // NSWindowDelegate → coordinator lifecycle methods. The closures // capture SwiftUI-scoped state (tables binding, sidebarState, // rightPanelState) that the coordinator can't reach directly. - coordinator.onWindowBecameKey = { [tabManager, sidebarState, tables] in + let connectionId = connection.id + 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 = tables.first(where: { $0.name == currentTableName }) { + let match = liveTables.first(where: { $0.name == currentTableName }) { target = [match] } else { target = [] @@ -269,7 +277,7 @@ struct MainContentView: View { if sidebarState.selectedTables != target { // Don't clear sidebar selection while tables still loading — // avoids double-navigation race against SidebarSyncAction. - if target.isEmpty && tables.isEmpty { return } + if target.isEmpty && liveTables.isEmpty { return } sidebarState.selectedTables = target } } diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index b4424e2c1..d43a20d64 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,23 @@ 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") - WindowManager.shared.openTab(payload: payload) - - // Payload stays pending (notification handler will create the window) - #expect(opener.pendingPayloads.contains { $0.id == payload.id }) - // Clean up - opener.acknowledgePayload(payload.id) - } - - @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 - WindowManager.shared.openTab(payload: payloadA) - WindowManager.shared.openTab(payload: 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 +46,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) } From 7bd15becbd95ed0ca65b85897ca69cd441e94cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 17:18:46 +0700 Subject: [PATCH 06/18] fix: open ER Diagram and Server Dashboard in new window tab instead of replacing current tab --- CHANGELOG.md | 1 + .../MainContentCoordinator+ERDiagram.swift | 33 +++++++++++++++++-- ...inContentCoordinator+ServerDashboard.swift | 30 +++++++++++++++-- ...ainContentCoordinator+SidebarActions.swift | 4 --- .../Main/MainContentCommandActions.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 12 +++++++ 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 530f71f5b..2e4aa36df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Toolbar appearing empty on tabs 2+ in a tab group due to mid-merge `NSToolbar` discard; now re-claimed via KVO and re-keyed if AppKit drops key state during the swap - Menu shortcuts (Cmd+T, Cmd+1...9) becoming disabled after clicking a toolbar button due to `@FocusedValue(\.commandActions)` resolving nil from the toolbar's NSHostingController scene; new `CommandActionsRegistry` provides a fallback published from `windowDidBecomeKey` - Inconsistent disabled state between menu shortcuts and toolbar buttons: Cmd+Shift+P now also requires pending data changes; Cmd+S now also requires pending changes; toolbar New Tab / Inspector / Save Changes now check the same conditions as their menu counterparts +- View → ER Diagram and View → Server Dashboard silently replacing the current tab's content instead of opening in a new window tab; now focus an existing matching window if present, otherwise open a new native window tab (Server Dashboard is deduped per connection; ER Diagram per connection/schema) ### Added diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift index 64f99f40d..4a89d6fd7 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+ServerDashboard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift index d6fb31ca4..71dc5b840 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 e805a09a5..93d399754 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -52,10 +52,6 @@ extension MainContentCoordinator { } } - func showERDiagram() { - openERDiagramTab() - } - // MARK: - View Operations func createView() { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 1a56f7606..fbaf94b69 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -492,7 +492,7 @@ final class MainContentCommandActions { } func showServerDashboard() { - coordinator?.openServerDashboardTab() + coordinator?.showServerDashboard() } var supportsServerDashboard: Bool { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 9f5654a56..b9ea167f4 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -250,6 +250,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 From 73d8aaff9b1e3d491cd9b81a597335705c91ef73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 17:21:00 +0700 Subject: [PATCH 07/18] docs: simplify CHANGELOG entry for ER Diagram/Server Dashboard fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4aa36df..15eb81a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Toolbar appearing empty on tabs 2+ in a tab group due to mid-merge `NSToolbar` discard; now re-claimed via KVO and re-keyed if AppKit drops key state during the swap - Menu shortcuts (Cmd+T, Cmd+1...9) becoming disabled after clicking a toolbar button due to `@FocusedValue(\.commandActions)` resolving nil from the toolbar's NSHostingController scene; new `CommandActionsRegistry` provides a fallback published from `windowDidBecomeKey` - Inconsistent disabled state between menu shortcuts and toolbar buttons: Cmd+Shift+P now also requires pending data changes; Cmd+S now also requires pending changes; toolbar New Tab / Inspector / Save Changes now check the same conditions as their menu counterparts -- View → ER Diagram and View → Server Dashboard silently replacing the current tab's content instead of opening in a new window tab; now focus an existing matching window if present, otherwise open a new native window tab (Server Dashboard is deduped per connection; ER Diagram per connection/schema) +- View → ER Diagram and Server Dashboard silently replacing the current tab; now opens in a new window tab or focuses an existing one ### Added From c5a47e4a251aa10adb33c626696f2e5fe5f9c006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 17 Apr 2026 17:21:35 +0700 Subject: [PATCH 08/18] docs: simplify unreleased CHANGELOG entries --- CHANGELOG.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15eb81a03..8ebb0a5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,24 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Replaced SwiftUI `WindowGroup(for: EditorTabPayload)` with imperative `NSWindowController` (`TabWindowController` + `WindowManager`) for the main editor window — eliminates phantom `ContentView.init` (5–7×) per tab open, removes 200–7000 ms close grace-period delay, and reduces per-focus `windowDidBecomeKey` fan-out from 10–14 handlers to 1 -- Toolbar moved from SwiftUI `.toolbar { ... }` modifier to AppKit `NSToolbar` (`MainWindowToolbar`) so it renders correctly in `NSHostingView`-hosted content; eliminates `Cannot use Scene methods for URL, NSUserActivity...` console warnings -- Toolbar layout matches Apple HIG (Mail / Notes / Music): native `.toggleSidebar` + `sidebarTrackingSeparator` on the left, principal centered via balanced flexible spaces, view actions packed to the right, dedicated Inspector toggle at far right -- Toolbar density reduced to 5 right-side actions (Quick Switcher, New Tab, Filters, Preview SQL, Inspector); Results, Dashboard, History, Export, Import remain accessible via menus and keyboard shortcuts +- Rewrote main editor window on AppKit (`NSWindowController` + `NSToolbar`) for faster tab opens and deterministic lifecycle +- Toolbar layout reorganized to match Apple HIG (Mail / Notes / Music) with sidebar toggle on the left, connection in the center, and view actions on the right ### Fixed -- Cmd+W closing the entire connection window instead of clearing the current tab to the empty state when only one tab was open -- Welcome window stealing focus during connect, leaving the new editor window with no key window and disabling all menu shortcuts (Cmd+T, Cmd+1...9) until the user clicked back into the content -- Toolbar appearing empty on tabs 2+ in a tab group due to mid-merge `NSToolbar` discard; now re-claimed via KVO and re-keyed if AppKit drops key state during the swap -- Menu shortcuts (Cmd+T, Cmd+1...9) becoming disabled after clicking a toolbar button due to `@FocusedValue(\.commandActions)` resolving nil from the toolbar's NSHostingController scene; new `CommandActionsRegistry` provides a fallback published from `windowDidBecomeKey` -- Inconsistent disabled state between menu shortcuts and toolbar buttons: Cmd+Shift+P now also requires pending data changes; Cmd+S now also requires pending changes; toolbar New Tab / Inspector / Save Changes now check the same conditions as their menu counterparts +- Cmd+W closed the entire connection window instead of clearing the last tab to empty state +- Welcome window stealing focus during connect, disabling menu shortcuts until the user clicked into the new window +- Toolbar appearing empty on tabs 2+ in a tab group +- Menu shortcuts (Cmd+T, Cmd+1...9) becoming disabled after clicking a toolbar button +- Inconsistent disabled state between menu shortcuts and toolbar buttons (Save Changes, Preview SQL, New Tab, Inspector) - View → ER Diagram and Server Dashboard silently replacing the current tab; now opens in a new window tab or focuses an existing one ### Added -- NSUserActivity (`com.TablePro.viewConnection` / `com.TablePro.viewTable`) published from `TabWindowController` for Handoff and Continuity, refreshed on tab-selection change -- Sidebar toggle, principal connection status, and inspector toggle laid out in the unified toolbar following the same patterns as Mail and Apple Music +- Handoff / Continuity support via NSUserActivity, refreshed on tab-selection change ## [0.32.1] - 2026-04-17 From 7b9ad3d2f7f1a44ba96c334abb4032020287e696 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 19:26:27 +0700 Subject: [PATCH 09/18] refactor: address code review findings --- .../Core/Services/Infrastructure/MainWindowToolbar.swift | 7 ------- .../Core/Services/Infrastructure/SessionStateFactory.swift | 4 ++++ .../Core/Services/Infrastructure/TabWindowController.swift | 3 ++- TablePro/Core/Services/Infrastructure/WindowManager.swift | 2 +- TableProTests/Core/Services/WindowTabGroupingTests.swift | 4 ++++ 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 68b8758e6..0a070fba9 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -82,13 +82,6 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { private static let refreshSaveGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.refreshSaveGroup") private static let exportImportGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.exportImportGroup") - // MARK: - Factory - - internal func makeToolbar() -> NSToolbar { - Self.lifecycleLogger.info("[open] MainWindowToolbar.makeToolbar returning managed instance") - return managedToolbar - } - // MARK: - NSToolbarDelegate internal func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 9715fd81c..ffed7071f 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -35,6 +35,10 @@ enum SessionStateFactory { 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/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index e7191053c..cfc8d0779 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -160,7 +160,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { /// 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) { - guard toolbarKVO == nil else { return } + 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 diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index ebd8c4b35..d4a18eedd 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -72,7 +72,7 @@ internal final class WindowManager { "[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.consumePending(for: payload.id) + SessionStateFactory.removePending(for: payload.id) return } diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index d43a20d64..9a7ae352d 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -19,6 +19,10 @@ import Testing @Suite("WindowTabGrouping") @MainActor struct WindowTabGroupingTests { + init() { + AppSettingsManager.shared.tabs.groupAllConnectionTabs = false + } + @Test("tabbingIdentifier produces a connection-specific identifier") func tabbingIdentifierUsesConnectionId() { let connectionId = UUID() From 4ec9d84617144627c60cd7f69582a77020e21785 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 19:46:17 +0700 Subject: [PATCH 10/18] fix: add debug logging for rapid tab switch/close delay investigation --- TablePro/AppDelegate+WindowConfig.swift | 7 ++++- .../TabPersistenceCoordinator.swift | 5 ++++ .../Infrastructure/TabWindowController.swift | 27 +++++++++++++------ .../MainContentCoordinator+TabSwitch.swift | 17 ++++++++---- ...inContentCoordinator+WindowLifecycle.swift | 8 +++--- .../MainContentView+EventHandlers.swift | 20 ++++++++------ .../Main/MainContentCommandActions.swift | 5 ++++ .../Views/Main/MainContentCoordinator.swift | 7 +++++ TablePro/Views/Main/MainContentView.swift | 16 +++++++---- 9 files changed, 81 insertions(+), 31 deletions(-) diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index cb13d0961..51af4c053 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -261,20 +261,25 @@ extension AppDelegate { } @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) { diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index f2266d486..40f80ed0b 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.info("[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.info("[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 index cfc8d0779..73d750097 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -195,41 +195,52 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { // 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.info( + "[switch] windowDidBecomeKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public) connId=\(coordinator.connectionId, privacy: .public)" + ) installToolbar(coordinator: coordinator) - // Publish the current key window's command actions so menu shortcuts - // (Cmd+T, Cmd+1...9, etc.) stay live even when SwiftUI's - // `@FocusedValue(\.commandActions)` resolves to nil — happens when a - // toolbar Button's NSHostingController holds scene focus instead of - // MainContentView's. + Self.lifecycleLogger.info("[switch] windowDidBecomeKey seq=\(seq) installToolbar ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") CommandActionsRegistry.shared.current = coordinator.commandActions updateUserActivity(coordinator: coordinator) + Self.lifecycleLogger.info("[switch] windowDidBecomeKey seq=\(seq) userActivity ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") coordinator.handleWindowDidBecomeKey() + Self.lifecycleLogger.info("[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.info( + "[switch] windowDidResignKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public)" + ) activity?.resignCurrent() coordinator.handleWindowDidResignKey() + Self.lifecycleLogger.info("[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 } - // Coordinator may be nil during startup races; guard defensively. + Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)") let coordinator = MainContentCoordinator.coordinator(forWindow: window) coordinator?.handleWindowWillClose() - // Clear the registry only if our actions were the published ones — - // otherwise we'd nil out actions that another window just published. + 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 diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 94f8a5fad..6c8d18c50 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -28,7 +28,8 @@ extension MainContentCoordinator { ) } - // 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 }) { @@ -42,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] @@ -79,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.info( + "[switch] handleTabChange phases: saveOutgoing=\(saveMs)ms evict=\(evictMs)ms restoreIncoming=\(restoreMs)ms" + ) if !newTab.databaseName.isEmpty { let currentDatabase: String diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 19f9e76bc..e3785feaa 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -54,18 +54,18 @@ extension MainContentCoordinator { ) runQuery() } + let t1 = Date() - // Auto-refresh schema for file-based connections (SQLite, DuckDB) when the - // window regains focus — catches external modifications. if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { Task { await self.refreshTablesIfStale() } } + let t2 = Date() - // View-layer: sync sidebar selection (requires access to @Binding tables). onWindowBecameKey?() + let t3 = Date() Self.lifecycleLogger.info( - "[switch] coordinator.handleWindowDidBecomeKey done connId=\(self.connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000)) lazyLoadQueued=\(needsLazyLoad && !hasPendingEdits && isConnected && !isMenuBounce) menuBounce=\(isMenuBounce)" + "[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)" ) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index f8470c5b5..6c3874765 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -6,48 +6,49 @@ // Extracted to reduce main view complexity. // +import os import SwiftUI extension MainContentView { // MARK: - Event Handlers func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { + 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.info( + "[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]) { + 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 +61,9 @@ extension MainContentView { selectedTabId: normalizedSelectedId ) } + MainContentView.lifecycleLogger.info( + "[switch] handleTabsChange tabCount=\(newTabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" + ) } func handleColumnsChange(newColumns: [String]?) { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index fbaf94b69..27f144a35 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -359,6 +359,8 @@ final class MainContentCommandActions { } 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 @@ -382,8 +384,10 @@ final class MainContentCommandActions { } private func performClose() { + 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 visibleTabbedWindows.count > 1 { window.close() @@ -397,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 { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index b9ea167f4..29807447a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -65,6 +65,13 @@ 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. + @ObservationIgnored private(set) 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. static let teardownNotification = Notification.Name("MainContentCoordinator.teardown") diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 9956cdd15..0245b66fd 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -314,22 +314,28 @@ struct MainContentView: View { ) } .onChange(of: tabManager.selectedTabId) { _, newTabId in + let seq = MainContentCoordinator.nextSwitchSeq() let switchQueued = Date() Self.lifecycleLogger.info( - "[switch] tabManager.selectedTabId changed from=\(previousSelectedTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)" + "[switch] selectedTabId changed seq=\(seq) from=\(previousSelectedTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)" ) - // Refresh Handoff activity (viewConnection ↔ viewTable + tableName) - // when the selected tab changes while this window is key. (viewWindow?.windowController as? TabWindowController)?.refreshUserActivity() + if pendingTabSwitch != nil { + Self.lifecycleLogger.info("[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.info("[switch] pendingTabSwitch CANCELLED seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") + return + } let handleStart = Date() + Self.lifecycleLogger.info("[switch] pendingTabSwitch executing seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) previousSelectedTabId = newTabId Self.lifecycleLogger.info( - "[switch] handleTabSelectionChange done windowId=\(windowId, privacy: .public) handleMs=\(Int(Date().timeIntervalSince(handleStart) * 1_000)) queueToDoneMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))" + "[switch] handleTabSelectionChange done seq=\(seq) handleMs=\(Int(Date().timeIntervalSince(handleStart) * 1_000)) totalMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))" ) } } From 96550225dff61f2387e0001aaa4d333a76c4d6c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 20:18:17 +0700 Subject: [PATCH 11/18] fix: defer body content and coalesce schema loads for rapid tab operations --- TablePro/ContentView.swift | 6 ++-- .../Core/Autocomplete/SQLSchemaProvider.swift | 36 ++++++++++++++----- .../MainContentView+EventHandlers.swift | 3 +- TablePro/Views/Main/MainContentView.swift | 3 ++ 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index b83396910..619e79acf 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -29,6 +29,7 @@ struct ContentView: View { @State private var rightPanelState: RightPanelState? @State private var sessionState: SessionStateFactory.SessionState? @State private var inspectorContext = InspectorContext.empty + @State private var isContentReady = false @State private var windowTitle: String @Environment(\.openWindow) private var openWindow @@ -170,7 +171,7 @@ struct ContentView: View { private var mainContent: some View { NavigationSplitView(columnVisibility: $columnVisibility) { // MARK: - Sidebar (Left) - Table Browser - if let currentSession = currentSession, let sessionState { + if isContentReady, let currentSession = currentSession, let sessionState { VStack(spacing: 0) { SidebarView( tables: sessionTablesBinding, @@ -209,7 +210,7 @@ struct ContentView: View { } } detail: { // MARK: - Detail (Main workspace with optional right sidebar) - if let currentSession = currentSession, let rightPanelState, let sessionState { + if isContentReady, let currentSession = currentSession, let rightPanelState, let sessionState { HStack(spacing: 0) { MainContentView( connection: currentSession.connection, @@ -259,6 +260,7 @@ struct ContentView: View { } .navigationTitle(windowTitle) .navigationSubtitle(currentSession?.connection.name ?? "") + .task { isContentReady = true } } // MARK: - Session State Bindings diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index dce5ff970..bea202331 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,40 @@ 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 { + await existing.value + return + } - // Store driver reference for later column fetching 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 + } + + 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/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 6c3874765..96ea2ce00 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -13,6 +13,7 @@ extension MainContentView { // MARK: - Event Handlers func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { + guard !coordinator.isTearingDown else { return } let t0 = Date() coordinator.handleTabChange( from: oldTabId, @@ -39,10 +40,10 @@ extension MainContentView { } func handleTabsChange(_ newTabs: [QueryTab]) { + guard !coordinator.isTearingDown else { return } let t0 = Date() updateWindowTitleAndFileState() - guard !coordinator.isTearingDown else { return } guard !coordinator.isUpdatingColumnLayout else { return } if let tab = tabManager.selectedTab, tab.isPreview, tab.hasUserInteraction { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 0245b66fd..0e4fc7893 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -314,6 +314,8 @@ struct MainContentView: View { ) } .onChange(of: tabManager.selectedTabId) { _, newTabId in + guard !coordinator.isTearingDown else { return } + guard previousSelectedTabId != nil || newTabId != nil else { return } let seq = MainContentCoordinator.nextSwitchSeq() let switchQueued = Date() Self.lifecycleLogger.info( @@ -354,6 +356,7 @@ struct MainContentView: View { } .onChange(of: sidebarState.selectedTables) { _, newTables in + guard !coordinator.isTearingDown else { return } handleTableSelectionChange(from: previousSelectedTables, to: newTables) previousSelectedTables = newTables } From 22e7d301507b6fdb070784d93e047885212468bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 20:25:12 +0700 Subject: [PATCH 12/18] fix: add debug logging for lifecycle guards, schema coalescing, and deferred content --- TablePro/ContentView.swift | 10 +++++++++- .../Core/Autocomplete/SQLSchemaProvider.swift | 6 ++++++ .../MainContentView+EventHandlers.swift | 10 ++++++++-- TablePro/Views/Main/MainContentView.swift | 15 ++++++++++++--- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 619e79acf..ff123e489 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -144,10 +144,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 { @@ -260,7 +262,10 @@ struct ContentView: View { } .navigationTitle(windowTitle) .navigationSubtitle(currentSession?.connection.name ?? "") - .task { isContentReady = true } + .task { + Self.lifecycleLogger.info("[open] ContentView.task isContentReady=true payloadId=\(payload?.id.uuidString ?? "nil", privacy: .public) hasSession=\(sessionState != nil)") + isContentReady = true + } } // MARK: - Session State Bindings @@ -335,6 +340,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 @@ -362,10 +368,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 bea202331..f8cd7bbcc 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -36,10 +36,15 @@ actor SQLSchemaProvider { /// Concurrent callers await the same in-flight Task instead of firing duplicate queries. func loadSchema(using driver: DatabaseDriver, connection: DatabaseConnection? = nil) async { if let existing = loadTask { + Self.logger.info("[schema] loadSchema awaiting existing in-flight task") + let t0 = Date() await existing.value + Self.logger.info("[schema] loadSchema coalesced — awaited existing task ms=\(Int(Date().timeIntervalSince(t0) * 1_000)) tableCount=\(self.tables.count)") return } + Self.logger.info("[schema] loadSchema starting new fetch") + let t0 = Date() self.cachedDriver = driver self.connectionInfo = connection isLoading = true @@ -56,6 +61,7 @@ actor SQLSchemaProvider { 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]) { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 96ea2ce00..04eb537a8 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -13,7 +13,10 @@ extension MainContentView { // MARK: - Event Handlers func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { - guard !coordinator.isTearingDown else { return } + guard !coordinator.isTearingDown else { + MainContentView.lifecycleLogger.info("[switch] handleTabSelectionChange SKIPPED (tearingDown) connId=\(coordinator.connectionId, privacy: .public)") + return + } let t0 = Date() coordinator.handleTabChange( from: oldTabId, @@ -40,7 +43,10 @@ extension MainContentView { } func handleTabsChange(_ newTabs: [QueryTab]) { - guard !coordinator.isTearingDown else { return } + guard !coordinator.isTearingDown else { + MainContentView.lifecycleLogger.info("[switch] handleTabsChange SKIPPED (tearingDown) tabCount=\(newTabs.count) connId=\(coordinator.connectionId, privacy: .public)") + return + } let t0 = Date() updateWindowTitleAndFileState() diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 0e4fc7893..8b20df8d2 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -314,8 +314,14 @@ struct MainContentView: View { ) } .onChange(of: tabManager.selectedTabId) { _, newTabId in - guard !coordinator.isTearingDown else { return } - guard previousSelectedTabId != nil || newTabId != nil else { return } + guard !coordinator.isTearingDown else { + Self.lifecycleLogger.info("[switch] selectedTabId SKIPPED (tearingDown) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)") + return + } + guard previousSelectedTabId != nil || newTabId != nil else { + Self.lifecycleLogger.info("[switch] selectedTabId SKIPPED (nil→nil) windowId=\(windowId, privacy: .public)") + return + } let seq = MainContentCoordinator.nextSwitchSeq() let switchQueued = Date() Self.lifecycleLogger.info( @@ -356,7 +362,10 @@ struct MainContentView: View { } .onChange(of: sidebarState.selectedTables) { _, newTables in - guard !coordinator.isTearingDown else { return } + guard !coordinator.isTearingDown else { + Self.lifecycleLogger.info("[switch] sidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") + return + } handleTableSelectionChange(from: previousSelectedTables, to: newTables) previousSelectedTables = newTables } From 62610b7d589408ffb06e2107d87c4731c52ab41d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 20:27:42 +0700 Subject: [PATCH 13/18] fix: show 'ER Diagram' as window tab title instead of 'SQL Query' --- TablePro/ContentView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index ff123e489..497295bb9 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -45,6 +45,8 @@ struct ContentView: View { let defaultTitle: String if payload?.tabType == .serverDashboard { defaultTitle = String(localized: "Server Dashboard") + } else if payload?.tabType == .erDiagram { + defaultTitle = String(localized: "ER Diagram") } else if let tabTitle = payload?.tabTitle { defaultTitle = tabTitle } else if let tableName = payload?.tableName { From a99e2a9eb72ff325c2f18c2bf594f85589e6d680 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 20:49:33 +0700 Subject: [PATCH 14/18] refactor: fix spinner flash, toolbar retention, localization, tab titles, and dead disconnect branch --- TablePro/ContentView.swift | 11 ++++------- .../Infrastructure/MainWindowToolbar.swift | 9 ++++++++- .../Infrastructure/TabWindowController.swift | 6 ++++++ .../MainContentCoordinator+WindowLifecycle.swift | 14 +++----------- .../Main/Extensions/MainContentView+Setup.swift | 2 ++ 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 497295bb9..d5ed5959c 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -29,7 +29,6 @@ struct ContentView: View { @State private var rightPanelState: RightPanelState? @State private var sessionState: SessionStateFactory.SessionState? @State private var inspectorContext = InspectorContext.empty - @State private var isContentReady = false @State private var windowTitle: String @Environment(\.openWindow) private var openWindow @@ -47,6 +46,8 @@ struct ContentView: View { 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 { @@ -175,7 +176,7 @@ struct ContentView: View { private var mainContent: some View { NavigationSplitView(columnVisibility: $columnVisibility) { // MARK: - Sidebar (Left) - Table Browser - if isContentReady, let currentSession = currentSession, let sessionState { + if let currentSession = currentSession, let sessionState { VStack(spacing: 0) { SidebarView( tables: sessionTablesBinding, @@ -214,7 +215,7 @@ struct ContentView: View { } } detail: { // MARK: - Detail (Main workspace with optional right sidebar) - if isContentReady, let currentSession = currentSession, let rightPanelState, let sessionState { + if let currentSession = currentSession, let rightPanelState, let sessionState { HStack(spacing: 0) { MainContentView( connection: currentSession.connection, @@ -264,10 +265,6 @@ struct ContentView: View { } .navigationTitle(windowTitle) .navigationSubtitle(currentSession?.connection.name ?? "") - .task { - Self.lifecycleLogger.info("[open] ContentView.task isContentReady=true payloadId=\(payload?.id.uuidString ?? "nil", privacy: .public) hasSession=\(sessionState != nil)") - isContentReady = true - } } // MARK: - Session State Bindings diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 0a070fba9..4b334fe64 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -62,6 +62,13 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { } + /// 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") @@ -386,7 +393,7 @@ private struct PreviewSQLToolbarButton: View { coordinator.commandActions?.previewSQL() } label: { let langName = PluginManager.shared.queryLanguageName(for: state.databaseType) - Label("Preview \(langName)", systemImage: "eye") + 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) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 73d750097..ce972787b 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -231,6 +231,12 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { 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))") diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index e3785feaa..b820259c1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -124,17 +124,9 @@ extension MainContentCoordinator { teardown() - let closedConnectionId = connectionId - // Disconnect the session if no other windows remain for this connection. - if !WindowLifecycleMonitor.shared.hasWindows(for: closedConnectionId) { - Task { - let t1 = Date() - await DatabaseManager.shared.disconnectSession(closedConnectionId) - Self.lifecycleLogger.info( - "[close] coordinator disconnected last session connId=\(closedConnectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t1) * 1_000))" - ) - } - } + // 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+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index a511b2044..04f23cfc7 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -187,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 { From ccfa0eea5e10de988a3b3f7fc4d126cee483f5b2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 20:58:33 +0700 Subject: [PATCH 15/18] refactor: downgrade hot-path logging to debug level --- .../Core/Autocomplete/SQLSchemaProvider.swift | 4 ++-- .../TabPersistenceCoordinator.swift | 4 ++-- .../Infrastructure/TabWindowController.swift | 14 +++++++------- .../MainContentCoordinator+TabSwitch.swift | 16 ++++++++-------- .../MainContentCoordinator+WindowLifecycle.swift | 10 +++++----- .../MainContentView+EventHandlers.swift | 8 ++++---- TablePro/Views/Main/MainContentCoordinator.swift | 2 +- TablePro/Views/Main/MainContentView.swift | 16 ++++++++-------- .../Core/Services/WindowTabGroupingTests.swift | 1 + 9 files changed, 38 insertions(+), 37 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index f8cd7bbcc..292ba95ea 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -36,10 +36,10 @@ actor SQLSchemaProvider { /// Concurrent callers await the same in-flight Task instead of firing duplicate queries. func loadSchema(using driver: DatabaseDriver, connection: DatabaseConnection? = nil) async { if let existing = loadTask { - Self.logger.info("[schema] loadSchema awaiting existing in-flight task") + Self.logger.debug("[schema] loadSchema awaiting existing in-flight task") let t0 = Date() await existing.value - Self.logger.info("[schema] loadSchema coalesced — awaited existing task ms=\(Int(Date().timeIntervalSince(t0) * 1_000)) tableCount=\(self.tables.count)") + Self.logger.debug("[schema] loadSchema coalesced — awaited existing task ms=\(Int(Date().timeIntervalSince(t0) * 1_000)) tableCount=\(self.tables.count)") return } diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 40f80ed0b..e8bc381a6 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -48,13 +48,13 @@ internal final class TabPersistenceCoordinator { let connId = connectionId let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId }) ? selectedTabId : nonPreviewTabs.first?.id - Self.logger.info("[persist] saveNow queued tabCount=\(nonPreviewTabs.count) connId=\(connId, privacy: .public)") + 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.info("[persist] saveNow written tabCount=\(persisted.count) connId=\(connId, privacy: .public) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + 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 index ce972787b..4ecb5bd86 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -172,7 +172,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.toolbar !== owner.managedToolbar else { return } let wasKey = window.isKeyWindow - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] KVO toolbar replaced — re-claiming controllerId=\(self.controllerId, privacy: .public) wasKey=\(wasKey)" ) window.toolbar = owner.managedToolbar @@ -200,16 +200,16 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window = notification.object as? NSWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] windowDidBecomeKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public) connId=\(coordinator.connectionId, privacy: .public)" ) installToolbar(coordinator: coordinator) - Self.lifecycleLogger.info("[switch] windowDidBecomeKey seq=\(seq) installToolbar ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + 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.info("[switch] windowDidBecomeKey seq=\(seq) userActivity ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) userActivity ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") coordinator.handleWindowDidBecomeKey() - Self.lifecycleLogger.info("[switch] windowDidBecomeKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } internal func windowDidResignKey(_ notification: Notification) { @@ -218,12 +218,12 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { guard let window = notification.object as? NSWindow, let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] windowDidResignKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public)" ) activity?.resignCurrent() coordinator.handleWindowDidResignKey() - Self.lifecycleLogger.info("[switch] windowDidResignKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + Self.lifecycleLogger.debug("[switch] windowDidResignKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } internal func windowWillClose(_ notification: Notification) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 6c8d18c50..b214c5879 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -17,13 +17,13 @@ extension MainContentCoordinator { tabs: [QueryTab] ) { let start = Date() - Self.lifecycleLogger.info( + 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 - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] handleTabChange done to=\(newTabId?.uuidString ?? "nil", privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } @@ -87,7 +87,7 @@ extension MainContentCoordinator { } let restoreMs = Int(Date().timeIntervalSince(restoreStart) * 1_000) - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] handleTabChange phases: saveOutgoing=\(saveMs)ms evict=\(evictMs)ms restoreIncoming=\(restoreMs)ms" ) @@ -100,7 +100,7 @@ extension MainContentCoordinator { } if newTab.databaseName != currentDatabase { - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] handleTabChange triggering switchDatabase from=\(currentDatabase, privacy: .public) to=\(newTab.databaseName, privacy: .public)" ) changeManager.reloadVersion += 1 @@ -133,12 +133,12 @@ extension MainContentCoordinator { if needsLazyQuery { if let session = DatabaseManager.shared.session(for: connectionId), session.isConnected { - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] handleTabChange lazy query executing (eviction=\(isEvicted)) tabId=\(newId, privacy: .public)" ) executeTableTabQueryDirectly() } else { - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] handleTabChange lazy query deferred (not connected) tabId=\(newId, privacy: .public)" ) changeManager.reloadVersion += 1 @@ -182,7 +182,7 @@ extension MainContentCoordinator { let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs() guard sorted.count > maxInactiveLoaded else { - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] evictInactiveTabs no-op candidates=\(sorted.count) budget=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) return @@ -192,7 +192,7 @@ extension MainContentCoordinator { for tab in toEvict { tab.rowBuffer.evict() } - Self.lifecycleLogger.info( + 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 index b820259c1..ee7f1b805 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -23,7 +23,7 @@ extension MainContentCoordinator { /// sidebar-sync callback set by MainContentView. func handleWindowDidBecomeKey() { let t0 = Date() - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidBecomeKey connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" ) isKeyWindow = true @@ -49,7 +49,7 @@ extension MainContentCoordinator { // 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.info( + Self.lifecycleLogger.debug( "[switch] coordinator triggering lazy runQuery connId=\(self.connectionId, privacy: .public)" ) runQuery() @@ -64,7 +64,7 @@ extension MainContentCoordinator { onWindowBecameKey?() let t3 = Date() - Self.lifecycleLogger.info( + 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)" ) } @@ -73,7 +73,7 @@ extension MainContentCoordinator { /// Schedules a 5s-delayed eviction of row data in inactive tabs; a fresh /// `windowDidBecomeKey` cancels the eviction before it fires. func handleWindowDidResignKey() { - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidResignKey connId=\(self.connectionId, privacy: .public)" ) isKeyWindow = false @@ -83,7 +83,7 @@ extension MainContentCoordinator { evictionTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(5)) guard let self, !Task.isCancelled else { return } - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] coordinator evictInactiveRowData firing (5s after resignKey) connId=\(self.connectionId, privacy: .public)" ) self.evictInactiveRowData() diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 04eb537a8..4b92ffbf5 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -14,7 +14,7 @@ extension MainContentView { func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { guard !coordinator.isTearingDown else { - MainContentView.lifecycleLogger.info("[switch] handleTabSelectionChange SKIPPED (tearingDown) connId=\(coordinator.connectionId, privacy: .public)") + MainContentView.lifecycleLogger.debug("[switch] handleTabSelectionChange SKIPPED (tearingDown) connId=\(coordinator.connectionId, privacy: .public)") return } let t0 = Date() @@ -37,14 +37,14 @@ extension MainContentView { tabs: tabManager.tabs, selectedTabId: newTabId ) - MainContentView.lifecycleLogger.info( + 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.info("[switch] handleTabsChange SKIPPED (tearingDown) tabCount=\(newTabs.count) connId=\(coordinator.connectionId, privacy: .public)") + MainContentView.lifecycleLogger.debug("[switch] handleTabsChange SKIPPED (tearingDown) tabCount=\(newTabs.count) connId=\(coordinator.connectionId, privacy: .public)") return } let t0 = Date() @@ -68,7 +68,7 @@ extension MainContentView { selectedTabId: normalizedSelectedId ) } - MainContentView.lifecycleLogger.info( + MainContentView.lifecycleLogger.debug( "[switch] handleTabsChange tabCount=\(newTabs.count) persistableCount=\(persistableTabs.count) ms=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 29807447a..972e79675 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -66,7 +66,7 @@ final class MainContentCoordinator { static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") /// Monotonic counter for correlating rapid tab-switch/close log entries. - @ObservationIgnored private(set) static var switchSeq: Int = 0 + static var switchSeq: Int = 0 static func nextSwitchSeq() -> Int { switchSeq += 1 return switchSeq diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 8b20df8d2..a2d5f4085 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -315,34 +315,34 @@ struct MainContentView: View { } .onChange(of: tabManager.selectedTabId) { _, newTabId in guard !coordinator.isTearingDown else { - Self.lifecycleLogger.info("[switch] selectedTabId SKIPPED (tearingDown) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)") + 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.info("[switch] selectedTabId SKIPPED (nil→nil) windowId=\(windowId, privacy: .public)") + Self.lifecycleLogger.debug("[switch] selectedTabId SKIPPED (nil→nil) windowId=\(windowId, privacy: .public)") return } let seq = MainContentCoordinator.nextSwitchSeq() let switchQueued = Date() - Self.lifecycleLogger.info( + 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.info("[switch] cancelling previous pendingTabSwitch seq=\(seq)") + Self.lifecycleLogger.debug("[switch] cancelling previous pendingTabSwitch seq=\(seq)") } pendingTabSwitch?.cancel() pendingTabSwitch = Task { @MainActor in await Task.yield() guard !Task.isCancelled else { - Self.lifecycleLogger.info("[switch] pendingTabSwitch CANCELLED seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") + Self.lifecycleLogger.debug("[switch] pendingTabSwitch CANCELLED seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") return } let handleStart = Date() - Self.lifecycleLogger.info("[switch] pendingTabSwitch executing seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") + Self.lifecycleLogger.debug("[switch] pendingTabSwitch executing seq=\(seq) waitMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))") handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) previousSelectedTabId = newTabId - Self.lifecycleLogger.info( + Self.lifecycleLogger.debug( "[switch] handleTabSelectionChange done seq=\(seq) handleMs=\(Int(Date().timeIntervalSince(handleStart) * 1_000)) totalMs=\(Int(Date().timeIntervalSince(switchQueued) * 1_000))" ) } @@ -363,7 +363,7 @@ struct MainContentView: View { .onChange(of: sidebarState.selectedTables) { _, newTables in guard !coordinator.isTearingDown else { - Self.lifecycleLogger.info("[switch] sidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") + Self.lifecycleLogger.debug("[switch] sidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") return } handleTableSelectionChange(from: previousSelectedTables, to: newTables) diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index 9a7ae352d..9fcb4c10a 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -20,6 +20,7 @@ import Testing @MainActor struct WindowTabGroupingTests { init() { + // Tests assume per-connection grouping; reset in case a prior suite changed it. AppSettingsManager.shared.tabs.groupAllConnectionTabs = false } From 5ae9a821f50c6eed0ad4bcf11fc092121a8c5869 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 21:12:50 +0700 Subject: [PATCH 16/18] fix: Cmd+W on first connect now clears to empty state instead of closing window --- TablePro/TableProApp.swift | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 6982e3e93..8eec83121 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -128,11 +128,16 @@ struct AppMenuCommands: Commands { private var resolvedCloseTabActions: MainContentCommandActions? { if let actions { return actions } guard let window = NSApp.keyWindow, - window.identifier?.rawValue.hasPrefix("main") == true, - let windowId = WindowLifecycleMonitor.shared.windowId(forWindow: window), - let coordinator = MainContentCoordinator.coordinator(for: windowId) + window.identifier?.rawValue.hasPrefix("main") == true else { return nil } - return coordinator.commandActions + 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 { @@ -215,9 +220,13 @@ struct AppMenuCommands: Commands { Button(actions != nil ? "Close Tab" : "Close") { if let resolved = resolvedCloseTabActions { resolved.closeTab() - } else if let window = NSApp.keyWindow, - window.identifier?.rawValue.hasPrefix("main") != true { - window.performClose(nil) + } else if let window = NSApp.keyWindow { + if window.identifier?.rawValue.hasPrefix("main") == true, + let coordinator = MainContentCoordinator.coordinator(forWindow: window) { + coordinator.commandActions?.closeTab() + } else { + window.performClose(nil) + } } } .optionalKeyboardShortcut(shortcut(for: .closeTab)) From 8e021cacfe630cb7f2e572fa91547a2e9a2bde3a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 21:20:06 +0700 Subject: [PATCH 17/18] fix: route Cmd+W through closeTab via EditorWindow.performClose override --- .../Infrastructure/TabWindowController.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 4ecb5bd86..13bd9af27 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -20,6 +20,21 @@ 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") @@ -56,7 +71,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { self.payload = payload self.controllerId = UUID() - let window = NSWindow( + let window = EditorWindow( contentRect: NSRect(x: 0, y: 0, width: 1_200, height: 800), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, From c17933b6dd7c888c9176104974719c69115864ee Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 18 Apr 2026 21:22:49 +0700 Subject: [PATCH 18/18] docs: simplify unreleased CHANGELOG entries --- CHANGELOG.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ebb0a5b9..3fef221ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,21 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Rewrote main editor window on AppKit (`NSWindowController` + `NSToolbar`) for faster tab opens and deterministic lifecycle -- Toolbar layout reorganized to match Apple HIG (Mail / Notes / Music) with sidebar toggle on the left, connection in the center, and view actions on the right +- 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 closed the entire connection window instead of clearing the last tab to empty state -- Welcome window stealing focus during connect, disabling menu shortcuts until the user clicked into the new window -- Toolbar appearing empty on tabs 2+ in a tab group -- Menu shortcuts (Cmd+T, Cmd+1...9) becoming disabled after clicking a toolbar button -- Inconsistent disabled state between menu shortcuts and toolbar buttons (Save Changes, Preview SQL, New Tab, Inspector) -- View → ER Diagram and Server Dashboard silently replacing the current tab; now opens in a new window tab or focuses an existing one +- 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 / Continuity support via NSUserActivity, refreshed on tab-selection change +- Handoff via NSUserActivity ## [0.32.1] - 2026-04-17