From b0f6e276e0f061eb5ee1f46945c9550c098d19be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 15 Apr 2026 17:25:42 +0700 Subject: [PATCH 01/26] refactor: replace native window tabs with in-app tab bar for instant switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native macOS window tabs used addTabbedWindow() which took 600-900ms per call, causing severe lag on Cmd+T and tab restoration. This refactor moves to a single-window-per-connection architecture with a custom SwiftUI tab bar, eliminating the N² view lifecycle cascades during tab restore. --- CHANGELOG.md | 5 + TablePro/AppDelegate+WindowConfig.swift | 28 ++- TablePro/ContentView.swift | 3 + .../Database/DatabaseManager+Sessions.swift | 9 + .../Infrastructure/SessionStateFactory.swift | 8 + .../TabPersistenceCoordinator.swift | 8 + .../WindowLifecycleMonitor.swift | 5 + .../Infrastructure/WindowOpener.swift | 3 + TablePro/TableProApp.swift | 8 +- .../Main/Child/MainEditorContentView.swift | 21 +- .../MainContentCoordinator+FKNavigation.swift | 20 +- .../MainContentCoordinator+Favorites.swift | 8 +- .../MainContentCoordinator+Navigation.swift | 184 +++++++++--------- ...ainContentCoordinator+SidebarActions.swift | 36 +--- ...MainContentCoordinator+TabOperations.swift | 161 +++++++++++++++ .../Extensions/MainContentView+Setup.swift | 61 +++--- .../Main/MainContentCommandActions.swift | 65 ++++--- .../Views/Main/MainContentCoordinator.swift | 53 ++--- TablePro/Views/Main/MainContentView.swift | 37 ++-- TablePro/Views/TabBar/EditorTabBar.swift | 124 ++++++++++++ TablePro/Views/TabBar/EditorTabBarItem.swift | 114 +++++++++++ 21 files changed, 701 insertions(+), 260 deletions(-) create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift create mode 100644 TablePro/Views/TabBar/EditorTabBar.swift create mode 100644 TablePro/Views/TabBar/EditorTabBarItem.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ec642cb08..7d28fadcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Replace native macOS window tabs with in-app tab bar for instant tab switching (was 600ms+ per tab) +- Tab restoration now loads all tabs in a single window instead of opening N separate windows + ### Fixed - Raw SQL injection via external URL scheme deeplinks — now requires user confirmation diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index ae1e35a01..3c62965b1 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -10,6 +10,7 @@ import os import SwiftUI private let windowLogger = Logger(subsystem: "com.TablePro", category: "WindowConfig") +private let windowPerfLog = OSSignposter(subsystem: "com.TablePro", category: "WindowPerf") extension AppDelegate { // MARK: - Dock Menu @@ -63,19 +64,20 @@ extension AppDelegate { } @objc func newWindowForTab(_ sender: Any?) { + let start = ContinuousClock.now guard let keyWindow = NSApp.keyWindow, let connectionId = MainActor.assumeIsolated({ WindowLifecycleMonitor.shared.connectionId(fromWindow: keyWindow) }) else { return } - let payload = EditorTabPayload( - connectionId: connectionId, - intent: .newEmptyTab - ) + // Add an in-app tab to the active coordinator instead of creating a new native window MainActor.assumeIsolated { - WindowOpener.shared.openNativeTab(payload) + if let coordinator = MainContentCoordinator.firstCoordinator(for: connectionId) { + coordinator.addNewQueryTab() + } } + windowLogger.info("[PERF] newWindowForTab: \(ContinuousClock.now - start)") } @objc func connectFromDock(_ sender: NSMenuItem) { @@ -223,6 +225,7 @@ extension AppDelegate { // MARK: - Window Notifications @objc func windowDidBecomeKey(_ notification: Notification) { + let becomeKeyStart = ContinuousClock.now guard let window = notification.object as? NSWindow else { return } let windowId = ObjectIdentifier(window) @@ -253,6 +256,7 @@ extension AppDelegate { } if isMainWindow(window) && !configuredWindows.contains(windowId) { + windowLogger.info("[PERF] windowDidBecomeKey: configuring new main window (elapsed so far: \(ContinuousClock.now - becomeKeyStart))") window.tabbingMode = .preferred window.isRestorable = false configuredWindows.insert(windowId) @@ -260,12 +264,15 @@ extension AppDelegate { let pendingConnectionId = MainActor.assumeIsolated { WindowOpener.shared.consumeOldestPendingConnectionId() } + windowLogger.info("[PERF] windowDidBecomeKey: consumeOldestPending=\(String(describing: pendingConnectionId)), isAutoReconnecting=\(self.isAutoReconnecting) (elapsed: \(ContinuousClock.now - becomeKeyStart))") if pendingConnectionId == nil && !isAutoReconnecting { if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 { + windowLogger.info("[PERF] windowDidBecomeKey: orphan window already tabbed, returning (total: \(ContinuousClock.now - becomeKeyStart))") return } window.orderOut(nil) + windowLogger.info("[PERF] windowDidBecomeKey: orphan window hidden (total: \(ContinuousClock.now - becomeKeyStart))") return } @@ -278,6 +285,7 @@ extension AppDelegate { NSWindow.allowsAutomaticWindowTabbing = true } + let windowLookupStart = ContinuousClock.now let matchingWindow: NSWindow? if groupAll { let existingMainWindows = NSApp.windows.filter { @@ -293,16 +301,24 @@ extension AppDelegate { && $0.tabbingIdentifier == resolvedIdentifier } } + windowLogger.info("[PERF] windowDidBecomeKey: window lookup took \(ContinuousClock.now - windowLookupStart), totalWindows=\(NSApp.windows.count), groupAll=\(groupAll)") + if let existingWindow = matchingWindow { + let mergeStart = ContinuousClock.now let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow targetWindow.addTabbedWindow(window, ordered: .above) window.makeKeyAndOrderFront(nil) + windowLogger.info("[PERF] windowDidBecomeKey: addTabbedWindow took \(ContinuousClock.now - mergeStart)") } } + windowLogger.info("[PERF] windowDidBecomeKey: main window config TOTAL=\(ContinuousClock.now - becomeKeyStart)") + } else { + windowLogger.info("[PERF] windowDidBecomeKey: non-main or already configured (total: \(ContinuousClock.now - becomeKeyStart))") } } @objc func windowWillClose(_ notification: Notification) { + let closeStart = ContinuousClock.now guard let window = notification.object as? NSWindow else { return } configuredWindows.remove(ObjectIdentifier(window)) @@ -311,12 +327,14 @@ extension AppDelegate { let remainingMainWindows = NSApp.windows.filter { $0 !== window && isMainWindow($0) && $0.isVisible }.count + windowLogger.info("[PERF] windowWillClose: isMainWindow=true, remainingMainWindows=\(remainingMainWindows), totalWindows=\(NSApp.windows.count)") if remainingMainWindows == 0 { NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) openWelcomeWindow() } } + windowLogger.info("[PERF] windowWillClose: total=\(ContinuousClock.now - closeStart)") } @objc func windowDidChangeOcclusionState(_ notification: Notification) { diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index abddd800d..0a6cf527d 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -35,6 +35,7 @@ struct ContentView: View { private let storage = ConnectionStorage.shared init(payload: EditorTabPayload?) { + let initStart = ContinuousClock.now self.payload = payload let defaultTitle: String if payload?.tabType == .serverDashboard { @@ -62,6 +63,7 @@ struct ContentView: View { resolvedSession = DatabaseManager.shared.activeSessions[currentId] } _currentSession = State(initialValue: resolvedSession) + let sessionResolved = ContinuousClock.now if let session = resolvedSession { _rightPanelState = State(initialValue: RightPanelState()) @@ -77,6 +79,7 @@ struct ContentView: View { _rightPanelState = State(initialValue: nil) _sessionState = State(initialValue: nil) } + Self.logger.info("[PERF] ContentView.init: total=\(ContinuousClock.now - initStart), sessionResolve=\(sessionResolved - initStart), stateFactory=\(ContinuousClock.now - sessionResolved)") } var body: some View { diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 69812257a..0778c40db 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -241,21 +241,29 @@ extension DatabaseManager { /// Disconnect a specific session func disconnectSession(_ sessionId: UUID) async { + let disconnStart = ContinuousClock.now guard let session = activeSessions[sessionId] else { return } // Close SSH tunnel if exists if session.connection.resolvedSSHConfig.enabled { + let sshStart = ContinuousClock.now do { try await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id) } catch { Self.logger.warning("SSH tunnel cleanup failed for \(session.connection.name): \(error.localizedDescription)") } + Self.logger.info("[PERF] disconnectSession: SSH tunnel close=\(ContinuousClock.now - sshStart)") } // Stop health monitoring + let healthStart = ContinuousClock.now await stopHealthMonitor(for: sessionId) + Self.logger.info("[PERF] disconnectSession: stopHealthMonitor=\(ContinuousClock.now - healthStart)") + let driverStart = ContinuousClock.now session.driver?.disconnect() + Self.logger.info("[PERF] disconnectSession: driver.disconnect=\(ContinuousClock.now - driverStart)") + removeSessionEntry(for: sessionId) // Clean up shared schema cache for this connection @@ -274,6 +282,7 @@ extension DatabaseManager { AppSettingsStorage.shared.saveLastConnectionId(nil) } } + Self.logger.info("[PERF] disconnectSession: TOTAL=\(ContinuousClock.now - disconnStart)") } /// Disconnect all sessions diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 7ab632752..83f75ed0e 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -7,6 +7,9 @@ // import Foundation +import os + +private let sessionFactoryLogger = Logger(subsystem: "com.TablePro", category: "SessionStateFactory") @MainActor enum SessionStateFactory { @@ -23,6 +26,7 @@ enum SessionStateFactory { connection: DatabaseConnection, payload: EditorTabPayload? ) -> SessionState { + let factoryStart = ContinuousClock.now let tabMgr = QueryTabManager() let changeMgr = DataChangeManager() changeMgr.databaseType = connection.type @@ -111,6 +115,7 @@ enum SessionStateFactory { } } + let preCoordTime = ContinuousClock.now let coord = MainContentCoordinator( connection: connection, tabManager: tabMgr, @@ -119,6 +124,9 @@ enum SessionStateFactory { columnVisibilityManager: colVisMgr, toolbarState: toolbarSt ) + let coordTime = ContinuousClock.now + + sessionFactoryLogger.info("[PERF] SessionStateFactory.create total=\(ContinuousClock.now - factoryStart), coordinator.init=\(coordTime - preCoordTime), tabSetup=\(preCoordTime - factoryStart)") return SessionState( tabManager: tabMgr, diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index f2266d486..bc03e9e21 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -8,6 +8,9 @@ import Foundation import Observation +import os + +private let persistLogger = Logger(subsystem: "com.TablePro", category: "TabPersistence") /// Result of tab restoration from disk internal struct RestoreResult { @@ -99,15 +102,20 @@ internal final class TabPersistenceCoordinator { /// Restore tabs from disk. Called once at window creation. internal func restoreFromDisk() async -> RestoreResult { + let start = ContinuousClock.now guard let state = await TabDiskActor.shared.load(connectionId: connectionId) else { + persistLogger.info("[PERF] restoreFromDisk: no saved state (\(ContinuousClock.now - start))") return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } guard !state.tabs.isEmpty else { + persistLogger.info("[PERF] restoreFromDisk: empty tabs (\(ContinuousClock.now - start))") return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } + let mapStart = ContinuousClock.now let restoredTabs = state.tabs.map { QueryTab(from: $0) } + persistLogger.info("[PERF] restoreFromDisk: diskLoad=\(mapStart - start), tabMapping=\(ContinuousClock.now - mapStart), totalTabs=\(restoredTabs.count), total=\(ContinuousClock.now - start)") return RestoreResult( tabs: restoredTabs, selectedTabId: state.selectedTabId, diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index fdc6adf78..88965cc1c 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -199,7 +199,9 @@ internal final class WindowLifecycleMonitor { } private func handleWindowClose(_ closedWindow: NSWindow) { + let closeStart = ContinuousClock.now guard let (windowId, entry) = entries.first(where: { $0.value.window === closedWindow }) else { + Self.logger.info("[PERF] handleWindowClose: window not found in entries") return } @@ -214,9 +216,12 @@ internal final class WindowLifecycleMonitor { let hasRemainingWindows = entries.values.contains { $0.connectionId == closedConnectionId && $0.window != nil } + Self.logger.info("[PERF] handleWindowClose: cleanup took \(ContinuousClock.now - closeStart), hasRemainingWindows=\(hasRemainingWindows), remainingEntries=\(self.entries.count)") if !hasRemainingWindows { Task { + let disconnectStart = ContinuousClock.now await DatabaseManager.shared.disconnectSession(closedConnectionId) + Self.logger.info("[PERF] handleWindowClose: disconnectSession took \(ContinuousClock.now - disconnectStart)") } } } diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index c75ca71fb..a0d6b01c4 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -55,6 +55,7 @@ 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) { + let start = ContinuousClock.now pendingPayloads.append((id: payload.id, connectionId: payload.connectionId)) if let openWindow { openWindow(id: "main", value: payload) @@ -62,6 +63,8 @@ internal final class WindowOpener { Self.logger.info("openWindow not set — falling back to .openMainWindow notification") NotificationCenter.default.post(name: .openMainWindow, object: payload) } + let elapsed = ContinuousClock.now - start + Self.logger.info("[PERF] openNativeTab: \(elapsed) (intent=\(String(describing: payload.intent)), pendingCount=\(self.pendingPayloads.count))") } /// Called by MainContentView.configureWindow after the window is fully set up. diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 838e2e34a..6d3a89846 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -457,16 +457,16 @@ struct AppMenuCommands: Commands { Divider() - // Previous tab (Cmd+Shift+[) — delegate to native macOS tab switching + // Previous tab (Cmd+Shift+[) — in-app tab switching Button("Show Previous Tab") { - NSApp.sendAction(#selector(NSWindow.selectPreviousTab(_:)), to: nil, from: nil) + actions?.selectPreviousTab() } .optionalKeyboardShortcut(shortcut(for: .showPreviousTab)) .disabled(!(actions?.isConnected ?? false)) - // Next tab (Cmd+Shift+]) — delegate to native macOS tab switching + // Next tab (Cmd+Shift+]) — in-app tab switching Button("Show Next Tab") { - NSApp.sendAction(#selector(NSWindow.selectNextTab(_:)), to: nil, from: nil) + actions?.selectNextTab() } .optionalKeyboardShortcut(shortcut(for: .showNextTab)) .disabled(!(actions?.isConnected ?? false)) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 2a5e829bc..2e3fff9a0 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -96,8 +96,25 @@ struct MainEditorContentView: View { let isHistoryVisible = coordinator.toolbarState.isHistoryPanelVisible VStack(spacing: 0) { - // Native macOS window tabs replace the custom tab bar. - // Each window-tab contains a single tab — no ZStack keep-alive needed. + if tabManager.tabs.count > 1 || !tabManager.tabs.isEmpty { + EditorTabBar( + tabs: tabManager.tabs, + selectedTabId: Binding( + get: { tabManager.selectedTabId }, + set: { tabManager.selectedTabId = $0 } + ), + databaseType: connection.type, + onClose: { id in coordinator.closeInAppTab(id) }, + onCloseOthers: { id in coordinator.closeOtherTabs(excluding: id) }, + onCloseAll: { coordinator.closeAllTabs() }, + onReorder: { tabs in coordinator.reorderTabs(tabs) }, + onRename: { id, name in coordinator.renameTab(id, to: name) }, + onAddTab: { coordinator.addNewQueryTab() }, + onDuplicate: { id in coordinator.duplicateTab(id) } + ) + Divider() + } + if let tab = tabManager.selectedTab { tabContent(for: tab) } else { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 7c2e8f1c7..23ca781bd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -53,7 +53,7 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, open in a new native tab instead of replacing + // If current tab has unsaved changes, open in a new in-app tab instead of replacing if changeManager.hasChanges { let fkFilterState = TabFilterState( filters: [filter], @@ -61,16 +61,18 @@ extension MainContentCoordinator { isVisible: true, filterLogicMode: .and ) - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + tabManager.addTableTab( tableName: referencedTable, - databaseName: currentDatabase, - schemaName: targetSchema, - isView: false, - initialFilterState: fkFilterState + databaseType: connection.type, + databaseName: currentDatabase ) - WindowOpener.shared.openNativeTab(payload) + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].schemaName = targetSchema + tabManager.tabs[tabIndex].filterState = fkFilterState + filterStateManager.restoreFromTabState(fkFilterState) + } + restoreColumnLayoutForTable(referencedTable) + runQuery() return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index bd7f1ce0d..7e18c4a44 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -42,12 +42,6 @@ extension MainContentCoordinator { return } - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - databaseName: connection.database, - initialQuery: favorite.query - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: favorite.query, databaseName: connection.database) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 08827eba1..974feda48 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -60,15 +60,12 @@ extension MainContentCoordinator { return } - // Check if another native window tab already has this table open — switch to it - if let keyWindow = NSApp.keyWindow { - let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) - let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] - for window in tabbedWindows - where window.title == tableName && ownWindows.contains(ObjectIdentifier(window)) { - window.makeKeyAndOrderFront(nil) - return - } + // Check if another in-app tab already has this table open — switch to it + if let existingTab = tabManager.tabs.first(where: { + $0.tabType == .table && $0.tableName == tableName && $0.databaseName == currentDatabase + }) { + tabManager.selectedTabId = existingTab.id + return } // If no tabs exist (empty state), add a table tab directly. @@ -136,21 +133,18 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, active filters, or sorting, open in a new native tab + // If current tab has unsaved changes, active filters, or sorting, open in a new in-app tab let hasActiveWork = changeManager.hasChanges || filterStateManager.hasAppliedFilters || (tabManager.selectedTab?.sortState.isSorting ?? false) if hasActiveWork { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + addTableTabInApp( tableName: tableName, databaseName: currentDatabase, schemaName: currentSchema, isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) return } @@ -160,17 +154,42 @@ extension MainContentCoordinator { return } - // Default: open table in a new native tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + // Default: open table in a new in-app tab + addTableTabInApp( tableName: tableName, databaseName: currentDatabase, schemaName: currentSchema, isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) + } + + /// Helper: add a table tab in-app and execute its query + private func addTableTabInApp( + tableName: String, + databaseName: String, + schemaName: String?, + isView: Bool = false, + showStructure: Bool = false + ) { + tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: databaseName + ) + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].isView = isView + tabManager.tabs[tabIndex].isEditable = !isView + tabManager.tabs[tabIndex].schemaName = schemaName + if showStructure { + tabManager.tabs[tabIndex].showStructure = true + } + tabManager.tabs[tabIndex].pagination.reset() + toolbarState.isTableTab = true + } + restoreColumnLayoutForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() } // MARK: - Preview Tabs @@ -180,48 +199,44 @@ extension MainContentCoordinator { databaseName: String = "", schemaName: String? = nil, showStructure: Bool = false ) { - // Check if a preview window already exists for this connection - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId) { - if let previewCoordinator = Self.coordinator(for: preview.windowId) { - // Skip if preview tab already shows this table - if let current = previewCoordinator.tabManager.selectedTab, - current.tableName == tableName, - current.databaseName == databaseName { - preview.window.makeKeyAndOrderFront(nil) - return - } - if let oldTab = previewCoordinator.tabManager.selectedTab, - let oldTableName = oldTab.tableName { - previewCoordinator.filterStateManager.saveLastFilters(for: oldTableName) - } - previewCoordinator.tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true - ) - previewCoordinator.filterStateManager.clearAll() - if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { - previewCoordinator.tabManager.tabs[tabIndex].showStructure = showStructure - previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() - previewCoordinator.toolbarState.isTableTab = true - } - preview.window.makeKeyAndOrderFront(nil) - previewCoordinator.restoreColumnLayoutForTable(tableName) - previewCoordinator.restoreFiltersForTable(tableName) - previewCoordinator.runQuery() + // Check if a preview tab already exists in this window's tab manager + if let previewIndex = tabManager.tabs.firstIndex(where: { $0.isPreview }) { + let previewTab = tabManager.tabs[previewIndex] + // Skip if preview tab already shows this table + if previewTab.tableName == tableName, previewTab.databaseName == databaseName { + tabManager.selectedTabId = previewTab.id return } + if let oldTableName = previewTab.tableName { + filterStateManager.saveLastFilters(for: oldTableName) + } + // Select the preview tab first so replaceTabContent operates on it + tabManager.selectedTabId = previewTab.id + tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: databaseName, + schemaName: schemaName, + isPreview: true + ) + filterStateManager.clearAll() + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].showStructure = showStructure + tabManager.tabs[tabIndex].pagination.reset() + toolbarState.isTableTab = true + } + restoreColumnLayoutForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() + return } - // No preview window exists but current tab can be reused: replace in-place. - // This covers: preview tabs, non-preview table tabs with no active work, + // No preview tab exists but current tab can be reused: replace in-place. + // This covers: non-preview table tabs with no active work, // and empty/default query tabs (no user-entered content). let isReusableTab: Bool = { guard let tab = tabManager.selectedTab else { return false } - if tab.isPreview { return true } // Table tab with no active work if tab.tabType == .table && !changeManager.hasChanges && !filterStateManager.hasAppliedFilters && !tab.sortState.isSorting { @@ -239,7 +254,7 @@ extension MainContentCoordinator { if selectedTab.tableName == tableName, selectedTab.databaseName == databaseName { return } - // If preview tab has active work, promote it and open new tab instead + // If reusable tab has active work, promote it and open new tab instead let hasUnsavedQuery = tabManager.selectedTab.map { tab in tab.tabType == .query && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false @@ -249,16 +264,13 @@ extension MainContentCoordinator { || hasUnsavedQuery if previewHasWork { promotePreviewTab() - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + addTableTabInApp( tableName: tableName, databaseName: databaseName, schemaName: schemaName, isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) return } if let oldTableName = selectedTab.tableName { @@ -284,40 +296,36 @@ extension MainContentCoordinator { return } - // No preview tab anywhere: create a new native preview tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + // No reusable tab: create a new in-app preview tab + tabManager.addPreviewTableTab( tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - isView: isView, - showStructure: showStructure, - isPreview: true + databaseType: connection.type, + databaseName: databaseName ) - WindowOpener.shared.openNativeTab(payload) + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].isView = isView + tabManager.tabs[tabIndex].schemaName = schemaName + if showStructure { + tabManager.tabs[tabIndex].showStructure = true + } + tabManager.tabs[tabIndex].pagination.reset() + toolbarState.isTableTab = true + } + restoreColumnLayoutForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() } func promotePreviewTab() { guard let tabIndex = tabManager.selectedTabIndex, tabManager.tabs[tabIndex].isPreview else { return } tabManager.tabs[tabIndex].isPreview = false - - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(false, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = connection.name - } } func showAllTablesMetadata() { guard let sql = allTablesMetadataSQL() else { return } - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: sql - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: sql, databaseName: connection.database) + runQuery() } private func currentSchemaName(fallback: String) -> String { @@ -359,17 +367,9 @@ extension MainContentCoordinator { /// Each table opened via WindowOpener creates a separate NSWindow in the same /// tab group. Clearing `tabManager.tabs` only affects the in-app state of the /// *current* window — other NSWindows remain open with stale content. - private func closeSiblingNativeWindows() { - guard let keyWindow = NSApp.keyWindow else { return } - let siblings = keyWindow.tabbedWindows ?? [] - let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) - for sibling in siblings where sibling !== keyWindow { - // Only close windows belonging to this connection to avoid - // destroying tabs from other connections when groupAllConnectionTabs is ON - guard ownWindows.contains(ObjectIdentifier(sibling)) else { continue } - sibling.close() - } - } + /// No-op: with in-app tabs, there are no sibling native windows per connection. + /// Kept as a placeholder to avoid changing callers in switchDatabase/switchSchema. + private func closeSiblingNativeWindows() {} /// Switch to a different database (called from database switcher) func switchDatabase(to database: String) async { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e5dacead0..6dfdce0c2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -39,17 +39,7 @@ extension MainContentCoordinator { func createNewTable() { guard !safeModeLevel.blocksAllWrites else { return } - - if tabManager.tabs.isEmpty { - tabManager.addCreateTableTab(databaseName: connection.database) - } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .createTable, - databaseName: connection.database - ) - WindowOpener.shared.openNativeTab(payload) - } + tabManager.addCreateTableTab(databaseName: connection.database) } func showERDiagram() { @@ -65,13 +55,7 @@ extension MainContentCoordinator { let template = driver?.createViewTemplate() ?? "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - databaseName: connection.database, - initialQuery: template - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: template, databaseName: connection.database) } func editViewDefinition(_ viewName: String) { @@ -79,25 +63,13 @@ extension MainContentCoordinator { do { guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } let definition = try await driver.fetchViewDefinition(view: viewName) - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: definition - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: definition, databaseName: connection.database) } catch { let driver = DatabaseManager.shared.driver(for: self.connection.id) let template = driver?.editViewFallbackTemplate(viewName: viewName) ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" let fallbackSQL = "-- Could not fetch view definition: \(error.localizedDescription)\n\(template)" - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: fallbackSQL - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: fallbackSQL, databaseName: connection.database) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift new file mode 100644 index 000000000..996f69a48 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift @@ -0,0 +1,161 @@ +// +// MainContentCoordinator+TabOperations.swift +// TablePro +// +// In-app tab bar operations: close, reorder, rename, duplicate, add. +// + +import AppKit +import Foundation + +extension MainContentCoordinator { + // MARK: - Tab Close + + func closeInAppTab(_ id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + + let tab = tabManager.tabs[index] + let isSelected = tabManager.selectedTabId == id + + // Check for unsaved changes on this specific tab + if isSelected && changeManager.hasChanges { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Your changes will be lost if you don't save them."), + window: contentWindow + ) + switch result { + case .save: + // Save then close — delegate to existing save flow + break + case .dontSave: + changeManager.clearChangesAndUndoHistory() + removeTab(id) + case .cancel: + return + } + } + return + } + + // Check for dirty file + if tab.isFileDirty { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Your changes will be lost if you don't save them."), + window: contentWindow + ) + switch result { + case .save: + if let url = tab.sourceFileURL { + try? await SQLFileService.writeFile(content: tab.query, to: url) + } + removeTab(id) + case .dontSave: + removeTab(id) + case .cancel: + return + } + } + return + } + + removeTab(id) + } + + private func removeTab(_ id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + let wasSelected = tabManager.selectedTabId == id + + tabManager.tabs[index].rowBuffer.evict() + tabManager.tabs.remove(at: index) + + if wasSelected { + if tabManager.tabs.isEmpty { + tabManager.selectedTabId = nil + // Close the window when last tab is closed + contentWindow?.close() + } else { + // Select adjacent tab (prefer left, fall back to right) + let newIndex = min(index, tabManager.tabs.count - 1) + tabManager.selectedTabId = tabManager.tabs[newIndex].id + } + } + + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + func closeOtherTabs(excluding id: UUID) { + let idsToClose = tabManager.tabs.filter { $0.id != id }.map(\.id) + for tabId in idsToClose { + if let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { + tabManager.tabs[index].rowBuffer.evict() + } + tabManager.tabs.removeAll { $0.id == tabId } + } + tabManager.selectedTabId = id + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + func closeAllTabs() { + for tab in tabManager.tabs { + tab.rowBuffer.evict() + } + tabManager.tabs.removeAll() + tabManager.selectedTabId = nil + persistence.clearSavedState() + contentWindow?.close() + } + + // MARK: - Tab Reorder + + func reorderTabs(_ newOrder: [QueryTab]) { + tabManager.tabs = newOrder + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + // MARK: - Tab Rename + + func renameTab(_ id: UUID, to name: String) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + tabManager.tabs[index].title = name + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + // MARK: - Add Tab + + func addNewQueryTab() { + let allTabs = tabManager.tabs + let title = QueryTabManager.nextQueryTitle(existingTabs: allTabs) + tabManager.addTab(title: title, databaseName: connection.database) + } + + // MARK: - Duplicate Tab + + func duplicateTab(_ id: UUID) { + guard let sourceTab = tabManager.tabs.first(where: { $0.id == id }) else { return } + + switch sourceTab.tabType { + case .table: + if let tableName = sourceTab.tableName { + tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: sourceTab.databaseName + ) + } + case .query: + tabManager.addTab( + initialQuery: sourceTab.query, + title: sourceTab.title + " Copy", + databaseName: sourceTab.databaseName + ) + case .createTable: + tabManager.addCreateTableTab(databaseName: sourceTab.databaseName) + case .erDiagram: + openERDiagramTab() + case .serverDashboard: + openServerDashboardTab() + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 38b8e02c4..b7d85f056 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -6,18 +6,24 @@ // for MainContentView. Extracted to reduce main view complexity. // +import os import SwiftUI +private let setupLogger = Logger(subsystem: "com.TablePro", category: "MainContentSetup") + extension MainContentView { // MARK: - Initialization func initializeAndRestoreTabs() async { + let start = ContinuousClock.now guard !hasInitialized else { return } hasInitialized = true Task { await coordinator.loadSchemaIfNeeded() } guard let payload else { + setupLogger.info("[PERF] initializeAndRestoreTabs: no payload, calling handleRestoreOrDefault") await handleRestoreOrDefault() + setupLogger.info("[PERF] initializeAndRestoreTabs: total=\(ContinuousClock.now - start) (restoreOrDefault path)") return } @@ -65,14 +71,17 @@ extension MainContentView { } case .newEmptyTab: + setupLogger.info("[PERF] initializeAndRestoreTabs: newEmptyTab (total=\(ContinuousClock.now - start))") return case .restoreOrDefault: await handleRestoreOrDefault() + setupLogger.info("[PERF] initializeAndRestoreTabs: restoreOrDefault (total=\(ContinuousClock.now - start))") } } private func handleRestoreOrDefault() async { + let restoreStart = ContinuousClock.now if WindowLifecycleMonitor.shared.hasOtherWindows(for: connection.id, excluding: windowId) { if tabManager.tabs.isEmpty { let allTabs = MainContentCoordinator.allTabs(for: connection.id) @@ -82,7 +91,9 @@ extension MainContentView { return } + let preRestore = ContinuousClock.now let result = await coordinator.persistence.restoreFromDisk() + setupLogger.info("[PERF] handleRestoreOrDefault: restoreFromDisk took \(ContinuousClock.now - preRestore), tabCount=\(result.tabs.count)") if !result.tabs.isEmpty { var restoredTabs = result.tabs for i in restoredTabs.indices where restoredTabs[i].tabType == .table { @@ -95,44 +106,24 @@ extension MainContentView { } } - let selectedId = result.selectedTabId - - // First tab in the array gets the current window to preserve order. - // Remaining tabs open as native window tabs in order. - let firstTab = restoredTabs[0] - tabManager.tabs = [firstTab] - tabManager.selectedTabId = firstTab.id - - let remainingTabs = Array(restoredTabs.dropFirst()) - - if !remainingTabs.isEmpty { - let selectedWasFirst = firstTab.id == selectedId - Task { @MainActor in - for tab in remainingTabs { - let restorePayload = EditorTabPayload( - from: tab, connectionId: connection.id, skipAutoExecute: true) - WindowOpener.shared.openNativeTab(restorePayload) - } - // Bring the first window to front only if it had the selected tab. - // Otherwise let the last restored window stay focused. - if selectedWasFirst { - viewWindow?.makeKeyAndOrderFront(nil) - } - } - } + // All tabs go into one QueryTabManager — no native window loop + tabManager.tabs = restoredTabs + tabManager.selectedTabId = result.selectedTabId ?? restoredTabs.first?.id - if firstTab.tabType == .table, - !firstTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // Execute the selected tab's query if it's a table tab + if let selectedTab = tabManager.selectedTab, + selectedTab.tabType == .table, + !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { - if !firstTab.databaseName.isEmpty, - firstTab.databaseName != session.activeDatabase + if !selectedTab.databaseName.isEmpty, + selectedTab.databaseName != session.activeDatabase { - Task { await coordinator.switchDatabase(to: firstTab.databaseName) } + Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } } else { - if let tableName = firstTab.tableName { + if let tableName = selectedTab.tableName { coordinator.restoreColumnLayoutForTable(tableName) } coordinator.executeTableTabQueryDirectly() @@ -179,6 +170,7 @@ extension MainContentView { /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. func configureWindow(_ window: NSWindow) { + let configStart = ContinuousClock.now let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false if isPreview { window.subtitle = "\(connection.name) — Preview" @@ -188,15 +180,19 @@ extension MainContentView { let resolvedId = WindowOpener.tabbingIdentifier(for: connection.id) window.tabbingIdentifier = resolvedId - window.tabbingMode = .preferred + // Disallow native window tabbing — tabs are managed in-app via EditorTabBar + window.tabbingMode = .disallowed coordinator.windowId = windowId + let registerStart = ContinuousClock.now WindowLifecycleMonitor.shared.register( window: window, connectionId: connection.id, windowId: windowId, isPreview: isPreview ) + setupLogger.info("[PERF] configureWindow: WindowLifecycleMonitor.register took \(ContinuousClock.now - registerStart)") + viewWindow = window coordinator.contentWindow = window isKeyWindow = window.isKeyWindow @@ -211,6 +207,7 @@ extension MainContentView { // Update command actions window reference now that it's available commandActions?.window = window + setupLogger.info("[PERF] configureWindow: total=\(ContinuousClock.now - configStart)") } func setupCommandActions() { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 3254a4fe7..d592f96ea 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -330,19 +330,11 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) func newTab(initialQuery: String? = nil) { - // If no tabs exist (empty state), add directly to this window - if coordinator?.tabManager.tabs.isEmpty == true { + if let initialQuery { coordinator?.tabManager.addTab(initialQuery: initialQuery, databaseName: connection.database) - return + } else { + coordinator?.addNewQueryTab() } - // Open a new native macOS window tab with a query editor - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: initialQuery, - intent: .newEmptyTab - ) - WindowOpener.shared.openNativeTab(payload) } func closeTab() { @@ -369,20 +361,23 @@ final class MainContentCommandActions { } private func performClose() { - guard let keyWindow = NSApp.keyWindow else { return } - let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] + guard let coordinator else { + NSApp.keyWindow?.close() + return + } - if tabbedWindows.count > 1 { - keyWindow.close() - } else if coordinator?.tabManager.tabs.isEmpty == true { - keyWindow.close() + // Multiple in-app tabs: close the selected tab + if coordinator.tabManager.tabs.count > 1, let selectedId = coordinator.tabManager.selectedTabId { + coordinator.closeInAppTab(selectedId) } else { - for tab in coordinator?.tabManager.tabs ?? [] { + // Last tab or no tabs: close the window + for tab in coordinator.tabManager.tabs { tab.rowBuffer.evict() } - coordinator?.tabManager.tabs.removeAll() - coordinator?.tabManager.selectedTabId = nil - coordinator?.toolbarState.isTableTab = false + coordinator.tabManager.tabs.removeAll() + coordinator.tabManager.selectedTabId = nil + coordinator.toolbarState.isTableTab = false + NSApp.keyWindow?.close() } } @@ -490,11 +485,23 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) func selectTab(number: Int) { - // Switch to the nth native window tab - guard let keyWindow = NSApp.keyWindow, - let tabbedWindows = keyWindow.tabbedWindows, - number > 0, number <= tabbedWindows.count else { return } - tabbedWindows[number - 1].makeKeyAndOrderFront(nil) + guard let tabs = coordinator?.tabManager.tabs, + number > 0, number <= tabs.count else { return } + coordinator?.tabManager.selectedTabId = tabs[number - 1].id + } + + func selectPreviousTab() { + guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, + let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } + let newIndex = (currentIndex - 1 + tabs.count) % tabs.count + coordinator?.tabManager.selectedTabId = tabs[newIndex].id + } + + func selectNextTab() { + guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, + let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } + let newIndex = (currentIndex + 1) % tabs.count + coordinator?.tabManager.selectedTabId = tabs[newIndex].id } // MARK: - Filter Operations (Group A — Called Directly) @@ -799,13 +806,11 @@ final class MainContentCommandActions { }.value if let content { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, + coordinator?.tabManager.addTab( initialQuery: content, + databaseName: connection.database, sourceFileURL: url ) - WindowOpener.shared.openNativeTab(payload) } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1622a8d94..362c40e7e 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -200,6 +200,11 @@ final class MainContentCoordinator { activeCoordinators.values.first { $0.windowId == windowId } } + /// Find the first coordinator for a connection (used by AppDelegate for Cmd+T). + static func firstCoordinator(for connectionId: UUID) -> MainContentCoordinator? { + activeCoordinators.values.first { $0.connectionId == connectionId } + } + /// Check whether any active coordinator has unsaved edits. static func hasAnyUnsavedChanges() -> Bool { activeCoordinators.values.contains { coordinator in @@ -300,22 +305,27 @@ final class MainContentCoordinator { columnVisibilityManager: ColumnVisibilityManager, toolbarState: ConnectionToolbarState ) { + let coordInitStart = ContinuousClock.now self.connection = connection self.tabManager = tabManager self.changeManager = changeManager self.filterStateManager = filterStateManager self.columnVisibilityManager = columnVisibilityManager self.toolbarState = toolbarState + let dialectStart = ContinuousClock.now let dialect = PluginManager.shared.sqlDialect(for: connection.type) self.queryBuilder = TableQueryBuilder( databaseType: connection.type, dialect: dialect, dialectQuote: quoteIdentifierFromDialect(dialect) ) + Self.logger.info("[PERF] MainContentCoordinator.init: dialect+queryBuilder=\(ContinuousClock.now - dialectStart)") self.persistence = TabPersistenceCoordinator(connectionId: connection.id) + let schemaStart = ContinuousClock.now self.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) SchemaProviderRegistry.shared.retain(for: connection.id) + Self.logger.info("[PERF] MainContentCoordinator.init: schemaProvider=\(ContinuousClock.now - schemaStart)") urlFilterObservers = setupURLNotificationObservers() // Synchronous save at quit time. NotificationCenter with queue: .main @@ -329,25 +339,20 @@ final class MainContentCoordinator { ) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } - // Only the first coordinator for this connection saves, - // aggregating tabs from all windows to fix last-write-wins bug. - // Skip isTearingDown check: during Cmd+Q, onDisappear fires - // markTeardownScheduled() before willTerminate, and we still - // need to save here. - guard self.isFirstCoordinatorForConnection() else { return } - let allTabs = Self.aggregatedTabs(for: self.connectionId) - let selectedId = Self.aggregatedSelectedTabId(for: self.connectionId) + // Save all tabs directly — single coordinator per connection self.persistence.saveNowSync( - tabs: allTabs, - selectedTabId: selectedId + tabs: self.tabManager.tabs, + selectedTabId: self.tabManager.selectedTabId ) } } _ = Self.registerTerminationObserver + Self.logger.info("[PERF] MainContentCoordinator.init: TOTAL=\(ContinuousClock.now - coordInitStart)") } func markActivated() { + let activateStart = ContinuousClock.now _didActivate.withLock { $0 = true } registerForPersistence() setupPluginDriver() @@ -362,6 +367,7 @@ final class MainContentCoordinator { } } } + Self.logger.info("[PERF] markActivated: total=\(ContinuousClock.now - activateStart)") } /// Start watching the database file for external changes (SQLite, DuckDB). @@ -458,9 +464,11 @@ 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 teardownStart = ContinuousClock.now _didTeardown.withLock { $0 = true } unregisterFromPersistence() + let observerStart = ContinuousClock.now for observer in urlFilterObservers { NotificationCenter.default.removeObserver(observer) } @@ -473,6 +481,8 @@ final class MainContentCoordinator { NotificationCenter.default.removeObserver(observer) pluginDriverObserver = nil } + Self.logger.info("[PERF] teardown: observer cleanup=\(ContinuousClock.now - observerStart)") + fileWatcher?.stopWatching(connectionId: connectionId) fileWatcher = nil currentQueryTask?.cancel() @@ -487,16 +497,21 @@ final class MainContentCoordinator { // Let the view layer release cached row providers before we drop RowBuffers. // Called synchronously here because SwiftUI onChange handlers don't fire // reliably on disappearing views. + let onTeardownStart = ContinuousClock.now onTeardown?() onTeardown = nil + Self.logger.info("[PERF] teardown: onTeardown callback=\(ContinuousClock.now - onTeardownStart)") // Notify DataGridView coordinators to release NSTableView cell views + let notifyStart = ContinuousClock.now NotificationCenter.default.post( name: Self.teardownNotification, object: connection.id ) + Self.logger.info("[PERF] teardown: teardownNotification post=\(ContinuousClock.now - notifyStart)") // Release heavy data so memory drops even if SwiftUI delays deallocation + let evictStart = ContinuousClock.now for tab in tabManager.tabs { tab.rowBuffer.evict() } @@ -506,6 +521,7 @@ final class MainContentCoordinator { tabManager.tabs.removeAll() tabManager.selectedTabId = nil + Self.logger.info("[PERF] teardown: data eviction=\(ContinuousClock.now - evictStart), tabCount=\(self.tabManager.tabs.count)") // Release change manager state — pluginDriver holds a strong reference // to the entire database driver which prevents deallocation @@ -517,8 +533,11 @@ final class MainContentCoordinator { filterStateManager.filters.removeAll() filterStateManager.appliedFilters.removeAll() + let schemaStart = ContinuousClock.now SchemaProviderRegistry.shared.release(for: connection.id) SchemaProviderRegistry.shared.purgeUnused() + Self.logger.info("[PERF] teardown: schema release=\(ContinuousClock.now - schemaStart)") + Self.logger.info("[PERF] teardown: TOTAL=\(ContinuousClock.now - teardownStart)") } deinit { @@ -792,12 +811,7 @@ final class MainContentCoordinator { tabManager.tabs[tabIndex].query = query tabManager.tabs[tabIndex].hasUserInteraction = true } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: query - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: query, databaseName: connection.database) } } @@ -815,12 +829,7 @@ final class MainContentCoordinator { } else if tabManager.tabs.isEmpty { tabManager.addTab(initialQuery: query, databaseName: connection.database) } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: query - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: query, databaseName: connection.database) } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index bb7a46d3b..4bb1c8ebb 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -14,9 +14,12 @@ // import Combine +import os import SwiftUI import TableProPluginKit +private let mcvLogger = Logger(subsystem: "com.TablePro", category: "MainContentView") + /// Main content view - thin presentation layer struct MainContentView: View { // MARK: - Properties @@ -64,9 +67,7 @@ struct MainContentView: View { /// 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) + // Grace period removed — no longer needed with in-app tabs (no native tab group merges) // MARK: - Environment @@ -253,40 +254,26 @@ struct MainContentView: View { // Window registration is handled by WindowAccessor in .background } .onDisappear { - // Mark teardown intent synchronously so deinit doesn't warn - // if SwiftUI deallocates the coordinator before the delayed Task fires + let disappearStart = ContinuousClock.now + mcvLogger.info("[PERF] onDisappear: START windowId=\(self.windowId)") 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) - - // If this window re-registered (temporary disappear during tab group merge), skip cleanup - if WindowLifecycleMonitor.shared.isRegistered(windowId: capturedWindowId) { - coordinator.clearTeardownScheduled() - return - } - - // Window truly closed — teardown coordinator + // Direct teardown — no grace period needed since we no longer + // create native window tabs that trigger merge cascades. + let teardownStart = ContinuousClock.now coordinator.teardown() + mcvLogger.info("[PERF] onDisappear: coordinator.teardown took \(ContinuousClock.now - teardownStart)") 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 { + mcvLogger.info("[PERF] onDisappear: other windows exist, skipping disconnect (total=\(ContinuousClock.now - disappearStart))") return } await DatabaseManager.shared.disconnectSession(connectionId) + mcvLogger.info("[PERF] onDisappear: TOTAL=\(ContinuousClock.now - disappearStart)") - // 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) } diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift new file mode 100644 index 000000000..65f29b1a6 --- /dev/null +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -0,0 +1,124 @@ +// +// EditorTabBar.swift +// TablePro +// +// Horizontal tab bar for switching between editor tabs within a connection window. +// Replaces native macOS window tabs for instant tab switching. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct EditorTabBar: View { + let tabs: [QueryTab] + @Binding var selectedTabId: UUID? + let databaseType: DatabaseType + var onClose: (UUID) -> Void + var onCloseOthers: (UUID) -> Void + var onCloseAll: () -> Void + var onReorder: ([QueryTab]) -> Void + var onRename: (UUID, String) -> Void + var onAddTab: () -> Void + var onDuplicate: (UUID) -> Void + + @State private var draggedTabId: UUID? + + var body: some View { + HStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 1) { + ForEach(tabs) { tab in + EditorTabBarItem( + tab: tab, + isSelected: tab.id == selectedTabId, + databaseType: databaseType, + onSelect: { selectedTabId = tab.id }, + onClose: { onClose(tab.id) }, + onCloseOthers: { onCloseOthers(tab.id) }, + onCloseTabsToRight: { closeTabsToRight(of: tab.id) }, + onCloseAll: onCloseAll, + onDuplicate: { onDuplicate(tab.id) }, + onRename: { name in onRename(tab.id, name) } + ) + .id(tab.id) + .onDrag { + draggedTabId = tab.id + return NSItemProvider(object: tab.id.uuidString as NSString) + } + .onDrop(of: [.text], delegate: TabDropDelegate( + targetId: tab.id, + tabs: tabs, + draggedTabId: $draggedTabId, + onReorder: onReorder + )) + } + } + .padding(.horizontal, 4) + } + .onChange(of: selectedTabId) { _, newId in + if let id = newId { + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(id, anchor: .center) + } + } + } + } + + Divider() + .frame(height: 16) + + Button { + onAddTab() + } label: { + Image(systemName: "plus") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .help(String(localized: "New Tab")) + } + .frame(height: 30) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private func closeTabsToRight(of id: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + let idsToClose = tabs[(index + 1)...].map(\.id) + for tabId in idsToClose { + onClose(tabId) + } + } +} + +// MARK: - Drag & Drop + +private struct TabDropDelegate: DropDelegate { + let targetId: UUID + let tabs: [QueryTab] + @Binding var draggedTabId: UUID? + let onReorder: ([QueryTab]) -> Void + + func performDrop(info: DropInfo) -> Bool { + draggedTabId = nil + return true + } + + func dropEntered(info: DropInfo) { + guard let draggedId = draggedTabId, + draggedId != targetId, + let fromIndex = tabs.firstIndex(where: { $0.id == draggedId }), + let toIndex = tabs.firstIndex(where: { $0.id == targetId }) + else { return } + + var reordered = tabs + let moved = reordered.remove(at: fromIndex) + reordered.insert(moved, at: toIndex) + onReorder(reordered) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } +} diff --git a/TablePro/Views/TabBar/EditorTabBarItem.swift b/TablePro/Views/TabBar/EditorTabBarItem.swift new file mode 100644 index 000000000..87e318af1 --- /dev/null +++ b/TablePro/Views/TabBar/EditorTabBarItem.swift @@ -0,0 +1,114 @@ +// +// EditorTabBarItem.swift +// TablePro +// +// Individual tab item for the editor tab bar. +// + +import SwiftUI + +struct EditorTabBarItem: View { + let tab: QueryTab + let isSelected: Bool + let databaseType: DatabaseType + var onSelect: () -> Void + var onClose: () -> Void + var onCloseOthers: () -> Void + var onCloseTabsToRight: () -> Void + var onCloseAll: () -> Void + var onDuplicate: () -> Void + var onRename: (String) -> Void + + @State private var isEditing = false + @State private var editingTitle = "" + @State private var isHovering = false + + private var icon: String { + switch tab.tabType { + case .table: + return "tablecells" + case .query: + return "chevron.left.forwardslash.chevron.right" + case .createTable: + return "plus.rectangle" + case .erDiagram: + return "chart.dots.scatter" + case .serverDashboard: + return "gauge.with.dots.needle.bottom.50percent" + } + } + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + + if isEditing { + TextField("", text: $editingTitle, onCommit: { + let trimmed = editingTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + onRename(trimmed) + } + isEditing = false + }) + .textFieldStyle(.plain) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .frame(minWidth: 40, maxWidth: 120) + } else { + Text(tab.title) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .italic(tab.isPreview) + .lineLimit(1) + } + + if tab.isFileDirty { + Circle() + .fill(Color.primary.opacity(0.5)) + .frame(width: 6, height: 6) + } + + if isHovering || isSelected { + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(width: 14, height: 14) + } else { + Color.clear + .frame(width: 14, height: 14) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.small) + .fill(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) + ) + .contentShape(Rectangle()) + .onHover { isHovering = $0 } + .onTapGesture { + onSelect() + } + .gesture( + TapGesture(count: 2).onEnded { + guard tab.tabType == .query else { return } + editingTitle = tab.title + isEditing = true + } + ) + .contextMenu { + Button(String(localized: "Close")) { onClose() } + Button(String(localized: "Close Others")) { onCloseOthers() } + Button(String(localized: "Close Tabs to the Right")) { onCloseTabsToRight() } + Divider() + Button(String(localized: "Close All")) { onCloseAll() } + Divider() + Button(String(localized: "Duplicate")) { onDuplicate() } + } + } +} From 71708c0dc0c330c51c0483869b9e9d6bfa7d98b3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 07:57:50 +0700 Subject: [PATCH 02/26] fix: resolve multiple in-app tab bar bugs and clean up stale references - Fix closeInAppTab save button no-op (data loss) - Add unsaved changes check to Close Others / Close All - Fix switchSchema not persisting tabs before clearing - Fix double-tap rename gesture conflict (exclusively before) - Fix Vim :q closing entire window instead of current tab - Show dirty indicator for pending database changes - Fix draggedTabId not cleared on cancelled drag - Fix rename TextField stuck on focus loss - Register SQL files for duplicate detection - Protect preview tabs with pending changes from replacement - Fix promotePreviewTab not clearing WindowLifecycleMonitor flag - Query hasPreview from tabManager instead of stale window monitor - Remove dead code (aggregatedTabs, closeSiblingNativeWindows) - Update 15 stale "native window tab" comments across 9 files - Add debug logging for tab navigation flow --- TablePro/ContentView.swift | 4 +- .../Infrastructure/WindowOpener.swift | 2 +- TablePro/Models/Query/EditorTabPayload.swift | 6 +- TablePro/Resources/Localizable.xcstrings | 63 ++++++++++++++++ TablePro/TableProApp.swift | 4 +- .../Main/Child/MainEditorContentView.swift | 10 ++- .../MainContentCoordinator+Navigation.swift | 65 +++++++++++++--- ...MainContentCoordinator+TabOperations.swift | 75 +++++++++++++++++-- .../MainContentView+EventHandlers.swift | 4 +- .../Main/MainContentCommandActions.swift | 3 + .../Views/Main/MainContentCoordinator.swift | 35 +-------- TablePro/Views/Main/MainContentView.swift | 5 +- .../Views/Main/SidebarNavigationResult.swift | 4 +- TablePro/Views/TabBar/EditorTabBar.swift | 4 + TablePro/Views/TabBar/EditorTabBarItem.swift | 16 +++- 15 files changed, 225 insertions(+), 75 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 0a6cf527d..da3e8fa22 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -13,7 +13,7 @@ import TableProPluginKit struct ContentView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ContentView") - /// Payload identifying what this native window-tab should display. + /// Payload identifying what this connection window should display. /// nil = default empty query tab (first window on connection). let payload: EditorTabPayload? @@ -55,7 +55,7 @@ struct ContentView: View { // Resolve session synchronously to avoid "Connecting..." flash. // For payload with connectionId: look up that specific session. - // For nil payload (native tab bar "+"): fall back to current session. + // For nil payload: fall back to current session. var resolvedSession: ConnectionSession? if let connectionId = payload?.connectionId { resolvedSession = DatabaseManager.shared.activeSessions[connectionId] diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index a0d6b01c4..e496691e9 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -51,7 +51,7 @@ internal final class WindowOpener { /// 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. + /// Opens a new connection window 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) { diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index f0ca47842..e7daac145 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -2,8 +2,8 @@ // EditorTabPayload.swift // TablePro // -// Payload for identifying the content of a native window tab. -// Used with WindowGroup(for:) to create native macOS window tabs. +// Payload for identifying the content of a connection window. +// Used with WindowGroup(for:) to create connection windows. // import Foundation @@ -18,7 +18,7 @@ internal enum TabIntent: String, Codable, Hashable { case restoreOrDefault } -/// Payload passed to each native window tab to identify what content it should display. +/// Payload passed to each connection window to identify what content it should display. /// Each window-tab receives this at creation time via `openWindow(id:value:)`. internal struct EditorTabPayload: Codable, Hashable { /// Unique identifier for this window-tab (ensures openWindow always creates a new window) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d93e02e0d..0f4668711 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" : { @@ -7303,6 +7318,9 @@ } } } + }, + "Close All" : { + }, "Close Others" : { "localizations" : { @@ -7391,6 +7409,9 @@ } } } + }, + "Close Tabs to the Right" : { + }, "Closing this tab will discard all unsaved changes." : { "extractionState" : "stale", @@ -13448,6 +13469,9 @@ } } } + }, + "Enter the passphrase for SSH key \"%@\":" : { + }, "Enter the passphrase to decrypt and import connections." : { "localizations" : { @@ -16541,8 +16565,12 @@ }, "Format Query" : { + }, + "Format Query (⇧⌘L)" : { + }, "Format Query (⌥⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -26344,6 +26372,9 @@ } } } + }, + "Preferred" : { + }, "Preserve all values as strings" : { "extractionState" : "stale", @@ -27622,8 +27653,12 @@ } } } + }, + "Quick Switcher (⇧⌘O)" : { + }, "Quick Switcher (⌘P)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27666,6 +27701,9 @@ } } } + }, + "Quit Anyway" : { + }, "Quote" : { "extractionState" : "stale", @@ -30185,6 +30223,9 @@ } } } + }, + "Save passphrase in Keychain" : { + }, "Save Sidebar Changes" : { "localizations" : { @@ -32493,6 +32534,12 @@ } } } + }, + "Some tabs have unsaved changes that will be lost." : { + + }, + "Some tabs have unsaved edits. Quitting will discard these changes." : { + }, "Something went wrong (error %d). Try again in a moment." : { "localizations" : { @@ -33093,6 +33140,9 @@ } } } + }, + "SSH Key Passphrase Required" : { + }, "SSH Port" : { "localizations" : { @@ -35194,6 +35244,9 @@ } } } + }, + "The following plugins were rejected:\n\n%@\n\nPlease update them from the plugin registry." : { + }, "The license has been suspended." : { "localizations" : { @@ -36305,8 +36358,12 @@ } } } + }, + "Toggle Filters (⇧⌘F)" : { + }, "Toggle Filters (⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39107,6 +39164,12 @@ } } } + }, + "You have unsaved changes" : { + + }, + "You have unsaved changes that will be lost." : { + }, "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 6d3a89846..c4837c528 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -441,7 +441,7 @@ struct AppMenuCommands: Commands { .keyboardShortcut("-", modifiers: .command) } - // Tab navigation shortcuts — native macOS window tabs + // Tab navigation shortcuts — in-app tab switching CommandGroup(after: .windowArrangement) { // Tab switching by number (Cmd+1 through Cmd+9) ForEach(1...9, id: \.self) { number in @@ -533,7 +533,7 @@ struct TableProApp: App { .windowResizability(.contentSize) // Main Window - opens when connecting to database - // Each native window-tab gets its own ContentView with independent state. + // Each connection window gets its own ContentView with independent state. WindowGroup(id: "main", for: EditorTabPayload.self) { $payload in ContentView(payload: payload) .background(OpenWindowHandler()) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 2e3fff9a0..8c9b983bf 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -75,7 +75,7 @@ struct MainEditorContentView: View { @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] @State private var favoriteDialogQuery: FavoriteDialogQuery? - // Native macOS window tabs — no LRU tracking needed (single tab per window) + // In-app tabs with LRU eviction for inactive tab RowBuffers // MARK: - Environment @@ -96,7 +96,7 @@ struct MainEditorContentView: View { let isHistoryVisible = coordinator.toolbarState.isHistoryPanelVisible VStack(spacing: 0) { - if tabManager.tabs.count > 1 || !tabManager.tabs.isEmpty { + if !tabManager.tabs.isEmpty { EditorTabBar( tabs: tabManager.tabs, selectedTabId: Binding( @@ -272,7 +272,11 @@ struct MainEditorContentView: View { connectionId: coordinator.connection.id, connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, onCloseTab: { - NSApp.keyWindow?.close() + if tabManager.tabs.count > 1, let selectedId = tabManager.selectedTabId { + coordinator.closeInAppTab(selectedId) + } else { + NSApp.keyWindow?.close() + } }, onExecuteQuery: { coordinator.runQuery() }, onExplain: { variant in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 974feda48..d7c1091c0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -36,11 +36,25 @@ extension MainContentCoordinator { let currentSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema + // DEBUG: Log full tab state for diagnosing replacement issues + let selTab = tabManager.selectedTab + let selName = selTab?.tableName ?? "nil" + let selPreview = selTab?.isPreview == true + let cmHasChanges = changeManager.hasChanges + let previewEnabled = AppSettingsManager.shared.tabs.enablePreviewTabs + navigationLogger.info("[TAB-NAV] openTableTab(\"\(tableName, privacy: .public)\") tabCount=\(self.tabManager.tabs.count) selected=\(selName, privacy: .public) selPreview=\(selPreview) changes=\(cmHasChanges) previewEnabled=\(previewEnabled)") + for (i, tab) in tabManager.tabs.enumerated() { + let tName = tab.tableName ?? "nil" + let isSel = tab.id == tabManager.selectedTabId + navigationLogger.info("[TAB-NAV] tab[\(i)] \"\(tab.title, privacy: .public)\" table=\(tName, privacy: .public) isPreview=\(tab.isPreview) pending=\(tab.pendingChanges.hasChanges) dirty=\(tab.isFileDirty) sel=\(isSel)") + } + // Fast path: if this table is already the active tab in the same database, skip all work if let current = tabManager.selectedTab, current.tabType == .table, current.tableName == tableName, current.databaseName == currentDatabase { + navigationLogger.info("[TAB-NAV] → FAST PATH: same table already active") if showStructure, let idx = tabManager.selectedTabIndex { tabManager.tabs[idx].showStructure = true } @@ -48,7 +62,7 @@ extension MainContentCoordinator { } // During database switch, update the existing tab in-place instead of - // opening a new native window tab. + // opening a new in-app tab. if sidebarLoadingState == .loading { if tabManager.tabs.isEmpty { tabManager.addTableTab( @@ -64,6 +78,7 @@ extension MainContentCoordinator { if let existingTab = tabManager.tabs.first(where: { $0.tabType == .table && $0.tableName == tableName && $0.databaseName == currentDatabase }) { + navigationLogger.info("[TAB-NAV] → EXISTING TAB: switching to \(existingTab.id)") tabManager.selectedTabId = existingTab.id return } @@ -108,7 +123,7 @@ extension MainContentCoordinator { } // In-place navigation: replace current tab content rather than - // opening new native window tabs (e.g. Redis database switching). + // opening new in-app tabs (e.g. Redis database switching). if navigationModel == .inPlace { if let oldTab = tabManager.selectedTab, let oldTableName = oldTab.tableName { filterStateManager.saveLastFilters(for: oldTableName) @@ -138,6 +153,9 @@ extension MainContentCoordinator { || filterStateManager.hasAppliedFilters || (tabManager.selectedTab?.sortState.isSorting ?? false) if hasActiveWork { + let hasFilters = filterStateManager.hasAppliedFilters + let hasSorting = tabManager.selectedTab?.sortState.isSorting ?? false + navigationLogger.info("[TAB-NAV] → ACTIVE WORK: addTableTabInApp (changes=\(cmHasChanges) filters=\(hasFilters) sorting=\(hasSorting))") addTableTabInApp( tableName: tableName, databaseName: currentDatabase, @@ -150,11 +168,13 @@ extension MainContentCoordinator { // Preview tab mode: reuse or create a preview tab instead of a new native window if AppSettingsManager.shared.tabs.enablePreviewTabs { + navigationLogger.info("[TAB-NAV] → PREVIEW MODE: calling openPreviewTab") openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, schemaName: currentSchema, showStructure: showStructure) return } // Default: open table in a new in-app tab + navigationLogger.info("[TAB-NAV] → DEFAULT: addTableTabInApp (preview disabled)") addTableTabInApp( tableName: tableName, databaseName: currentDatabase, @@ -202,11 +222,32 @@ extension MainContentCoordinator { // Check if a preview tab already exists in this window's tab manager if let previewIndex = tabManager.tabs.firstIndex(where: { $0.isPreview }) { let previewTab = tabManager.tabs[previewIndex] + let pName = previewTab.tableName ?? "nil" + let pSel = previewTab.id == tabManager.selectedTabId + navigationLogger.info("[TAB-NAV] openPreviewTab(\"\(tableName, privacy: .public)\"): found preview[\(previewIndex)] \"\(previewTab.title, privacy: .public)\" table=\(pName, privacy: .public) pending=\(previewTab.pendingChanges.hasChanges) dirty=\(previewTab.isFileDirty) sel=\(pSel)") // Skip if preview tab already shows this table if previewTab.tableName == tableName, previewTab.databaseName == databaseName { + navigationLogger.info("[TAB-NAV] → PREVIEW SKIP: same table") tabManager.selectedTabId = previewTab.id return } + // Preview tab has unsaved changes — promote it and open a new tab instead + if previewTab.pendingChanges.hasChanges || previewTab.isFileDirty { + navigationLogger.info("[TAB-NAV] → PREVIEW PROMOTE: has unsaved changes, creating new tab") + tabManager.tabs[previewIndex].isPreview = false + if let wid = windowId { + WindowLifecycleMonitor.shared.setPreview(false, for: wid) + } + addTableTabInApp( + tableName: tableName, + databaseName: databaseName, + schemaName: schemaName, + isView: isView, + showStructure: showStructure + ) + return + } + navigationLogger.info("[TAB-NAV] → PREVIEW REPLACE: replacing \"\(pName, privacy: .public)\" with \"\(tableName, privacy: .public)\"") if let oldTableName = previewTab.tableName { filterStateManager.saveLastFilters(for: oldTableName) } @@ -249,9 +290,12 @@ extension MainContentCoordinator { } return false }() + let reusableSelName = tabManager.selectedTab?.tableName ?? "nil" + navigationLogger.info("[TAB-NAV] openPreviewTab: no preview found, isReusableTab=\(isReusableTab) selectedTab=\(reusableSelName, privacy: .public)") if let selectedTab = tabManager.selectedTab, isReusableTab { // Skip if already showing this table if selectedTab.tableName == tableName, selectedTab.databaseName == databaseName { + navigationLogger.info("[TAB-NAV] → REUSABLE SKIP: same table") return } // If reusable tab has active work, promote it and open new tab instead @@ -263,6 +307,7 @@ extension MainContentCoordinator { || selectedTab.sortState.isSorting || hasUnsavedQuery if previewHasWork { + navigationLogger.info("[TAB-NAV] → REUSABLE PROMOTE: has work, creating new tab") promotePreviewTab() addTableTabInApp( tableName: tableName, @@ -273,6 +318,7 @@ extension MainContentCoordinator { ) return } + navigationLogger.info("[TAB-NAV] → REUSABLE REPLACE: replacing \"\(reusableSelName, privacy: .public)\" with \"\(tableName, privacy: .public)\"") if let oldTableName = selectedTab.tableName { filterStateManager.saveLastFilters(for: oldTableName) } @@ -297,6 +343,7 @@ extension MainContentCoordinator { } // No reusable tab: create a new in-app preview tab + navigationLogger.info("[TAB-NAV] → NEW PREVIEW TAB: creating for \"\(tableName, privacy: .public)\"") tabManager.addPreviewTableTab( tableName: tableName, databaseType: connection.type, @@ -320,6 +367,9 @@ extension MainContentCoordinator { guard let tabIndex = tabManager.selectedTabIndex, tabManager.tabs[tabIndex].isPreview else { return } tabManager.tabs[tabIndex].isPreview = false + if let wid = windowId { + WindowLifecycleMonitor.shared.setPreview(false, for: wid) + } } func showAllTablesMetadata() { @@ -363,14 +413,6 @@ extension MainContentCoordinator { // MARK: - Database Switching - /// Close all sibling native window-tabs except the current key window. - /// Each table opened via WindowOpener creates a separate NSWindow in the same - /// tab group. Clearing `tabManager.tabs` only affects the in-app state of the - /// *current* window — other NSWindows remain open with stale content. - /// No-op: with in-app tabs, there are no sibling native windows per connection. - /// Kept as a placeholder to avoid changing callers in switchDatabase/switchSchema. - private func closeSiblingNativeWindows() {} - /// Switch to a different database (called from database switcher) func switchDatabase(to database: String) async { sidebarLoadingState = .loading @@ -385,7 +427,6 @@ extension MainContentCoordinator { let previousDatabase = toolbarState.databaseName toolbarState.databaseName = database - closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) tabManager.tabs = [] tabManager.selectedTabId = nil @@ -450,7 +491,7 @@ extension MainContentCoordinator { let previousSchema = toolbarState.databaseName toolbarState.databaseName = schema - closeSiblingNativeWindows() + persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift index 996f69a48..273a87e87 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift @@ -26,8 +26,7 @@ extension MainContentCoordinator { ) switch result { case .save: - // Save then close — delegate to existing save flow - break + await self.saveDataChangesAndClose(tabId: id) case .dontSave: changeManager.clearChangesAndUndoHistory() removeTab(id) @@ -85,19 +84,81 @@ extension MainContentCoordinator { persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) } + private func saveDataChangesAndClose(tabId: UUID) async { + var truncates: Set = [] + var deletes: Set = [] + var options: [String: TableOperationOptions] = [:] + let saved = await withCheckedContinuation { (continuation: CheckedContinuation) in + saveCompletionContinuation = continuation + saveChanges(pendingTruncates: &truncates, pendingDeletes: &deletes, tableOperationOptions: &options) + } + if saved { + removeTab(tabId) + } + } + func closeOtherTabs(excluding id: UUID) { - let idsToClose = tabManager.tabs.filter { $0.id != id }.map(\.id) - for tabId in idsToClose { - if let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - tabManager.tabs[index].rowBuffer.evict() + let tabsToClose = tabManager.tabs.filter { $0.id != id } + let selectedIsBeingClosed = tabManager.selectedTabId != id + let hasUnsavedWork = tabsToClose.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } + || (selectedIsBeingClosed && changeManager.hasChanges) + + if hasUnsavedWork { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Some tabs have unsaved changes that will be lost."), + window: contentWindow + ) + switch result { + case .save, .dontSave: + if selectedIsBeingClosed { + changeManager.clearChangesAndUndoHistory() + } + forceCloseOtherTabs(excluding: id) + case .cancel: + return + } } - tabManager.tabs.removeAll { $0.id == tabId } + return } + + forceCloseOtherTabs(excluding: id) + } + + private func forceCloseOtherTabs(excluding id: UUID) { + for index in tabManager.tabs.indices where tabManager.tabs[index].id != id { + tabManager.tabs[index].rowBuffer.evict() + } + tabManager.tabs.removeAll { $0.id != id } tabManager.selectedTabId = id persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) } func closeAllTabs() { + let hasUnsavedWork = tabManager.tabs.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } + || changeManager.hasChanges + + if hasUnsavedWork { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "You have unsaved changes that will be lost."), + window: contentWindow + ) + switch result { + case .save, .dontSave: + changeManager.clearChangesAndUndoHistory() + forceCloseAllTabs() + case .cancel: + return + } + } + return + } + + forceCloseAllTabs() + } + + private func forceCloseAllTabs() { for tab in tabManager.tabs { tab.rowBuffer.evict() } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 57a0d4586..45b6a5131 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -100,13 +100,13 @@ extension MainContentView { } // Only navigate when this is the focused window. - // Prevents feedback loops when shared sidebar state syncs across native tabs. + // Prevents feedback loops when shared sidebar state syncs across connection windows. guard isKeyWindow else { return } let isPreviewMode = AppSettingsManager.shared.tabs.enablePreviewTabs - let hasPreview = WindowLifecycleMonitor.shared.previewWindow(for: connection.id) != nil + let hasPreview = tabManager.tabs.contains { $0.isPreview } let result = SidebarNavigationResult.resolve( clickedTableName: tableName, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index d592f96ea..a649abbaf 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -811,6 +811,9 @@ final class MainContentCommandActions { databaseName: connection.database, sourceFileURL: url ) + if let windowId = coordinator?.windowId { + WindowLifecycleMonitor.shared.registerSourceFile(url, windowId: windowId) + } } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 362c40e7e..ec88a0801 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -219,39 +219,6 @@ final class MainContentCoordinator { .flatMap { $0.tabManager.tabs } } - /// Collect non-preview tabs for persistence. - private static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { - let coordinators = activeCoordinators.values - .filter { $0.connectionId == connectionId } - - // Sort by native window tab order to preserve left-to-right position - let orderedCoordinators: [MainContentCoordinator] - if let firstWindow = coordinators.compactMap({ $0.contentWindow }).first, - let tabbedWindows = firstWindow.tabbedWindows { - let windowOrder = Dictionary(uniqueKeysWithValues: - tabbedWindows.enumerated().map { (ObjectIdentifier($0.element), $0.offset) } - ) - orderedCoordinators = coordinators.sorted { a, b in - let aIdx = a.contentWindow.flatMap { windowOrder[ObjectIdentifier($0)] } ?? Int.max - let bIdx = b.contentWindow.flatMap { windowOrder[ObjectIdentifier($0)] } ?? Int.max - return aIdx < bIdx - } - } else { - orderedCoordinators = Array(coordinators) - } - - return orderedCoordinators - .flatMap { $0.tabManager.tabs } - .filter { !$0.isPreview } - } - - /// Get selected tab ID from any coordinator for a given connectionId. - private static func aggregatedSelectedTabId(for connectionId: UUID) -> UUID? { - activeCoordinators.values - .first { $0.connectionId == connectionId && $0.tabManager.selectedTabId != nil }? - .tabManager.selectedTabId - } - /// Check if this coordinator is the first registered for its connection. private func isFirstCoordinatorForConnection() -> Bool { Self.activeCoordinators.values @@ -269,7 +236,7 @@ final class MainContentCoordinator { }() /// Evict row data for background tabs in this coordinator to free memory. - /// Called when the coordinator's native window-tab becomes inactive. + /// Called when the connection window becomes inactive. /// The currently selected tab is kept in memory so the user sees no /// refresh flicker when switching back — matching native macOS behavior. /// Background tabs are re-fetched automatically when selected. diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 4bb1c8ebb..10ebe5bd7 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -260,8 +260,7 @@ struct MainContentView: View { let connectionId = connection.id Task { @MainActor in - // Direct teardown — no grace period needed since we no longer - // create native window tabs that trigger merge cascades. + // Direct teardown — no grace period needed with in-app tabs. let teardownStart = ContinuousClock.now coordinator.teardown() mcvLogger.info("[PERF] onDisappear: coordinator.teardown took \(ContinuousClock.now - teardownStart)") @@ -381,7 +380,7 @@ struct MainContentView: View { isKeyWindow = false lastResignKeyDate = Date() - // Schedule row data eviction for inactive native window-tabs. + // Schedule row data eviction when the connection window becomes inactive. // 5s delay avoids thrashing when quickly switching between tabs. // Per-tab pendingChanges checks inside evictInactiveRowData() protect // tabs with unsaved changes from eviction. diff --git a/TablePro/Views/Main/SidebarNavigationResult.swift b/TablePro/Views/Main/SidebarNavigationResult.swift index 7ea14d69c..8483f835b 100644 --- a/TablePro/Views/Main/SidebarNavigationResult.swift +++ b/TablePro/Views/Main/SidebarNavigationResult.swift @@ -15,7 +15,7 @@ enum SidebarNavigationResult: Equatable { /// No existing tabs: navigate in-place inside this window. case openInPlace /// Existing tabs present: revert sidebar to the current tab immediately, - /// then open the clicked table in a new native window tab. + /// then open the clicked table in a new in-app tab. /// Reverting synchronously prevents SwiftUI from rendering the [B] state /// before coalescing back to [A] — eliminating the visible flash. case revertAndOpenNewWindow @@ -54,7 +54,7 @@ enum SidebarNavigationResult: Equatable { return .openNewPreviewTab } - // Default: revert sidebar synchronously (no flash), then open in a new native tab. + // Default: revert sidebar synchronously (no flash), then open in a new in-app tab. return .revertAndOpenNewWindow } } diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift index 65f29b1a6..b6838446f 100644 --- a/TablePro/Views/TabBar/EditorTabBar.swift +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -121,4 +121,8 @@ private struct TabDropDelegate: DropDelegate { func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } + + func dropExited(info: DropInfo) { + draggedTabId = nil + } } diff --git a/TablePro/Views/TabBar/EditorTabBarItem.swift b/TablePro/Views/TabBar/EditorTabBarItem.swift index 87e318af1..f1408997a 100644 --- a/TablePro/Views/TabBar/EditorTabBarItem.swift +++ b/TablePro/Views/TabBar/EditorTabBarItem.swift @@ -22,6 +22,7 @@ struct EditorTabBarItem: View { @State private var isEditing = false @State private var editingTitle = "" @State private var isHovering = false + @FocusState private var isEditingFocused: Bool private var icon: String { switch tab.tabType { @@ -55,6 +56,12 @@ struct EditorTabBarItem: View { .textFieldStyle(.plain) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .frame(minWidth: 40, maxWidth: 120) + .focused($isEditingFocused) + .onChange(of: isEditingFocused) { _, focused in + if !focused && isEditing { + isEditing = false + } + } } else { Text(tab.title) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) @@ -62,7 +69,7 @@ struct EditorTabBarItem: View { .lineLimit(1) } - if tab.isFileDirty { + if tab.isFileDirty || tab.pendingChanges.hasChanges { Circle() .fill(Color.primary.opacity(0.5)) .frame(width: 6, height: 6) @@ -91,15 +98,16 @@ struct EditorTabBarItem: View { ) .contentShape(Rectangle()) .onHover { isHovering = $0 } - .onTapGesture { - onSelect() - } .gesture( TapGesture(count: 2).onEnded { guard tab.tabType == .query else { return } editingTitle = tab.title isEditing = true + isEditingFocused = true } + .exclusively(before: TapGesture(count: 1).onEnded { + onSelect() + }) ) .contextMenu { Button(String(localized: "Close")) { onClose() } From 2043e052c90d3f481c44c47630019a8c703435ef 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: Thu, 16 Apr 2026 08:15:06 +0700 Subject: [PATCH 03/26] fix: remove PERF debug logging, fix stale native tab references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all 45 [PERF] log statements and timing infrastructure - Remove unused OSSignposter (windowPerfLog) and 5 PERF-only loggers - Fix tabbingMode = .preferred → .disallowed in windowDidBecomeKey - Remove cross-window previewWindow() lookup in sidebar double-click - Replace WindowLifecycleMonitor.setPreview() with direct subtitle update - Remove dead isFirstCoordinatorForConnection() multi-window code - Simplify ConnectionSwitcherPopover tabbingMode manipulation --- TablePro/AppDelegate+WindowConfig.swift | 23 +++------------- TablePro/ContentView.swift | 21 +++------------ .../Database/DatabaseManager+Sessions.swift | 8 ------ .../Infrastructure/SessionStateFactory.swift | 8 ------ .../TabPersistenceCoordinator.swift | 8 ------ .../WindowLifecycleMonitor.swift | 5 ---- .../Infrastructure/WindowOpener.swift | 3 --- .../MainContentCoordinator+Navigation.swift | 13 +++------- .../Extensions/MainContentView+Setup.swift | 15 ----------- .../Views/Main/MainContentCoordinator.swift | 26 ------------------- TablePro/Views/Main/MainContentView.swift | 11 -------- .../Toolbar/ConnectionSwitcherPopover.swift | 16 +++--------- 12 files changed, 13 insertions(+), 144 deletions(-) diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 3c62965b1..cae3c6888 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -10,7 +10,6 @@ import os import SwiftUI private let windowLogger = Logger(subsystem: "com.TablePro", category: "WindowConfig") -private let windowPerfLog = OSSignposter(subsystem: "com.TablePro", category: "WindowPerf") extension AppDelegate { // MARK: - Dock Menu @@ -64,7 +63,6 @@ extension AppDelegate { } @objc func newWindowForTab(_ sender: Any?) { - let start = ContinuousClock.now guard let keyWindow = NSApp.keyWindow, let connectionId = MainActor.assumeIsolated({ WindowLifecycleMonitor.shared.connectionId(fromWindow: keyWindow) @@ -77,7 +75,6 @@ extension AppDelegate { coordinator.addNewQueryTab() } } - windowLogger.info("[PERF] newWindowForTab: \(ContinuousClock.now - start)") } @objc func connectFromDock(_ sender: NSMenuItem) { @@ -225,7 +222,6 @@ extension AppDelegate { // MARK: - Window Notifications @objc func windowDidBecomeKey(_ notification: Notification) { - let becomeKeyStart = ContinuousClock.now guard let window = notification.object as? NSWindow else { return } let windowId = ObjectIdentifier(window) @@ -256,23 +252,21 @@ extension AppDelegate { } if isMainWindow(window) && !configuredWindows.contains(windowId) { - windowLogger.info("[PERF] windowDidBecomeKey: configuring new main window (elapsed so far: \(ContinuousClock.now - becomeKeyStart))") - window.tabbingMode = .preferred + // In-app tabs: disallow native window tabbing for editor tabs. + // Connection-level grouping (groupAllConnectionTabs) uses addTabbedWindow below. + window.tabbingMode = .disallowed window.isRestorable = false configuredWindows.insert(windowId) let pendingConnectionId = MainActor.assumeIsolated { WindowOpener.shared.consumeOldestPendingConnectionId() } - windowLogger.info("[PERF] windowDidBecomeKey: consumeOldestPending=\(String(describing: pendingConnectionId)), isAutoReconnecting=\(self.isAutoReconnecting) (elapsed: \(ContinuousClock.now - becomeKeyStart))") if pendingConnectionId == nil && !isAutoReconnecting { if let tabbedWindows = window.tabbedWindows, tabbedWindows.count > 1 { - windowLogger.info("[PERF] windowDidBecomeKey: orphan window already tabbed, returning (total: \(ContinuousClock.now - becomeKeyStart))") return } window.orderOut(nil) - windowLogger.info("[PERF] windowDidBecomeKey: orphan window hidden (total: \(ContinuousClock.now - becomeKeyStart))") return } @@ -285,7 +279,6 @@ extension AppDelegate { NSWindow.allowsAutomaticWindowTabbing = true } - let windowLookupStart = ContinuousClock.now let matchingWindow: NSWindow? if groupAll { let existingMainWindows = NSApp.windows.filter { @@ -301,24 +294,17 @@ extension AppDelegate { && $0.tabbingIdentifier == resolvedIdentifier } } - windowLogger.info("[PERF] windowDidBecomeKey: window lookup took \(ContinuousClock.now - windowLookupStart), totalWindows=\(NSApp.windows.count), groupAll=\(groupAll)") if let existingWindow = matchingWindow { - let mergeStart = ContinuousClock.now let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow targetWindow.addTabbedWindow(window, ordered: .above) window.makeKeyAndOrderFront(nil) - windowLogger.info("[PERF] windowDidBecomeKey: addTabbedWindow took \(ContinuousClock.now - mergeStart)") } } - windowLogger.info("[PERF] windowDidBecomeKey: main window config TOTAL=\(ContinuousClock.now - becomeKeyStart)") - } else { - windowLogger.info("[PERF] windowDidBecomeKey: non-main or already configured (total: \(ContinuousClock.now - becomeKeyStart))") } } @objc func windowWillClose(_ notification: Notification) { - let closeStart = ContinuousClock.now guard let window = notification.object as? NSWindow else { return } configuredWindows.remove(ObjectIdentifier(window)) @@ -327,14 +313,11 @@ extension AppDelegate { let remainingMainWindows = NSApp.windows.filter { $0 !== window && isMainWindow($0) && $0.isVisible }.count - windowLogger.info("[PERF] windowWillClose: isMainWindow=true, remainingMainWindows=\(remainingMainWindows), totalWindows=\(NSApp.windows.count)") - if remainingMainWindows == 0 { NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) openWelcomeWindow() } } - windowLogger.info("[PERF] windowWillClose: total=\(ContinuousClock.now - closeStart)") } @objc func windowDidChangeOcclusionState(_ notification: Notification) { diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index da3e8fa22..1857ea3fc 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -35,7 +35,6 @@ struct ContentView: View { private let storage = ConnectionStorage.shared init(payload: EditorTabPayload?) { - let initStart = ContinuousClock.now self.payload = payload let defaultTitle: String if payload?.tabType == .serverDashboard { @@ -63,7 +62,6 @@ struct ContentView: View { resolvedSession = DatabaseManager.shared.activeSessions[currentId] } _currentSession = State(initialValue: resolvedSession) - let sessionResolved = ContinuousClock.now if let session = resolvedSession { _rightPanelState = State(initialValue: RightPanelState()) @@ -79,7 +77,6 @@ struct ContentView: View { _rightPanelState = State(initialValue: nil) _sessionState = State(initialValue: nil) } - Self.logger.info("[PERF] ContentView.init: total=\(ContinuousClock.now - initStart), sessionResolve=\(sessionResolved - initStart), stateFactory=\(ContinuousClock.now - sessionResolved)") } var body: some View { @@ -174,21 +171,9 @@ struct ContentView: View { activeTableName: windowTitle, onDoubleClick: { table in let isView = table.type == .view - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: currentSession.connection.id), - let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { - // If the preview tab shows this table, promote it - if previewCoordinator.tabManager.selectedTab?.tableName == table.name { - previewCoordinator.promotePreviewTab() - } else { - // Preview shows a different table — promote it first, then open this table permanently - previewCoordinator.promotePreviewTab() - sessionState.coordinator.openTableTab(table.name, isView: isView) - } - } else { - // No preview tab — promote current if it's a preview, otherwise open permanently - sessionState.coordinator.promotePreviewTab() - sessionState.coordinator.openTableTab(table.name, isView: isView) - } + // Promote any in-app preview tab, then open the table permanently + sessionState.coordinator.promotePreviewTab() + sessionState.coordinator.openTableTab(table.name, isView: isView) }, pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 0778c40db..7cabfbea9 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -241,28 +241,21 @@ extension DatabaseManager { /// Disconnect a specific session func disconnectSession(_ sessionId: UUID) async { - let disconnStart = ContinuousClock.now guard let session = activeSessions[sessionId] else { return } // Close SSH tunnel if exists if session.connection.resolvedSSHConfig.enabled { - let sshStart = ContinuousClock.now do { try await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id) } catch { Self.logger.warning("SSH tunnel cleanup failed for \(session.connection.name): \(error.localizedDescription)") } - Self.logger.info("[PERF] disconnectSession: SSH tunnel close=\(ContinuousClock.now - sshStart)") } // Stop health monitoring - let healthStart = ContinuousClock.now await stopHealthMonitor(for: sessionId) - Self.logger.info("[PERF] disconnectSession: stopHealthMonitor=\(ContinuousClock.now - healthStart)") - let driverStart = ContinuousClock.now session.driver?.disconnect() - Self.logger.info("[PERF] disconnectSession: driver.disconnect=\(ContinuousClock.now - driverStart)") removeSessionEntry(for: sessionId) @@ -282,7 +275,6 @@ extension DatabaseManager { AppSettingsStorage.shared.saveLastConnectionId(nil) } } - Self.logger.info("[PERF] disconnectSession: TOTAL=\(ContinuousClock.now - disconnStart)") } /// Disconnect all sessions diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 83f75ed0e..7ab632752 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -7,9 +7,6 @@ // import Foundation -import os - -private let sessionFactoryLogger = Logger(subsystem: "com.TablePro", category: "SessionStateFactory") @MainActor enum SessionStateFactory { @@ -26,7 +23,6 @@ enum SessionStateFactory { connection: DatabaseConnection, payload: EditorTabPayload? ) -> SessionState { - let factoryStart = ContinuousClock.now let tabMgr = QueryTabManager() let changeMgr = DataChangeManager() changeMgr.databaseType = connection.type @@ -115,7 +111,6 @@ enum SessionStateFactory { } } - let preCoordTime = ContinuousClock.now let coord = MainContentCoordinator( connection: connection, tabManager: tabMgr, @@ -124,9 +119,6 @@ enum SessionStateFactory { columnVisibilityManager: colVisMgr, toolbarState: toolbarSt ) - let coordTime = ContinuousClock.now - - sessionFactoryLogger.info("[PERF] SessionStateFactory.create total=\(ContinuousClock.now - factoryStart), coordinator.init=\(coordTime - preCoordTime), tabSetup=\(preCoordTime - factoryStart)") return SessionState( tabManager: tabMgr, diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index bc03e9e21..f2266d486 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -8,9 +8,6 @@ import Foundation import Observation -import os - -private let persistLogger = Logger(subsystem: "com.TablePro", category: "TabPersistence") /// Result of tab restoration from disk internal struct RestoreResult { @@ -102,20 +99,15 @@ internal final class TabPersistenceCoordinator { /// Restore tabs from disk. Called once at window creation. internal func restoreFromDisk() async -> RestoreResult { - let start = ContinuousClock.now guard let state = await TabDiskActor.shared.load(connectionId: connectionId) else { - persistLogger.info("[PERF] restoreFromDisk: no saved state (\(ContinuousClock.now - start))") return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } guard !state.tabs.isEmpty else { - persistLogger.info("[PERF] restoreFromDisk: empty tabs (\(ContinuousClock.now - start))") return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } - let mapStart = ContinuousClock.now let restoredTabs = state.tabs.map { QueryTab(from: $0) } - persistLogger.info("[PERF] restoreFromDisk: diskLoad=\(mapStart - start), tabMapping=\(ContinuousClock.now - mapStart), totalTabs=\(restoredTabs.count), total=\(ContinuousClock.now - start)") return RestoreResult( tabs: restoredTabs, selectedTabId: state.selectedTabId, diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 88965cc1c..fdc6adf78 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -199,9 +199,7 @@ internal final class WindowLifecycleMonitor { } private func handleWindowClose(_ closedWindow: NSWindow) { - let closeStart = ContinuousClock.now guard let (windowId, entry) = entries.first(where: { $0.value.window === closedWindow }) else { - Self.logger.info("[PERF] handleWindowClose: window not found in entries") return } @@ -216,12 +214,9 @@ internal final class WindowLifecycleMonitor { let hasRemainingWindows = entries.values.contains { $0.connectionId == closedConnectionId && $0.window != nil } - Self.logger.info("[PERF] handleWindowClose: cleanup took \(ContinuousClock.now - closeStart), hasRemainingWindows=\(hasRemainingWindows), remainingEntries=\(self.entries.count)") if !hasRemainingWindows { Task { - let disconnectStart = ContinuousClock.now await DatabaseManager.shared.disconnectSession(closedConnectionId) - Self.logger.info("[PERF] handleWindowClose: disconnectSession took \(ContinuousClock.now - disconnectStart)") } } } diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index e496691e9..477293c4f 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -55,7 +55,6 @@ 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) { - let start = ContinuousClock.now pendingPayloads.append((id: payload.id, connectionId: payload.connectionId)) if let openWindow { openWindow(id: "main", value: payload) @@ -63,8 +62,6 @@ internal final class WindowOpener { Self.logger.info("openWindow not set — falling back to .openMainWindow notification") NotificationCenter.default.post(name: .openMainWindow, object: payload) } - let elapsed = ContinuousClock.now - start - Self.logger.info("[PERF] openNativeTab: \(elapsed) (intent=\(String(describing: payload.intent)), pendingCount=\(self.pendingPayloads.count))") } /// Called by MainContentView.configureWindow after the window is fully set up. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index d7c1091c0..e7a705560 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -92,10 +92,7 @@ extension MainContentCoordinator { databaseType: connection.type, databaseName: currentDatabase ) - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(true, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) — Preview" - } + contentWindow?.subtitle = "\(connection.name) — Preview" } else { tabManager.addTableTab( tableName: tableName, @@ -235,9 +232,7 @@ extension MainContentCoordinator { if previewTab.pendingChanges.hasChanges || previewTab.isFileDirty { navigationLogger.info("[TAB-NAV] → PREVIEW PROMOTE: has unsaved changes, creating new tab") tabManager.tabs[previewIndex].isPreview = false - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(false, for: wid) - } + contentWindow?.subtitle = connection.name addTableTabInApp( tableName: tableName, databaseName: databaseName, @@ -367,9 +362,7 @@ extension MainContentCoordinator { guard let tabIndex = tabManager.selectedTabIndex, tabManager.tabs[tabIndex].isPreview else { return } tabManager.tabs[tabIndex].isPreview = false - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(false, for: wid) - } + contentWindow?.subtitle = connection.name } func showAllTablesMetadata() { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index b7d85f056..8c7b23e1d 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -6,24 +6,18 @@ // for MainContentView. Extracted to reduce main view complexity. // -import os import SwiftUI -private let setupLogger = Logger(subsystem: "com.TablePro", category: "MainContentSetup") - extension MainContentView { // MARK: - Initialization func initializeAndRestoreTabs() async { - let start = ContinuousClock.now guard !hasInitialized else { return } hasInitialized = true Task { await coordinator.loadSchemaIfNeeded() } guard let payload else { - setupLogger.info("[PERF] initializeAndRestoreTabs: no payload, calling handleRestoreOrDefault") await handleRestoreOrDefault() - setupLogger.info("[PERF] initializeAndRestoreTabs: total=\(ContinuousClock.now - start) (restoreOrDefault path)") return } @@ -71,17 +65,14 @@ extension MainContentView { } case .newEmptyTab: - setupLogger.info("[PERF] initializeAndRestoreTabs: newEmptyTab (total=\(ContinuousClock.now - start))") return case .restoreOrDefault: await handleRestoreOrDefault() - setupLogger.info("[PERF] initializeAndRestoreTabs: restoreOrDefault (total=\(ContinuousClock.now - start))") } } private func handleRestoreOrDefault() async { - let restoreStart = ContinuousClock.now if WindowLifecycleMonitor.shared.hasOtherWindows(for: connection.id, excluding: windowId) { if tabManager.tabs.isEmpty { let allTabs = MainContentCoordinator.allTabs(for: connection.id) @@ -91,9 +82,7 @@ extension MainContentView { return } - let preRestore = ContinuousClock.now let result = await coordinator.persistence.restoreFromDisk() - setupLogger.info("[PERF] handleRestoreOrDefault: restoreFromDisk took \(ContinuousClock.now - preRestore), tabCount=\(result.tabs.count)") if !result.tabs.isEmpty { var restoredTabs = result.tabs for i in restoredTabs.indices where restoredTabs[i].tabType == .table { @@ -170,7 +159,6 @@ extension MainContentView { /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. func configureWindow(_ window: NSWindow) { - let configStart = ContinuousClock.now let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false if isPreview { window.subtitle = "\(connection.name) — Preview" @@ -184,14 +172,12 @@ extension MainContentView { window.tabbingMode = .disallowed coordinator.windowId = windowId - let registerStart = ContinuousClock.now WindowLifecycleMonitor.shared.register( window: window, connectionId: connection.id, windowId: windowId, isPreview: isPreview ) - setupLogger.info("[PERF] configureWindow: WindowLifecycleMonitor.register took \(ContinuousClock.now - registerStart)") viewWindow = window coordinator.contentWindow = window @@ -207,7 +193,6 @@ extension MainContentView { // Update command actions window reference now that it's available commandActions?.window = window - setupLogger.info("[PERF] configureWindow: total=\(ContinuousClock.now - configStart)") } func setupCommandActions() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index ec88a0801..93421f852 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -219,12 +219,6 @@ final class MainContentCoordinator { .flatMap { $0.tabManager.tabs } } - /// Check if this coordinator is the first registered for its connection. - private func isFirstCoordinatorForConnection() -> Bool { - Self.activeCoordinators.values - .first { $0.connectionId == self.connectionId } === self - } - private static let registerTerminationObserver: Void = { NotificationCenter.default.addObserver( forName: NSApplication.willTerminateNotification, @@ -272,27 +266,22 @@ final class MainContentCoordinator { columnVisibilityManager: ColumnVisibilityManager, toolbarState: ConnectionToolbarState ) { - let coordInitStart = ContinuousClock.now self.connection = connection self.tabManager = tabManager self.changeManager = changeManager self.filterStateManager = filterStateManager self.columnVisibilityManager = columnVisibilityManager self.toolbarState = toolbarState - let dialectStart = ContinuousClock.now let dialect = PluginManager.shared.sqlDialect(for: connection.type) self.queryBuilder = TableQueryBuilder( databaseType: connection.type, dialect: dialect, dialectQuote: quoteIdentifierFromDialect(dialect) ) - Self.logger.info("[PERF] MainContentCoordinator.init: dialect+queryBuilder=\(ContinuousClock.now - dialectStart)") self.persistence = TabPersistenceCoordinator(connectionId: connection.id) - let schemaStart = ContinuousClock.now self.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) SchemaProviderRegistry.shared.retain(for: connection.id) - Self.logger.info("[PERF] MainContentCoordinator.init: schemaProvider=\(ContinuousClock.now - schemaStart)") urlFilterObservers = setupURLNotificationObservers() // Synchronous save at quit time. NotificationCenter with queue: .main @@ -315,11 +304,9 @@ final class MainContentCoordinator { } _ = Self.registerTerminationObserver - Self.logger.info("[PERF] MainContentCoordinator.init: TOTAL=\(ContinuousClock.now - coordInitStart)") } func markActivated() { - let activateStart = ContinuousClock.now _didActivate.withLock { $0 = true } registerForPersistence() setupPluginDriver() @@ -334,7 +321,6 @@ final class MainContentCoordinator { } } } - Self.logger.info("[PERF] markActivated: total=\(ContinuousClock.now - activateStart)") } /// Start watching the database file for external changes (SQLite, DuckDB). @@ -431,11 +417,9 @@ 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 teardownStart = ContinuousClock.now _didTeardown.withLock { $0 = true } unregisterFromPersistence() - let observerStart = ContinuousClock.now for observer in urlFilterObservers { NotificationCenter.default.removeObserver(observer) } @@ -448,7 +432,6 @@ final class MainContentCoordinator { NotificationCenter.default.removeObserver(observer) pluginDriverObserver = nil } - Self.logger.info("[PERF] teardown: observer cleanup=\(ContinuousClock.now - observerStart)") fileWatcher?.stopWatching(connectionId: connectionId) fileWatcher = nil @@ -464,21 +447,16 @@ final class MainContentCoordinator { // Let the view layer release cached row providers before we drop RowBuffers. // Called synchronously here because SwiftUI onChange handlers don't fire // reliably on disappearing views. - let onTeardownStart = ContinuousClock.now onTeardown?() onTeardown = nil - Self.logger.info("[PERF] teardown: onTeardown callback=\(ContinuousClock.now - onTeardownStart)") // Notify DataGridView coordinators to release NSTableView cell views - let notifyStart = ContinuousClock.now NotificationCenter.default.post( name: Self.teardownNotification, object: connection.id ) - Self.logger.info("[PERF] teardown: teardownNotification post=\(ContinuousClock.now - notifyStart)") // Release heavy data so memory drops even if SwiftUI delays deallocation - let evictStart = ContinuousClock.now for tab in tabManager.tabs { tab.rowBuffer.evict() } @@ -488,7 +466,6 @@ final class MainContentCoordinator { tabManager.tabs.removeAll() tabManager.selectedTabId = nil - Self.logger.info("[PERF] teardown: data eviction=\(ContinuousClock.now - evictStart), tabCount=\(self.tabManager.tabs.count)") // Release change manager state — pluginDriver holds a strong reference // to the entire database driver which prevents deallocation @@ -500,11 +477,8 @@ final class MainContentCoordinator { filterStateManager.filters.removeAll() filterStateManager.appliedFilters.removeAll() - let schemaStart = ContinuousClock.now SchemaProviderRegistry.shared.release(for: connection.id) SchemaProviderRegistry.shared.purgeUnused() - Self.logger.info("[PERF] teardown: schema release=\(ContinuousClock.now - schemaStart)") - Self.logger.info("[PERF] teardown: TOTAL=\(ContinuousClock.now - teardownStart)") } deinit { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 10ebe5bd7..918fde335 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -14,12 +14,9 @@ // import Combine -import os import SwiftUI import TableProPluginKit -private let mcvLogger = Logger(subsystem: "com.TablePro", category: "MainContentView") - /// Main content view - thin presentation layer struct MainContentView: View { // MARK: - Properties @@ -67,8 +64,6 @@ struct MainContentView: View { /// Reference to this view's NSWindow for filtering notifications @State var viewWindow: NSWindow? - // Grace period removed — no longer needed with in-app tabs (no native tab group merges) - // MARK: - Environment @@ -254,24 +249,18 @@ struct MainContentView: View { // Window registration is handled by WindowAccessor in .background } .onDisappear { - let disappearStart = ContinuousClock.now - mcvLogger.info("[PERF] onDisappear: START windowId=\(self.windowId)") coordinator.markTeardownScheduled() let connectionId = connection.id Task { @MainActor in // Direct teardown — no grace period needed with in-app tabs. - let teardownStart = ContinuousClock.now coordinator.teardown() - mcvLogger.info("[PERF] onDisappear: coordinator.teardown took \(ContinuousClock.now - teardownStart)") rightPanelState.teardown() guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { - mcvLogger.info("[PERF] onDisappear: other windows exist, skipping disconnect (total=\(ContinuousClock.now - disappearStart))") return } await DatabaseManager.shared.disconnectSession(connectionId) - mcvLogger.info("[PERF] onDisappear: TOTAL=\(ContinuousClock.now - disappearStart)") try? await Task.sleep(for: .seconds(2)) malloc_zone_pressure_relief(nil, 0) diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index ccaccd5bf..d212daabc 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -328,17 +328,9 @@ struct ConnectionSwitcherPopover: View { /// merge as a tab with the current connection's window group /// (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) - } 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) - DispatchQueue.main.async { - currentWindow?.tabbingMode = previousMode - } - } + // Each connection opens its own window. With in-app tabs and + // tabbingMode = .disallowed, windows don't auto-merge. + // groupAllConnectionTabs merging is handled by windowDidBecomeKey. + WindowOpener.shared.openNativeTab(payload) } } From 73f4741f850e1a8545bce0c97bf34d4a1ae77bf1 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: Thu, 16 Apr 2026 08:20:35 +0700 Subject: [PATCH 04/26] fix: defer SessionState creation from ContentView.init to view lifecycle SessionStateFactory.create() was called eagerly in ContentView.init, which SwiftUI invokes speculatively during body evaluation. Each call allocated 7 heavy objects (QueryTabManager, MainContentCoordinator, etc.) that were immediately discarded, causing "QueryTabManager deallocated" spam and wasted resources. Consolidated 3 duplicate creation sites into a single ensureSessionState() method, called only from reactive handlers (onChange, onReceive) after the view is committed to the hierarchy. --- TablePro/ContentView.swift | 58 +++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 1857ea3fc..dea3b24dd 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -63,20 +63,10 @@ struct ContentView: View { } _currentSession = State(initialValue: resolvedSession) - if let session = resolvedSession { - _rightPanelState = State(initialValue: RightPanelState()) - let state = SessionStateFactory.create( - connection: session.connection, payload: payload - ) - _sessionState = State(initialValue: state) - if payload?.intent == .newEmptyTab, - let tabTitle = state.coordinator.tabManager.selectedTab?.title { - _windowTitle = State(initialValue: tabTitle) - } - } else { - _rightPanelState = State(initialValue: nil) - _sessionState = State(initialValue: nil) - } + // SessionState is created lazily in ensureSessionState() on first + // connection event — not in init, which SwiftUI may call speculatively. + _rightPanelState = State(initialValue: nil) + _sessionState = State(initialValue: nil) } var body: some View { @@ -113,15 +103,7 @@ struct ContentView: View { currentSession = DatabaseManager.shared.activeSessions[connectionId] columnVisibility = currentSession != nil ? .all : .detailOnly if let session = currentSession { - if rightPanelState == nil { - rightPanelState = RightPanelState() - } - if sessionState == nil { - sessionState = SessionStateFactory.create( - connection: session.connection, - payload: payload - ) - } + ensureSessionState(for: session) } } else { currentSession = nil @@ -333,18 +315,30 @@ struct ContentView: View { return } currentSession = newSession - // Update window title on first session connect (fixes cold-launch stale title) - if payload?.tableName == nil, windowTitle == "SQL Query" || windowTitle.hasSuffix(" Query") { - windowTitle = newSession.connection.name - } + ensureSessionState(for: newSession) + } + + /// Create SessionState exactly once per connection. Called from reactive + /// handlers (onChange, handleConnectionStatusChange) — never from init, + /// because SwiftUI may call init speculatively during body evaluation. + private func ensureSessionState(for session: ConnectionSession) { + guard sessionState == nil else { return } if rightPanelState == nil { rightPanelState = RightPanelState() } - if sessionState == nil { - sessionState = SessionStateFactory.create( - connection: newSession.connection, - payload: payload - ) + let state = SessionStateFactory.create( + connection: session.connection, + payload: payload + ) + sessionState = state + columnVisibility = .all + // Update window title on first connect + if payload?.intent == .newEmptyTab, + let tabTitle = state.coordinator.tabManager.selectedTab?.title { + windowTitle = tabTitle + } else if payload?.tableName == nil, + windowTitle == "SQL Query" || windowTitle.hasSuffix(" Query") { + windowTitle = session.connection.name } } From 745675a62da7cd1e7ab5affa9d3a9cc346568208 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: Thu, 16 Apr 2026 08:34:41 +0700 Subject: [PATCH 05/26] fix: restore onDisappear grace period, remove race-prone disconnectSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI fires onDisappear transiently when the view hierarchy is reconstructed (e.g., sessionState changing from nil to a value causes if-let branches to rebuild). Without a grace period, the immediate coordinator.teardown() + disconnectSession killed the SSH tunnel while the connection was still being established, causing auto-reconnect to fail repeatedly. Fix: restore 200ms grace period with window re-registration check, and remove disconnectSession from onDisappear entirely — WindowLifecycleMonitor .handleWindowClose already handles disconnect on actual NSWindow close. --- TablePro/AppDelegate+WindowConfig.swift | 6 +++++- TablePro/AppDelegate.swift | 2 ++ TablePro/ContentView.swift | 3 +++ TablePro/Views/Main/MainContentView.swift | 25 +++++++++++++++-------- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index cae3c6888..1907b3637 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -349,25 +349,29 @@ extension AppDelegate { } isAutoReconnecting = true + windowLogger.info("[RESTORE] attemptAutoReconnectAll: \(validConnections.count) connection(s): \(validConnections.map(\.name))") Task { @MainActor [weak self] in guard let self else { return } defer { self.isAutoReconnecting = false } for connection in validConnections { + windowLogger.info("[RESTORE] opening window for '\(connection.name)' (\(connection.id))") let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) WindowOpener.shared.openNativeTab(payload) do { try await DatabaseManager.shared.connectToSession(connection) + windowLogger.info("[RESTORE] connected '\(connection.name)' successfully") } catch is CancellationError { + windowLogger.info("[RESTORE] connection cancelled for '\(connection.name)'") for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { window.close() } continue } catch { windowLogger.error( - "Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)" + "[RESTORE] auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)" ) for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { window.close() diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 7b7cef60d..69b23f6ab 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -114,6 +114,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let settings = AppSettingsStorage.shared.loadGeneral() if settings.startupBehavior == .reopenLast { let connectionIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() + Self.logger.info("[RESTORE] startupBehavior=reopenLast, savedConnectionIds=\(connectionIds.map(\.uuidString))") if !connectionIds.isEmpty { closeWelcomeWindowEagerly() attemptAutoReconnectAll(connectionIds: connectionIds) @@ -212,6 +213,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let openConnectionIds = connections .filter { activeIds.contains($0.id) } .map(\.id) + Self.logger.info("[RESTORE] applicationWillTerminate: activeSessions=\(activeIds.map(\.uuidString)), saving connectionIds=\(openConnectionIds.map(\.uuidString))") AppSettingsStorage.shared.saveLastOpenConnectionIds(openConnectionIds) LinkedFolderWatcher.shared.stop() diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index dea3b24dd..1d88a3117 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -35,6 +35,7 @@ struct ContentView: View { private let storage = ConnectionStorage.shared init(payload: EditorTabPayload?) { + Self.logger.info("[RESTORE] ContentView.init: payload=\(String(describing: payload?.connectionId)), intent=\(String(describing: payload?.intent))") self.payload = payload let defaultTitle: String if payload?.tabType == .serverDashboard { @@ -296,6 +297,7 @@ struct ContentView: View { } guard let newSession = sessions[sid] else { if currentSession?.id == sid { + Self.logger.info("[RESTORE] handleConnectionStatusChange: session \(sid) removed from activeSessions — tearing down") closingSessionId = sid rightPanelState?.teardown() rightPanelState = nil @@ -323,6 +325,7 @@ struct ContentView: View { /// because SwiftUI may call init speculatively during body evaluation. private func ensureSessionState(for session: ConnectionSession) { guard sessionState == nil else { return } + Self.logger.info("[RESTORE] ensureSessionState: creating for '\(session.connection.name)' (\(session.connection.id)), payload=\(String(describing: self.payload?.intent))") if rightPanelState == nil { rightPanelState = RightPanelState() } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 918fde335..96a6cae04 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -14,6 +14,7 @@ // import Combine +import os import SwiftUI import TableProPluginKit @@ -249,21 +250,29 @@ struct MainContentView: View { // Window registration is handled by WindowAccessor in .background } .onDisappear { + MainContentCoordinator.logger.info("[RESTORE] MainContentView.onDisappear: windowId=\(self.windowId), connection=\(self.connection.name)") coordinator.markTeardownScheduled() - let connectionId = connection.id + let capturedWindowId = windowId Task { @MainActor in - // Direct teardown — no grace period needed with in-app tabs. - coordinator.teardown() - rightPanelState.teardown() + // Grace period: SwiftUI fires onDisappear transiently when the + // view hierarchy is reconstructed (e.g., sessionState changing from + // nil → value causes if-let branches to rebuild). Wait briefly to + // let onAppear re-register if this is a transient removal. + try? await Task.sleep(for: .milliseconds(200)) - guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { + if WindowLifecycleMonitor.shared.isRegistered(windowId: capturedWindowId) { + coordinator.clearTeardownScheduled() return } - await DatabaseManager.shared.disconnectSession(connectionId) - try? await Task.sleep(for: .seconds(2)) - malloc_zone_pressure_relief(nil, 0) + // View truly removed — teardown coordinator. + // Database disconnect is NOT done here — it's handled by + // WindowLifecycleMonitor.handleWindowClose when the NSWindow + // actually closes (a deterministic AppKit signal, not a + // SwiftUI lifecycle heuristic). + coordinator.teardown() + rightPanelState.teardown() } } .onChange(of: pendingChangeTrigger) { From e312398d40fe428accbacd3411f291cde8dbc8c1 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: Thu, 16 Apr 2026 08:50:15 +0700 Subject: [PATCH 06/26] refactor: move coordinator teardown from onDisappear to NSWindow willCloseNotification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI's onDisappear fires transiently during view hierarchy reconstruction (e.g., sessionState nil→value causes if-let branches to rebuild). Using it for coordinator teardown caused race conditions where SSH tunnels were killed during auto-reconnect. Apple's recommended pattern for macOS: use NSWindow.willCloseNotification for deterministic resource cleanup, not SwiftUI view lifecycle callbacks. Changes: - WindowLifecycleMonitor: add onWindowClose closure to Entry, called in handleWindowClose before disconnect — deterministic teardown - MainContentView+Setup: pass coordinator/rightPanelState teardown closure to WindowLifecycleMonitor.register() - MainContentView: remove onDisappear teardown (grace period hack gone) - MainContentCoordinator: remove markTeardownScheduled/clearTeardownScheduled and _teardownScheduled lock (no longer needed) - Remove all [RESTORE] and [TAB-NAV] debug logging --- TablePro/AppDelegate+WindowConfig.swift | 7 ---- TablePro/AppDelegate.swift | 2 -- TablePro/ContentView.swift | 3 -- .../WindowLifecycleMonitor.swift | 17 ++++++++-- .../MainContentCoordinator+Navigation.swift | 32 ------------------- .../Extensions/MainContentView+Setup.swift | 6 +++- .../Views/Main/MainContentCoordinator.swift | 19 ++--------- TablePro/Views/Main/MainContentView.swift | 28 +++------------- 8 files changed, 27 insertions(+), 87 deletions(-) diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 1907b3637..1de812485 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -349,30 +349,23 @@ extension AppDelegate { } isAutoReconnecting = true - windowLogger.info("[RESTORE] attemptAutoReconnectAll: \(validConnections.count) connection(s): \(validConnections.map(\.name))") Task { @MainActor [weak self] in guard let self else { return } defer { self.isAutoReconnecting = false } for connection in validConnections { - windowLogger.info("[RESTORE] opening window for '\(connection.name)' (\(connection.id))") let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) WindowOpener.shared.openNativeTab(payload) do { try await DatabaseManager.shared.connectToSession(connection) - windowLogger.info("[RESTORE] connected '\(connection.name)' successfully") } catch is CancellationError { - windowLogger.info("[RESTORE] connection cancelled for '\(connection.name)'") for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { window.close() } continue } catch { - windowLogger.error( - "[RESTORE] auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)" - ) for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { window.close() } diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 69b23f6ab..7b7cef60d 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -114,7 +114,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let settings = AppSettingsStorage.shared.loadGeneral() if settings.startupBehavior == .reopenLast { let connectionIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() - Self.logger.info("[RESTORE] startupBehavior=reopenLast, savedConnectionIds=\(connectionIds.map(\.uuidString))") if !connectionIds.isEmpty { closeWelcomeWindowEagerly() attemptAutoReconnectAll(connectionIds: connectionIds) @@ -213,7 +212,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let openConnectionIds = connections .filter { activeIds.contains($0.id) } .map(\.id) - Self.logger.info("[RESTORE] applicationWillTerminate: activeSessions=\(activeIds.map(\.uuidString)), saving connectionIds=\(openConnectionIds.map(\.uuidString))") AppSettingsStorage.shared.saveLastOpenConnectionIds(openConnectionIds) LinkedFolderWatcher.shared.stop() diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 1d88a3117..dea3b24dd 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -35,7 +35,6 @@ struct ContentView: View { private let storage = ConnectionStorage.shared init(payload: EditorTabPayload?) { - Self.logger.info("[RESTORE] ContentView.init: payload=\(String(describing: payload?.connectionId)), intent=\(String(describing: payload?.intent))") self.payload = payload let defaultTitle: String if payload?.tabType == .serverDashboard { @@ -297,7 +296,6 @@ struct ContentView: View { } guard let newSession = sessions[sid] else { if currentSession?.id == sid { - Self.logger.info("[RESTORE] handleConnectionStatusChange: session \(sid) removed from activeSessions — tearing down") closingSessionId = sid rightPanelState?.teardown() rightPanelState = nil @@ -325,7 +323,6 @@ struct ContentView: View { /// because SwiftUI may call init speculatively during body evaluation. private func ensureSessionState(for session: ConnectionSession) { guard sessionState == nil else { return } - Self.logger.info("[RESTORE] ensureSessionState: creating for '\(session.connection.name)' (\(session.connection.id)), payload=\(String(describing: self.payload?.intent))") if rightPanelState == nil { rightPanelState = RightPanelState() } diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index fdc6adf78..32d771ce6 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -21,6 +21,9 @@ internal final class WindowLifecycleMonitor { weak var window: NSWindow? var observer: NSObjectProtocol? var isPreview: Bool = false + /// Called on NSWindow.willCloseNotification — deterministic teardown + /// for coordinator and panel state that must not depend on SwiftUI onDisappear. + var onWindowClose: (@MainActor () -> Void)? } private var entries: [UUID: Entry] = [:] @@ -40,7 +43,11 @@ internal final class WindowLifecycleMonitor { // MARK: - Registration /// Register a window and start observing its willCloseNotification. - internal func register(window: NSWindow, connectionId: UUID, windowId: UUID, isPreview: Bool = false) { + internal func register( + window: NSWindow, connectionId: UUID, windowId: UUID, + isPreview: Bool = false, + onWindowClose: (@MainActor () -> Void)? = nil + ) { // Remove any existing entry for this windowId to avoid duplicate observers if let existing = entries[windowId] { if existing.window !== window { @@ -66,7 +73,8 @@ internal final class WindowLifecycleMonitor { connectionId: connectionId, window: window, observer: observer, - isPreview: isPreview + isPreview: isPreview, + onWindowClose: onWindowClose ) } @@ -205,6 +213,11 @@ internal final class WindowLifecycleMonitor { let closedConnectionId = entry.connectionId + // Deterministic teardown: coordinator and panel state are cleaned up here, + // triggered by NSWindow.willCloseNotification — not by SwiftUI onDisappear + // which fires transiently during view hierarchy reconstruction. + entry.onWindowClose?() + if let observer = entry.observer { NotificationCenter.default.removeObserver(observer) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index e7a705560..821d87f20 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -36,25 +36,11 @@ extension MainContentCoordinator { let currentSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema - // DEBUG: Log full tab state for diagnosing replacement issues - let selTab = tabManager.selectedTab - let selName = selTab?.tableName ?? "nil" - let selPreview = selTab?.isPreview == true - let cmHasChanges = changeManager.hasChanges - let previewEnabled = AppSettingsManager.shared.tabs.enablePreviewTabs - navigationLogger.info("[TAB-NAV] openTableTab(\"\(tableName, privacy: .public)\") tabCount=\(self.tabManager.tabs.count) selected=\(selName, privacy: .public) selPreview=\(selPreview) changes=\(cmHasChanges) previewEnabled=\(previewEnabled)") - for (i, tab) in tabManager.tabs.enumerated() { - let tName = tab.tableName ?? "nil" - let isSel = tab.id == tabManager.selectedTabId - navigationLogger.info("[TAB-NAV] tab[\(i)] \"\(tab.title, privacy: .public)\" table=\(tName, privacy: .public) isPreview=\(tab.isPreview) pending=\(tab.pendingChanges.hasChanges) dirty=\(tab.isFileDirty) sel=\(isSel)") - } - // Fast path: if this table is already the active tab in the same database, skip all work if let current = tabManager.selectedTab, current.tabType == .table, current.tableName == tableName, current.databaseName == currentDatabase { - navigationLogger.info("[TAB-NAV] → FAST PATH: same table already active") if showStructure, let idx = tabManager.selectedTabIndex { tabManager.tabs[idx].showStructure = true } @@ -78,7 +64,6 @@ extension MainContentCoordinator { if let existingTab = tabManager.tabs.first(where: { $0.tabType == .table && $0.tableName == tableName && $0.databaseName == currentDatabase }) { - navigationLogger.info("[TAB-NAV] → EXISTING TAB: switching to \(existingTab.id)") tabManager.selectedTabId = existingTab.id return } @@ -150,9 +135,6 @@ extension MainContentCoordinator { || filterStateManager.hasAppliedFilters || (tabManager.selectedTab?.sortState.isSorting ?? false) if hasActiveWork { - let hasFilters = filterStateManager.hasAppliedFilters - let hasSorting = tabManager.selectedTab?.sortState.isSorting ?? false - navigationLogger.info("[TAB-NAV] → ACTIVE WORK: addTableTabInApp (changes=\(cmHasChanges) filters=\(hasFilters) sorting=\(hasSorting))") addTableTabInApp( tableName: tableName, databaseName: currentDatabase, @@ -165,13 +147,11 @@ extension MainContentCoordinator { // Preview tab mode: reuse or create a preview tab instead of a new native window if AppSettingsManager.shared.tabs.enablePreviewTabs { - navigationLogger.info("[TAB-NAV] → PREVIEW MODE: calling openPreviewTab") openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, schemaName: currentSchema, showStructure: showStructure) return } // Default: open table in a new in-app tab - navigationLogger.info("[TAB-NAV] → DEFAULT: addTableTabInApp (preview disabled)") addTableTabInApp( tableName: tableName, databaseName: currentDatabase, @@ -219,18 +199,13 @@ extension MainContentCoordinator { // Check if a preview tab already exists in this window's tab manager if let previewIndex = tabManager.tabs.firstIndex(where: { $0.isPreview }) { let previewTab = tabManager.tabs[previewIndex] - let pName = previewTab.tableName ?? "nil" - let pSel = previewTab.id == tabManager.selectedTabId - navigationLogger.info("[TAB-NAV] openPreviewTab(\"\(tableName, privacy: .public)\"): found preview[\(previewIndex)] \"\(previewTab.title, privacy: .public)\" table=\(pName, privacy: .public) pending=\(previewTab.pendingChanges.hasChanges) dirty=\(previewTab.isFileDirty) sel=\(pSel)") // Skip if preview tab already shows this table if previewTab.tableName == tableName, previewTab.databaseName == databaseName { - navigationLogger.info("[TAB-NAV] → PREVIEW SKIP: same table") tabManager.selectedTabId = previewTab.id return } // Preview tab has unsaved changes — promote it and open a new tab instead if previewTab.pendingChanges.hasChanges || previewTab.isFileDirty { - navigationLogger.info("[TAB-NAV] → PREVIEW PROMOTE: has unsaved changes, creating new tab") tabManager.tabs[previewIndex].isPreview = false contentWindow?.subtitle = connection.name addTableTabInApp( @@ -242,7 +217,6 @@ extension MainContentCoordinator { ) return } - navigationLogger.info("[TAB-NAV] → PREVIEW REPLACE: replacing \"\(pName, privacy: .public)\" with \"\(tableName, privacy: .public)\"") if let oldTableName = previewTab.tableName { filterStateManager.saveLastFilters(for: oldTableName) } @@ -285,12 +259,9 @@ extension MainContentCoordinator { } return false }() - let reusableSelName = tabManager.selectedTab?.tableName ?? "nil" - navigationLogger.info("[TAB-NAV] openPreviewTab: no preview found, isReusableTab=\(isReusableTab) selectedTab=\(reusableSelName, privacy: .public)") if let selectedTab = tabManager.selectedTab, isReusableTab { // Skip if already showing this table if selectedTab.tableName == tableName, selectedTab.databaseName == databaseName { - navigationLogger.info("[TAB-NAV] → REUSABLE SKIP: same table") return } // If reusable tab has active work, promote it and open new tab instead @@ -302,7 +273,6 @@ extension MainContentCoordinator { || selectedTab.sortState.isSorting || hasUnsavedQuery if previewHasWork { - navigationLogger.info("[TAB-NAV] → REUSABLE PROMOTE: has work, creating new tab") promotePreviewTab() addTableTabInApp( tableName: tableName, @@ -313,7 +283,6 @@ extension MainContentCoordinator { ) return } - navigationLogger.info("[TAB-NAV] → REUSABLE REPLACE: replacing \"\(reusableSelName, privacy: .public)\" with \"\(tableName, privacy: .public)\"") if let oldTableName = selectedTab.tableName { filterStateManager.saveLastFilters(for: oldTableName) } @@ -338,7 +307,6 @@ extension MainContentCoordinator { } // No reusable tab: create a new in-app preview tab - navigationLogger.info("[TAB-NAV] → NEW PREVIEW TAB: creating for \"\(tableName, privacy: .public)\"") tabManager.addPreviewTableTab( tableName: tableName, databaseType: connection.type, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 8c7b23e1d..8549c68bf 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -176,7 +176,11 @@ extension MainContentView { window: window, connectionId: connection.id, windowId: windowId, - isPreview: isPreview + isPreview: isPreview, + onWindowClose: { [coordinator, rightPanelState] in + coordinator.teardown() + rightPanelState.teardown() + } ) viewWindow = window diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 93421f852..60f6798f4 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -165,13 +165,8 @@ final class MainContentCoordinator { /// Tracks whether teardown() was called; used by deinit to log missed teardowns @ObservationIgnored private let _didTeardown = OSAllocatedUnfairLock(initialState: false) - /// Tracks whether teardown has been scheduled (but not yet executed) - /// so deinit doesn't warn if SwiftUI deallocates before the delayed Task fires - @ObservationIgnored private let _teardownScheduled = OSAllocatedUnfairLock(initialState: false) - - /// Whether teardown is scheduled or already completed — used by views to skip - /// persistence during window close teardown - var isTearingDown: Bool { _teardownScheduled.withLock { $0 } || _didTeardown.withLock { $0 } } + /// Whether teardown has completed — used by views to skip persistence during teardown + var isTearingDown: Bool { _didTeardown.withLock { $0 } } /// Set when NSApplication is terminating — suppresses deinit warning since /// SwiftUI does not call onDisappear during app termination @@ -363,14 +358,6 @@ final class MainContentCoordinator { } } - func markTeardownScheduled() { - _teardownScheduled.withLock { $0 = true } - } - - func clearTeardownScheduled() { - _teardownScheduled.withLock { $0 = false } - } - func refreshTables() async { lastSchemaRefreshDate = Date() sidebarLoadingState = .loading @@ -486,7 +473,7 @@ final class MainContentCoordinator { saveCompletionContinuation = nil let connectionId = connection.id - let alreadyHandled = _didTeardown.withLock { $0 } || _teardownScheduled.withLock { $0 } + let alreadyHandled = _didTeardown.withLock { $0 } // Never-activated coordinators are throwaway instances created by SwiftUI // during body re-evaluation — @State only keeps the first, rest are discarded diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 96a6cae04..386c3b571 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -250,30 +250,10 @@ struct MainContentView: View { // Window registration is handled by WindowAccessor in .background } .onDisappear { - MainContentCoordinator.logger.info("[RESTORE] MainContentView.onDisappear: windowId=\(self.windowId), connection=\(self.connection.name)") - coordinator.markTeardownScheduled() - - let capturedWindowId = windowId - Task { @MainActor in - // Grace period: SwiftUI fires onDisappear transiently when the - // view hierarchy is reconstructed (e.g., sessionState changing from - // nil → value causes if-let branches to rebuild). Wait briefly to - // let onAppear re-register if this is a transient removal. - try? await Task.sleep(for: .milliseconds(200)) - - if WindowLifecycleMonitor.shared.isRegistered(windowId: capturedWindowId) { - coordinator.clearTeardownScheduled() - return - } - - // View truly removed — teardown coordinator. - // Database disconnect is NOT done here — it's handled by - // WindowLifecycleMonitor.handleWindowClose when the NSWindow - // actually closes (a deterministic AppKit signal, not a - // SwiftUI lifecycle heuristic). - coordinator.teardown() - rightPanelState.teardown() - } + // No teardown here. Coordinator and panel cleanup is handled by + // WindowLifecycleMonitor.handleWindowClose (NSWindow.willCloseNotification) + // — a deterministic AppKit signal. SwiftUI's onDisappear fires transiently + // during view hierarchy reconstruction and is not reliable for resource cleanup. } .onChange(of: pendingChangeTrigger) { updateToolbarPendingState() From 61af26ac1d0f05a611bb63091fb615091b19cd14 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: Thu, 16 Apr 2026 09:07:17 +0700 Subject: [PATCH 07/26] fix: persist open connection IDs incrementally per Apple guidelines Apple's documentation: "save data progressively and not rely solely on user actions to save important information." applicationWillTerminate does not fire on SIGKILL (Xcode Cmd+R, Force Quit, memory pressure). Now saves active connection IDs to UserDefaults immediately on connect and disconnect, so auto-reconnect works correctly after any termination. The applicationWillTerminate save is kept as a belt-and-suspenders fallback. --- .../Core/Database/DatabaseManager+Sessions.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 7cabfbea9..600e33db4 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -144,6 +144,7 @@ extension DatabaseManager { // Save as last connection for "Reopen Last Session" feature AppSettingsStorage.shared.saveLastConnectionId(connection.id) + persistOpenConnectionIds() // Post notification for reliable delivery NotificationCenter.default.post(name: .databaseDidConnect, object: nil) @@ -264,6 +265,7 @@ extension DatabaseManager { // Clean up shared sidebar state for this connection SharedSidebarState.removeConnection(sessionId) + persistOpenConnectionIds() // If this was the current session, switch to another or clear if currentSessionId == sessionId { @@ -316,6 +318,18 @@ extension DatabaseManager { NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId) } + /// Persist active connection IDs to UserDefaults immediately. + /// Apple's recommended pattern: save critical state incrementally as it changes, + /// not only in applicationWillTerminate (which doesn't fire on SIGKILL/Force Quit). + private func persistOpenConnectionIds() { + let connections = ConnectionStorage.shared.loadConnections() + let activeIds = Set(activeSessions.keys) + let openIds = connections + .filter { activeIds.contains($0.id) } + .map(\.id) + AppSettingsStorage.shared.saveLastOpenConnectionIds(openIds) + } + #if DEBUG /// Test-only: inject a session for unit testing without real database connections internal func injectSession(_ session: ConnectionSession, for connectionId: UUID) { From aeb41ebbb275ac7cec4d8e2f3220f0ba8ab93c72 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: Thu, 16 Apr 2026 09:24:05 +0700 Subject: [PATCH 08/26] feat: add reopen closed tab, MRU selection, and pinned tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopen Closed Tab (Cmd+Shift+T): - Closed tabs stored in per-window history stack (capped at 20) - Reopened tabs get fresh RowBuffer, data re-fetched on demand MRU Tab Selection: - Track tab activation order in QueryTabManager - On tab close, select the most recently active tab (not adjacent) - Matches browser behavior (Chrome, Safari) Pinned Tabs: - Right-click → Pin/Unpin Tab - Pinned tabs show pin icon, no close button - Always at left side of tab bar, separated by divider - Survive Close Others and Close All - Persisted across sessions via isPinned in PersistedTab --- CHANGELOG.md | 6 ++ .../TabPersistenceCoordinator.swift | 3 +- TablePro/Models/Query/QueryTab.swift | 5 ++ TablePro/Models/Query/QueryTabManager.swift | 39 ++++++++++ TablePro/Models/Query/QueryTabState.swift | 1 + TablePro/TableProApp.swift | 8 ++ .../Main/Child/MainEditorContentView.swift | 3 +- ...MainContentCoordinator+TabOperations.swift | 76 +++++++++++++++---- .../MainContentCoordinator+TabSwitch.swift | 5 ++ .../Main/MainContentCommandActions.swift | 8 ++ TablePro/Views/TabBar/EditorTabBar.swift | 68 +++++++++++------ TablePro/Views/TabBar/EditorTabBarItem.swift | 18 ++++- 12 files changed, 196 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d28fadcb..9066ece05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Reopen closed tab with Cmd+Shift+T (up to 20 tabs in history) +- Pinned tabs — pin important tabs to prevent accidental close, always at left side +- MRU tab selection — closing a tab now selects the most recently active tab, not just adjacent + ### Changed - Replace native macOS window tabs with in-app tab bar for instant tab switching (was 600ms+ per tab) diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index f2266d486..b123675cb 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -149,7 +149,8 @@ internal final class TabPersistenceCoordinator { isView: tab.isView, databaseName: tab.databaseName, schemaName: tab.schemaName, - sourceFileURL: tab.sourceFileURL + sourceFileURL: tab.sourceFileURL, + isPinned: tab.isPinned ) } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 3e56804b8..f3e2f07ae 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -104,6 +104,9 @@ struct QueryTab: Identifiable, Equatable { // Whether this tab is a preview (temporary) tab that gets replaced on next navigation var isPreview: Bool + // Whether this tab is pinned (cannot be closed, always at left) + var isPinned: Bool + // Multi-result-set support (Phase 0: added alongside existing single-result properties) var resultSets: [ResultSet] = [] var activeResultSetId: UUID? @@ -174,6 +177,7 @@ struct QueryTab: Identifiable, Equatable { self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() self.isPreview = false + self.isPinned = false self.sourceFileURL = nil self.resultVersion = 0 self.metadataVersion = 0 @@ -211,6 +215,7 @@ struct QueryTab: Identifiable, Equatable { self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() self.isPreview = false + self.isPinned = persisted.isPinned self.sourceFileURL = persisted.sourceFileURL self.resultVersion = 0 self.metadataVersion = 0 diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 10c5120ff..8be278d6c 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -19,6 +19,45 @@ final class QueryTabManager { @ObservationIgnored private var _tabIndexMap: [UUID: Int] = [:] @ObservationIgnored private var _tabIndexMapDirty = true + // MARK: - Closed Tab History (Cmd+Shift+T reopen) + + /// Closed tab snapshots for reopen. Capped to limit memory. + @ObservationIgnored private var closedTabHistory: [QueryTab] = [] + private static let maxClosedHistory = 20 + + func pushClosedTab(_ tab: QueryTab) { + closedTabHistory.append(tab) + if closedTabHistory.count > Self.maxClosedHistory { + closedTabHistory.removeFirst() + } + } + + func popClosedTab() -> QueryTab? { + closedTabHistory.popLast() + } + + var hasClosedTabs: Bool { !closedTabHistory.isEmpty } + + // MARK: - MRU Tab Activation Order + + /// Most-recently-used tab order for smart selection after close. + @ObservationIgnored private(set) var tabActivationOrder: [UUID] = [] + + func trackActivation(_ tabId: UUID) { + tabActivationOrder.removeAll { $0 == tabId } + tabActivationOrder.append(tabId) + } + + /// Returns the most recently active tab ID, excluding a given ID. + func mruTabId(excluding id: UUID) -> UUID? { + for candidateId in tabActivationOrder.reversed() { + if candidateId != id, tabs.contains(where: { $0.id == candidateId }) { + return candidateId + } + } + return nil + } + private func rebuildTabIndexMapIfNeeded() { guard _tabIndexMapDirty else { return } _tabIndexMap = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($1.id, $0) }) diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 5856830e4..bc1539086 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -26,6 +26,7 @@ struct PersistedTab: Codable { var schemaName: String? var sourceFileURL: URL? var erDiagramSchemaKey: String? + var isPinned: Bool = false } /// Stores pending changes for a tab (used to preserve state when switching tabs) diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index c4837c528..5ccd55f8e 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -473,6 +473,14 @@ struct AppMenuCommands: Commands { Divider() + Button(String(localized: "Reopen Closed Tab")) { + actions?.reopenClosedTab() + } + .keyboardShortcut("t", modifiers: [.command, .shift]) + .disabled(!(actions?.canReopenClosedTab ?? false)) + + Divider() + Button("Bring All to Front") { NSApp.arrangeInFront(nil) } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 8c9b983bf..7d8f548ef 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -110,7 +110,8 @@ struct MainEditorContentView: View { onReorder: { tabs in coordinator.reorderTabs(tabs) }, onRename: { id, name in coordinator.renameTab(id, to: name) }, onAddTab: { coordinator.addNewQueryTab() }, - onDuplicate: { id in coordinator.duplicateTab(id) } + onDuplicate: { id in coordinator.duplicateTab(id) }, + onTogglePin: { id in coordinator.togglePinTab(id) } ) Divider() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift index 273a87e87..90545f662 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift @@ -2,7 +2,7 @@ // MainContentCoordinator+TabOperations.swift // TablePro // -// In-app tab bar operations: close, reorder, rename, duplicate, add. +// In-app tab bar operations: close, reorder, rename, duplicate, pin, reopen. // import AppKit @@ -15,6 +15,10 @@ extension MainContentCoordinator { guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } let tab = tabManager.tabs[index] + + // Pinned tabs cannot be closed + guard !tab.isPinned else { return } + let isSelected = tabManager.selectedTabId == id // Check for unsaved changes on this specific tab @@ -66,18 +70,20 @@ extension MainContentCoordinator { guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } let wasSelected = tabManager.selectedTabId == id + // Snapshot for Cmd+Shift+T reopen before eviction + tabManager.pushClosedTab(tabManager.tabs[index]) + tabManager.tabs[index].rowBuffer.evict() tabManager.tabs.remove(at: index) if wasSelected { if tabManager.tabs.isEmpty { tabManager.selectedTabId = nil - // Close the window when last tab is closed contentWindow?.close() } else { - // Select adjacent tab (prefer left, fall back to right) - let newIndex = min(index, tabManager.tabs.count - 1) - tabManager.selectedTabId = tabManager.tabs[newIndex].id + // MRU: select the most recently active tab, not just adjacent + tabManager.selectedTabId = tabManager.mruTabId(excluding: id) + ?? tabManager.tabs[min(index, tabManager.tabs.count - 1)].id } } @@ -98,8 +104,9 @@ extension MainContentCoordinator { } func closeOtherTabs(excluding id: UUID) { - let tabsToClose = tabManager.tabs.filter { $0.id != id } - let selectedIsBeingClosed = tabManager.selectedTabId != id + // Skip pinned tabs — they survive "Close Others" + let tabsToClose = tabManager.tabs.filter { $0.id != id && !$0.isPinned } + let selectedIsBeingClosed = tabsToClose.contains { $0.id == tabManager.selectedTabId } let hasUnsavedWork = tabsToClose.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } || (selectedIsBeingClosed && changeManager.hasChanges) @@ -126,16 +133,20 @@ extension MainContentCoordinator { } private func forceCloseOtherTabs(excluding id: UUID) { - for index in tabManager.tabs.indices where tabManager.tabs[index].id != id { + for index in tabManager.tabs.indices where tabManager.tabs[index].id != id && !tabManager.tabs[index].isPinned { tabManager.tabs[index].rowBuffer.evict() } - tabManager.tabs.removeAll { $0.id != id } + tabManager.tabs.removeAll { $0.id != id && !$0.isPinned } tabManager.selectedTabId = id persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) } func closeAllTabs() { - let hasUnsavedWork = tabManager.tabs.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } + // Skip pinned tabs — they survive "Close All" + let closableTabs = tabManager.tabs.filter { !$0.isPinned } + guard !closableTabs.isEmpty else { return } + + let hasUnsavedWork = closableTabs.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } || changeManager.hasChanges if hasUnsavedWork { @@ -159,13 +170,48 @@ extension MainContentCoordinator { } private func forceCloseAllTabs() { - for tab in tabManager.tabs { + let closable = tabManager.tabs.filter { !$0.isPinned } + for tab in closable { tab.rowBuffer.evict() } - tabManager.tabs.removeAll() - tabManager.selectedTabId = nil - persistence.clearSavedState() - contentWindow?.close() + tabManager.tabs.removeAll { !$0.isPinned } + + if tabManager.tabs.isEmpty { + tabManager.selectedTabId = nil + persistence.clearSavedState() + contentWindow?.close() + } else { + // Pinned tabs remain — select the first one + tabManager.selectedTabId = tabManager.tabs.first?.id + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + } + + // MARK: - Reopen Closed Tab (Cmd+Shift+T) + + func reopenClosedTab() { + guard var tab = tabManager.popClosedTab() else { return } + tab.rowBuffer = RowBuffer() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + if tab.tabType == .table, !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + runQuery() + } + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + // MARK: - Pin Tab + + func togglePinTab(_ id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + tabManager.tabs[index].isPinned.toggle() + + // Stable sort: pinned tabs first, preserving relative order within each group + let pinned = tabManager.tabs.filter(\.isPinned) + let unpinned = tabManager.tabs.filter { !$0.isPinned } + tabManager.tabs = pinned + unpinned + + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) } // MARK: - Tab Reorder diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 952483f54..0b0767320 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -18,6 +18,11 @@ extension MainContentCoordinator { isHandlingTabSwitch = true defer { isHandlingTabSwitch = false } + // Track MRU order for smart tab selection after close + if let newId = newTabId { + tabManager.trackActivation(newId) + } + // Persist the outgoing tab's unsaved changes and filter state so they survive the switch if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index a649abbaf..292d81c29 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -329,6 +329,14 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) + func reopenClosedTab() { + coordinator?.reopenClosedTab() + } + + var canReopenClosedTab: Bool { + coordinator?.tabManager.hasClosedTabs ?? false + } + func newTab(initialQuery: String? = nil) { if let initialQuery { coordinator?.tabManager.addTab(initialQuery: initialQuery, databaseName: connection.database) diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift index b6838446f..77439cb73 100644 --- a/TablePro/Views/TabBar/EditorTabBar.swift +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -20,38 +20,28 @@ struct EditorTabBar: View { var onRename: (UUID, String) -> Void var onAddTab: () -> Void var onDuplicate: (UUID) -> Void + var onTogglePin: (UUID) -> Void @State private var draggedTabId: UUID? + private var pinnedTabs: [QueryTab] { tabs.filter(\.isPinned) } + private var unpinnedTabs: [QueryTab] { tabs.filter { !$0.isPinned } } + var body: some View { HStack(spacing: 0) { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 1) { - ForEach(tabs) { tab in - EditorTabBarItem( - tab: tab, - isSelected: tab.id == selectedTabId, - databaseType: databaseType, - onSelect: { selectedTabId = tab.id }, - onClose: { onClose(tab.id) }, - onCloseOthers: { onCloseOthers(tab.id) }, - onCloseTabsToRight: { closeTabsToRight(of: tab.id) }, - onCloseAll: onCloseAll, - onDuplicate: { onDuplicate(tab.id) }, - onRename: { name in onRename(tab.id, name) } - ) - .id(tab.id) - .onDrag { - draggedTabId = tab.id - return NSItemProvider(object: tab.id.uuidString as NSString) - } - .onDrop(of: [.text], delegate: TabDropDelegate( - targetId: tab.id, - tabs: tabs, - draggedTabId: $draggedTabId, - onReorder: onReorder - )) + ForEach(pinnedTabs) { tab in + tabItem(for: tab) + } + if !pinnedTabs.isEmpty && !unpinnedTabs.isEmpty { + Divider() + .frame(height: 16) + .padding(.horizontal, 2) + } + ForEach(unpinnedTabs) { tab in + tabItem(for: tab) } } .padding(.horizontal, 4) @@ -83,9 +73,37 @@ struct EditorTabBar: View { .background(Color(nsColor: .controlBackgroundColor)) } + @ViewBuilder + private func tabItem(for tab: QueryTab) -> some View { + EditorTabBarItem( + tab: tab, + isSelected: tab.id == selectedTabId, + databaseType: databaseType, + onSelect: { selectedTabId = tab.id }, + onClose: { onClose(tab.id) }, + onCloseOthers: { onCloseOthers(tab.id) }, + onCloseTabsToRight: { closeTabsToRight(of: tab.id) }, + onCloseAll: onCloseAll, + onDuplicate: { onDuplicate(tab.id) }, + onRename: { name in onRename(tab.id, name) }, + onTogglePin: { onTogglePin(tab.id) } + ) + .id(tab.id) + .onDrag { + draggedTabId = tab.id + return NSItemProvider(object: tab.id.uuidString as NSString) + } + .onDrop(of: [.text], delegate: TabDropDelegate( + targetId: tab.id, + tabs: tabs, + draggedTabId: $draggedTabId, + onReorder: onReorder + )) + } + private func closeTabsToRight(of id: UUID) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } - let idsToClose = tabs[(index + 1)...].map(\.id) + let idsToClose = tabs[(index + 1)...].filter { !$0.isPinned }.map(\.id) for tabId in idsToClose { onClose(tabId) } diff --git a/TablePro/Views/TabBar/EditorTabBarItem.swift b/TablePro/Views/TabBar/EditorTabBarItem.swift index f1408997a..697437c8c 100644 --- a/TablePro/Views/TabBar/EditorTabBarItem.swift +++ b/TablePro/Views/TabBar/EditorTabBarItem.swift @@ -18,6 +18,7 @@ struct EditorTabBarItem: View { var onCloseAll: () -> Void var onDuplicate: () -> Void var onRename: (String) -> Void + var onTogglePin: () -> Void @State private var isEditing = false @State private var editingTitle = "" @@ -75,7 +76,14 @@ struct EditorTabBarItem: View { .frame(width: 6, height: 6) } - if isHovering || isSelected { + // Pinned tabs: show pin icon, no close button + // Unpinned tabs: show close button on hover/selected + if tab.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + .foregroundStyle(.tertiary) + .frame(width: 14, height: 14) + } else if isHovering || isSelected { Button { onClose() } label: { @@ -110,7 +118,13 @@ struct EditorTabBarItem: View { }) ) .contextMenu { - Button(String(localized: "Close")) { onClose() } + Button(tab.isPinned ? String(localized: "Unpin Tab") : String(localized: "Pin Tab")) { + onTogglePin() + } + Divider() + if !tab.isPinned { + Button(String(localized: "Close")) { onClose() } + } Button(String(localized: "Close Others")) { onCloseOthers() } Button(String(localized: "Close Tabs to the Right")) { onCloseTabsToRight() } Divider() From e03d2daec33149b93f6298cee3cefa506ab29233 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: Thu, 16 Apr 2026 09:32:29 +0700 Subject: [PATCH 09/26] fix: scroll tab bar to active tab on initial load --- TablePro/Views/TabBar/EditorTabBar.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift index 77439cb73..70bd34ba2 100644 --- a/TablePro/Views/TabBar/EditorTabBar.swift +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -46,7 +46,7 @@ struct EditorTabBar: View { } .padding(.horizontal, 4) } - .onChange(of: selectedTabId) { _, newId in + .onChange(of: selectedTabId, initial: true) { _, newId in if let id = newId { withAnimation(.easeInOut(duration: 0.15)) { proxy.scrollTo(id, anchor: .center) From 042da88d43113e4cc3543be24a6f167ceae54ce7 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: Thu, 16 Apr 2026 09:44:18 +0700 Subject: [PATCH 10/26] fix: remove unnecessary Task.yield delay from tab switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab switch handler used await Task.yield() to debounce rapid clicks, but this deferred execution until after SwiftUI's body re-evaluation (~100-200ms). The actual handleTabChange work is only 2ms. Switched to synchronous onChange handler — tab switches are now instant. --- .../Extensions/MainContentView+EventHandlers.swift | 5 ----- TablePro/Views/Main/MainContentView.swift | 11 +++-------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 45b6a5131..bf1f014a5 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -20,13 +20,8 @@ extension MainContentView { ) updateWindowTitleAndFileState() - - // 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() - // Persist tab selection explicitly (skip during teardown) guard !coordinator.isTearingDown else { return } coordinator.persistence.saveNow( tabs: tabManager.tabs, diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 386c3b571..574d0995b 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -54,7 +54,7 @@ struct MainContentView: View { @State var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)? @State var inspectorUpdateTask: Task? @State var lazyLoadTask: Task? - @State var pendingTabSwitch: Task? + // pendingTabSwitch removed — tab switch is synchronous (2ms), no debounce needed @State var evictionTask: Task? /// Stable identifier for this window in WindowLifecycleMonitor @State var windowId = UUID() @@ -285,13 +285,8 @@ struct MainContentView: View { .modifier(ToolbarTintModifier(connectionColor: connection.color)) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { _, newTabId in - pendingTabSwitch?.cancel() - pendingTabSwitch = Task { @MainActor in - await Task.yield() - guard !Task.isCancelled else { return } - handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) - previousSelectedTabId = newTabId - } + handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) + previousSelectedTabId = newTabId } .onChange(of: tabManager.tabs) { _, newTabs in handleTabsChange(newTabs) From f7c6de3836928b547ac7b5b118f9262e0c093e6d 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: Thu, 16 Apr 2026 09:51:39 +0700 Subject: [PATCH 11/26] fix: remove tab-switch row eviction that caused re-fetch delays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row data was evicted on every tab switch (when >2 tabs), then re-fetched when switching back — causing visible delays while waiting for the query. Other DB clients (Beekeeper, DataGrip, TablePlus) keep tab data in memory and only evict under memory pressure. Eviction now only happens: - When the window loses focus (didResignKeyNotification, 5s delay) - Under system memory pressure (MemoryPressureAdvisor) Also removed Task.yield() from tab switch handler — the actual work is 2ms, no debounce needed. --- .../Main/Extensions/MainContentCoordinator+TabSwitch.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 0b0767320..63a632c7b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -38,10 +38,9 @@ extension MainContentCoordinator { saveColumnLayoutForTable() } - if tabManager.tabs.count > 2 { - let activeIds: Set = Set([oldTabId, newTabId].compactMap { $0 }) - evictInactiveTabs(excluding: activeIds) - } + // Row data eviction is handled by didResignKeyNotification (window loses focus) + // and by MemoryPressureAdvisor (system memory pressure) — NOT on tab switch. + // Evicting on every switch causes re-fetch delays that block the UI. if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { From 208655a76b5d57f34b09fe7e3652020e45f6092f 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: Thu, 16 Apr 2026 10:07:33 +0700 Subject: [PATCH 12/26] fix: remove window-resign row eviction that caused re-fetch on tab switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row data was evicted 5s after window resigned key (didResignKeyNotification), then re-fetched when switching back to the tab — causing visible delays. Other DB clients (Beekeeper, DataGrip, TablePlus) keep all tab data in memory until explicit close. Eviction now only happens under system memory pressure via MemoryPressureAdvisor — not on window resign or tab switch. Also removed [TAB-DBG] diagnostic logging. --- .../MainContentCoordinator+TabSwitch.swift | 12 ------------ TablePro/Views/Main/MainContentView.swift | 18 +++++------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 63a632c7b..d6bb685b1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -18,12 +18,10 @@ extension MainContentCoordinator { isHandlingTabSwitch = true defer { isHandlingTabSwitch = false } - // Track MRU order for smart tab selection after close if let newId = newTabId { tabManager.trackActivation(newId) } - // Persist the outgoing tab's unsaved changes and filter state so they survive the switch if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) { @@ -38,26 +36,16 @@ extension MainContentCoordinator { saveColumnLayoutForTable() } - // Row data eviction is handled by didResignKeyNotification (window loses focus) - // and by MemoryPressureAdvisor (system memory pressure) — NOT on tab switch. - // Evicting on every switch causes re-fetch delays that block the UI. - if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { let newTab = tabManager.tabs[newIndex] - // Restore filter state for new tab filterStateManager.restoreFromTabState(newTab.filterState) - - // Restore column visibility for new tab columnVisibilityManager.restoreFromColumnLayout(newTab.columnLayout.hiddenColumns) - selectedRowIndices = newTab.selectedRowIndices toolbarState.isTableTab = newTab.tabType == .table toolbarState.isResultsCollapsed = newTab.isResultsCollapsed - // Configure change manager without triggering reload yet — we'll fire a single - // reloadVersion bump below after everything is set up. let pendingState = newTab.pendingChanges if pendingState.hasChanges { changeManager.restoreState(from: pendingState, tableName: newTab.tableName ?? "", databaseType: connection.type) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 574d0995b..610ba54bd 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -55,7 +55,7 @@ struct MainContentView: View { @State var inspectorUpdateTask: Task? @State var lazyLoadTask: Task? // pendingTabSwitch removed — tab switch is synchronous (2ms), no debounce needed - @State var evictionTask: Task? + // evictionTask removed — eviction only on memory pressure, not window resign /// Stable identifier for this window in WindowLifecycleMonitor @State var windowId = UUID() @State var hasInitialized = false @@ -312,8 +312,6 @@ struct MainContentView: View { notificationWindow === viewWindow else { return } isKeyWindow = true - evictionTask?.cancel() - evictionTask = nil Task { @MainActor in syncSidebarToCurrentTab() } @@ -353,16 +351,10 @@ struct MainContentView: View { isKeyWindow = false lastResignKeyDate = Date() - // Schedule row data eviction when the connection window becomes inactive. - // 5s delay avoids thrashing when quickly switching between tabs. - // Per-tab pendingChanges checks inside evictInactiveRowData() protect - // tabs with unsaved changes from eviction. - evictionTask?.cancel() - evictionTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled else { return } - coordinator.evictInactiveRowData() - } + // Row data eviction only happens under system memory pressure + // (via MemoryPressureAdvisor), not on window resign. Other DB clients + // (Beekeeper, DataGrip, TablePlus) keep data in memory until close. + // Evicting on resign caused re-fetch delays when switching back. } .onChange(of: tables) { _, newTables in let syncAction = SidebarSyncAction.resolveOnTablesLoad( From 405c37a9274326d5db69c0c000e03aa50b35a50d 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: Thu, 16 Apr 2026 10:17:35 +0700 Subject: [PATCH 13/26] fix: skip redundant display format detection on cached tab switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cacheRowProvider() always called makeRowProvider() → applyDisplayFormats() even when the cached entry was still valid. This caused synchronous UserDefaults I/O (5-50ms) and format detection (1-5ms) on every tab switch, multiplied by 3 redundant onChange handlers. Now checks cache validity first — if resultVersion, metadataVersion, and sortState match, skips the expensive makeRowProvider entirely. --- TablePro/Views/Main/Child/MainEditorContentView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 7d8f548ef..c7c276270 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -575,6 +575,16 @@ struct MainEditorContentView: View { } private func cacheRowProvider(for tab: QueryTab) { + // Skip if the cached entry is still valid — avoids redundant + // applyDisplayFormats() + UserDefaults I/O on every tab switch + if let entry = tabProviderCache[tab.id], + entry.resultVersion == tab.resultVersion, + entry.metadataVersion == tab.metadataVersion, + entry.sortState == tab.sortState, + !tab.rowBuffer.isEvicted + { + return + } let provider = makeRowProvider(for: tab) tabProviderCache[tab.id] = RowProviderCacheEntry( provider: provider, From 725c22c737833f5ddf7f53397f2db8fbe37af8f8 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: Thu, 16 Apr 2026 10:35:29 +0700 Subject: [PATCH 14/26] perf: keep tab views alive across switches (NSTabViewController pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI's conditional rendering (if let tab { tabContent(for: tab) }) destroyed and recreated the entire DataGridView (NSTableView) and SourceEditor (TreeSitterClient) on every tab switch — ~200ms cost. Replaced with ZStack + ForEach + opacity pattern: all tab views stay alive in the hierarchy, only the active tab is visible. Matches Apple's NSTabViewController behavior where child view controllers are kept alive and only swapped in/out of the visible hierarchy. Tab switch is now instant — no view destruction/recreation, no NSTableView column rebuild, no TreeSitter language parser reinitialization. --- .../Main/Child/MainEditorContentView.swift | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index c7c276270..917a33dc6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -116,10 +116,21 @@ struct MainEditorContentView: View { Divider() } - if let tab = tabManager.selectedTab { - tabContent(for: tab) - } else { + if tabManager.tabs.isEmpty { emptyStateView + } else { + // Keep all tab views alive — only the active tab is visible. + // Matches Apple's NSTabViewController pattern: views are not + // destroyed/recreated on switch, avoiding ~200ms NSTableView + // + TreeSitter reconstruction cost. + ZStack { + ForEach(tabManager.tabs) { tab in + let isActive = tab.id == tabManager.selectedTabId + tabContent(for: tab) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + } + } } // Global History Panel @@ -177,7 +188,7 @@ struct MainEditorContentView: View { guard let tab = tabManager.selectedTab, newVersion != nil else { return } cacheRowProvider(for: tab) } - .onChange(of: tabManager.selectedTab?.metadataVersion) { _, _ in + .onChange(of: tabManager.selectedTab?.metadataVersion) { _, newVersion in guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } @@ -575,8 +586,7 @@ struct MainEditorContentView: View { } private func cacheRowProvider(for tab: QueryTab) { - // Skip if the cached entry is still valid — avoids redundant - // applyDisplayFormats() + UserDefaults I/O on every tab switch + // Skip if the cached entry is still valid if let entry = tabProviderCache[tab.id], entry.resultVersion == tab.resultVersion, entry.metadataVersion == tab.metadataVersion, From cc8189271192bc56096891747013016cb3121595 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: Thu, 16 Apr 2026 11:05:26 +0700 Subject: [PATCH 15/26] perf: eliminate redundant reloadVersion bump and cascading re-evals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for tab switch performance with ZStack keep-alive: 1. Removed unconditional changeManager.reloadVersion += 1 on tab switch. With ZStack, each tab's DataGridView already has its data — the forced reload caused a redundant 200ms+ NSTableView.reloadData(). 2. Added isHandlingTabSwitch guard to handleTabsChange. Saving outgoing tab state mutates tabManager.tabs, which triggered handleTabsChange → persistence.saveNow → more cascading body re-evaluations. Tab switch reduced from ~680ms (9 body re-evals) to ~90ms (5 re-evals). --- .../Extensions/MainContentCoordinator+TabSwitch.swift | 5 +++-- .../Main/Extensions/MainContentView+EventHandlers.swift | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index d6bb685b1..418dfb17a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -110,9 +110,10 @@ extension MainContentCoordinator { changeManager.reloadVersion += 1 needsLazyLoad = true } - } else { - changeManager.reloadVersion += 1 } + // No reloadVersion bump when data is already loaded. + // With ZStack keep-alive, each tab's DataGridView retains its data — + // a forced reload causes a redundant 200ms+ NSTableView.reloadData(). } else { toolbarState.isTableTab = false toolbarState.isResultsCollapsed = false diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index bf1f014a5..3458d9c88 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -20,6 +20,7 @@ extension MainContentView { ) updateWindowTitleAndFileState() + syncSidebarToCurrentTab() guard !coordinator.isTearingDown else { return } @@ -30,10 +31,13 @@ extension MainContentView { } func handleTabsChange(_ newTabs: [QueryTab]) { + // Skip during tab switch — handleTabChange saves outgoing tab state which + // mutates tabs[], triggering this handler redundantly. The tab selection + // handler already persists at the end. + guard !coordinator.isHandlingTabSwitch else { return } + 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 } From 1793bd3c7998bbc489db8048c57b173fd6bc3e58 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: Thu, 16 Apr 2026 11:20:46 +0700 Subject: [PATCH 16/26] =?UTF-8?q?perf:=20two-phase=20tab=20switch=20?= =?UTF-8?q?=E2=80=94=20instant=20visual,=20deferred=20state=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleTabChange was mutating 5 @Observable objects synchronously (filterStateManager, columnVisibilityManager, toolbarState, changeManager, tabManager.tabs), each triggering a separate SwiftUI body re-evaluation. With ZStack keep-alive, all tab views (active + hidden) re-evaluated on each mutation — 5 cascading passes blocking the visual switch. Split into two phases: - Phase 1 (sync, ~1ms): selectedRowIndices + toolbarState.isTableTab only. SwiftUI flips opacity immediately — user sees instant switch. - Phase 2 (deferred, next frame): save outgoing state + restore shared managers. Invisible to user — 16ms later, managers catch up. Also batch outgoing tab state save into single array write (1 didSet) instead of 2 separate element mutations (2 didSet calls). --- .../MainContentCoordinator+TabSwitch.swift | 128 ++++++++++-------- 1 file changed, 68 insertions(+), 60 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 418dfb17a..fe3922f56 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -9,6 +9,9 @@ import Foundation extension MainContentCoordinator { + /// Two-phase tab switch: synchronous visual update + deferred state reconfiguration. + /// Phase 1 (sync): Update only what's needed for immediate opacity flip (~1ms). + /// Phase 2 (deferred): Save/restore shared managers in the next frame (~5ms, invisible). func handleTabChange( from oldTabId: UUID?, to newTabId: UUID?, @@ -16,86 +19,98 @@ extension MainContentCoordinator { tabs: [QueryTab] ) { isHandlingTabSwitch = true - defer { isHandlingTabSwitch = false } + // Phase 1: Synchronous — minimal mutations for immediate visual switch if let newId = newTabId { tabManager.trackActivation(newId) } - if let oldId = oldTabId, - let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) - { - if changeManager.hasChanges { - tabManager.tabs[oldIndex].pendingChanges = changeManager.saveState() - } - tabManager.tabs[oldIndex].filterState = filterStateManager.saveToTabState() - if let tableName = tabManager.tabs[oldIndex].tableName { - filterStateManager.saveLastFilters(for: tableName) - } - saveColumnVisibilityToTab() - saveColumnLayoutForTable() - } - if let newId = newTabId, let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { - let newTab = tabManager.tabs[newIndex] + selectedRowIndices = tabManager.tabs[newIndex].selectedRowIndices + toolbarState.isTableTab = tabManager.tabs[newIndex].tabType == .table + } - filterStateManager.restoreFromTabState(newTab.filterState) - columnVisibilityManager.restoreFromColumnLayout(newTab.columnLayout.hiddenColumns) - selectedRowIndices = newTab.selectedRowIndices - toolbarState.isTableTab = newTab.tabType == .table - toolbarState.isResultsCollapsed = newTab.isResultsCollapsed + // Phase 2: Deferred — save outgoing + restore incoming shared manager state. + // The ZStack opacity flip happens immediately in the current frame; + // shared managers (@Observable) update in the next frame to avoid + // cascading body re-evaluations that block the visual switch. + let capturedOldId = oldTabId + let capturedNewId = newTabId + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isHandlingTabSwitch = false } + + // Save outgoing tab state (batch into single array write) + if let oldId = capturedOldId, + let oldIndex = self.tabManager.tabs.firstIndex(where: { $0.id == oldId }) { + var tab = self.tabManager.tabs[oldIndex] + if self.changeManager.hasChanges { + tab.pendingChanges = self.changeManager.saveState() + } + tab.filterState = self.filterStateManager.saveToTabState() + self.tabManager.tabs[oldIndex] = tab + if let tableName = tab.tableName { + self.filterStateManager.saveLastFilters(for: tableName) + } + self.saveColumnVisibilityToTab() + self.saveColumnLayoutForTable() + } + + // Restore incoming tab state + guard let newId = capturedNewId, + let newIndex = self.tabManager.tabs.firstIndex(where: { $0.id == newId }) + else { + self.toolbarState.isTableTab = false + self.toolbarState.isResultsCollapsed = false + self.filterStateManager.clearAll() + return + } + let newTab = self.tabManager.tabs[newIndex] + + self.filterStateManager.restoreFromTabState(newTab.filterState) + self.columnVisibilityManager.restoreFromColumnLayout(newTab.columnLayout.hiddenColumns) + self.toolbarState.isResultsCollapsed = newTab.isResultsCollapsed let pendingState = newTab.pendingChanges if pendingState.hasChanges { - changeManager.restoreState(from: pendingState, tableName: newTab.tableName ?? "", databaseType: connection.type) + self.changeManager.restoreState( + from: pendingState, + tableName: newTab.tableName ?? "", + databaseType: self.connection.type + ) } else { - changeManager.configureForTable( + self.changeManager.configureForTable( tableName: newTab.tableName ?? "", columns: newTab.resultColumns, primaryKeyColumns: newTab.primaryKeyColumns.isEmpty ? newTab.resultColumns.prefix(1).map { $0 } : newTab.primaryKeyColumns, - databaseType: connection.type, + databaseType: self.connection.type, triggerReload: false ) } - // 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). - + // Database switch check if !newTab.databaseName.isEmpty { - let currentDatabase: String - if let session = DatabaseManager.shared.session(for: connectionId) { - currentDatabase = session.activeDatabase - } else { - currentDatabase = connection.database - } - + let currentDatabase = DatabaseManager.shared.session(for: self.connectionId)?.activeDatabase + ?? self.connection.database if newTab.databaseName != currentDatabase { - changeManager.reloadVersion += 1 - Task { @MainActor in - await switchDatabase(to: newTab.databaseName) - } - return // switchDatabase will re-execute the query + self.changeManager.reloadVersion += 1 + await self.switchDatabase(to: newTab.databaseName) + return } } - // If the tab shows isExecuting but has no results, the previous query was - // likely cancelled when the user rapidly switched away. Force-clear the stale - // flag so the lazy-load check below can re-execute the query. + // Clear stale isExecuting flag if newTab.isExecuting && newTab.resultRows.isEmpty && newTab.lastExecutedAt == nil { - let tabId = newId - Task { @MainActor [weak self] in - guard let self, - let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }), - self.tabManager.tabs[idx].isExecuting else { return } + if let idx = self.tabManager.tabs.firstIndex(where: { $0.id == newId }), + self.tabManager.tabs[idx].isExecuting { self.tabManager.tabs[idx].isExecuting = false } } + // Lazy query for evicted/empty tabs let isEvicted = newTab.rowBuffer.isEvicted let needsLazyQuery = newTab.tabType == .table && (newTab.resultRows.isEmpty || isEvicted) @@ -104,20 +119,13 @@ extension MainContentCoordinator { && !newTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if needsLazyQuery { - if let session = DatabaseManager.shared.session(for: connectionId), session.isConnected { - executeTableTabQueryDirectly() + if let session = DatabaseManager.shared.session(for: self.connectionId), session.isConnected { + self.executeTableTabQueryDirectly() } else { - changeManager.reloadVersion += 1 - needsLazyLoad = true + self.changeManager.reloadVersion += 1 + self.needsLazyLoad = true } } - // No reloadVersion bump when data is already loaded. - // With ZStack keep-alive, each tab's DataGridView retains its data — - // a forced reload causes a redundant 200ms+ NSTableView.reloadData(). - } else { - toolbarState.isTableTab = false - toolbarState.isResultsCollapsed = false - filterStateManager.clearAll() } } From b0b18620196619bf9097cd405c81cbeb99563d9d 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: Thu, 16 Apr 2026 11:25:16 +0700 Subject: [PATCH 17/26] fix: cancel previous deferred tab switch on rapid Cmd+1/Cmd+2 spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred Phase 2 tasks were queuing up — each keypress created a new Task that executed in order even after the user stopped pressing. Now cancels the previous tabSwitchTask before creating a new one, so only the final tab switch commits its state restoration. --- .../Extensions/MainContentCoordinator+TabSwitch.swift | 9 +++++++-- TablePro/Views/Main/MainContentCoordinator.swift | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index fe3922f56..130f56f0b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -35,10 +35,13 @@ extension MainContentCoordinator { // The ZStack opacity flip happens immediately in the current frame; // shared managers (@Observable) update in the next frame to avoid // cascading body re-evaluations that block the visual switch. + // Cancel previous deferred task so rapid Cmd+1/Cmd+2 spam only + // commits the final tab — intermediate switches are discarded. + tabSwitchTask?.cancel() let capturedOldId = oldTabId let capturedNewId = newTabId - Task { @MainActor [weak self] in - guard let self else { return } + tabSwitchTask = Task { @MainActor [weak self] in + guard let self, !Task.isCancelled else { return } defer { self.isHandlingTabSwitch = false } // Save outgoing tab state (batch into single array write) @@ -57,6 +60,8 @@ extension MainContentCoordinator { self.saveColumnLayoutForTable() } + guard !Task.isCancelled else { return } + // Restore incoming tab state guard let newId = capturedNewId, let newIndex = self.tabManager.tabs.firstIndex(where: { $0.id == newId }) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 60f6798f4..2db53a665 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -133,6 +133,7 @@ final class MainContentCoordinator { @ObservationIgnored internal var currentQueryTask: Task? @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? + @ObservationIgnored internal var tabSwitchTask: Task? @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @ObservationIgnored private var terminationObserver: NSObjectProtocol? @ObservationIgnored private var urlFilterObservers: [NSObjectProtocol] = [] @@ -428,6 +429,8 @@ final class MainContentCoordinator { changeManagerUpdateTask = nil redisDatabaseSwitchTask?.cancel() redisDatabaseSwitchTask = nil + tabSwitchTask?.cancel() + tabSwitchTask = nil for task in activeSortTasks.values { task.cancel() } activeSortTasks.removeAll() From 59e311404a1360f19eb3dd434734d655b1debee3 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: Thu, 16 Apr 2026 11:45:39 +0700 Subject: [PATCH 18/26] perf: remove incoming state restoration from tab switch entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred Phase 2 was still restoring 5 @Observable managers (filterStateManager, columnVisibilityManager, changeManager, etc.) causing 16 body re-evaluations over ~960ms after the user stops pressing Cmd+1/Cmd+2. With ZStack keep-alive, each tab's view maintains its own correct state — shared manager reconfiguration is unnecessary. Phase 2 now only saves outgoing tab state (for persistence) and checks for lazy query needs. No @Observable mutations on the incoming tab. --- .../MainContentCoordinator+TabSwitch.swift | 56 +++++-------------- .../MainContentView+EventHandlers.swift | 1 - 2 files changed, 15 insertions(+), 42 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 130f56f0b..8d6db6187 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -9,9 +9,11 @@ import Foundation extension MainContentCoordinator { - /// Two-phase tab switch: synchronous visual update + deferred state reconfiguration. - /// Phase 1 (sync): Update only what's needed for immediate opacity flip (~1ms). - /// Phase 2 (deferred): Save/restore shared managers in the next frame (~5ms, invisible). + /// Two-phase tab switch optimized for ZStack keep-alive. + /// + /// Phase 1 (synchronous, ~1ms): Update selection + toolbar for immediate opacity flip. + /// Phase 2 (deferred): Save outgoing tab state only. NO incoming state restoration — + /// with ZStack, each tab's view is kept alive with its correct state. func handleTabChange( from oldTabId: UUID?, to newTabId: UUID?, @@ -29,14 +31,16 @@ extension MainContentCoordinator { let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { selectedRowIndices = tabManager.tabs[newIndex].selectedRowIndices toolbarState.isTableTab = tabManager.tabs[newIndex].tabType == .table + } else { + toolbarState.isTableTab = false + toolbarState.isResultsCollapsed = false } - // Phase 2: Deferred — save outgoing + restore incoming shared manager state. - // The ZStack opacity flip happens immediately in the current frame; - // shared managers (@Observable) update in the next frame to avoid - // cascading body re-evaluations that block the visual switch. - // Cancel previous deferred task so rapid Cmd+1/Cmd+2 spam only - // commits the final tab — intermediate switches are discarded. + // Phase 2: Deferred — save outgoing tab state for persistence. + // No incoming state restoration needed: ZStack keeps each tab's view + // alive with its correct state. Restoring shared @Observable managers + // (filterStateManager, changeManager, etc.) causes 15+ body re-evaluations + // that block the main thread for ~1 second. tabSwitchTask?.cancel() let capturedOldId = oldTabId let capturedNewId = newTabId @@ -62,40 +66,12 @@ extension MainContentCoordinator { guard !Task.isCancelled else { return } - // Restore incoming tab state + // Lazy query check for evicted/empty tabs guard let newId = capturedNewId, let newIndex = self.tabManager.tabs.firstIndex(where: { $0.id == newId }) - else { - self.toolbarState.isTableTab = false - self.toolbarState.isResultsCollapsed = false - self.filterStateManager.clearAll() - return - } + else { return } let newTab = self.tabManager.tabs[newIndex] - self.filterStateManager.restoreFromTabState(newTab.filterState) - self.columnVisibilityManager.restoreFromColumnLayout(newTab.columnLayout.hiddenColumns) - self.toolbarState.isResultsCollapsed = newTab.isResultsCollapsed - - let pendingState = newTab.pendingChanges - if pendingState.hasChanges { - self.changeManager.restoreState( - from: pendingState, - tableName: newTab.tableName ?? "", - databaseType: self.connection.type - ) - } else { - self.changeManager.configureForTable( - tableName: newTab.tableName ?? "", - columns: newTab.resultColumns, - primaryKeyColumns: newTab.primaryKeyColumns.isEmpty - ? newTab.resultColumns.prefix(1).map { $0 } - : newTab.primaryKeyColumns, - databaseType: self.connection.type, - triggerReload: false - ) - } - // Database switch check if !newTab.databaseName.isEmpty { let currentDatabase = DatabaseManager.shared.session(for: self.connectionId)?.activeDatabase @@ -115,7 +91,6 @@ extension MainContentCoordinator { } } - // Lazy query for evicted/empty tabs let isEvicted = newTab.rowBuffer.isEvicted let needsLazyQuery = newTab.tabType == .table && (newTab.resultRows.isEmpty || isEvicted) @@ -143,7 +118,6 @@ extension MainContentCoordinator { && !$0.pendingChanges.hasChanges } - // Sort by oldest first, breaking ties by largest estimated footprint first let sorted = candidates.sorted { let t0 = $0.lastExecutedAt ?? .distantFuture let t1 = $1.lastExecutedAt ?? .distantFuture diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 3458d9c88..00c9d0b41 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -20,7 +20,6 @@ extension MainContentView { ) updateWindowTitleAndFileState() - syncSidebarToCurrentTab() guard !coordinator.isTearingDown else { return } From a52f95c5bee112e7fc7b9c721ca271a26d8efd30 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: Thu, 16 Apr 2026 12:14:34 +0700 Subject: [PATCH 19/26] debug: add [DBG] logging to trace tab switch body re-evaluation cascade --- .../Views/Main/Child/MainEditorContentView.swift | 8 ++++++++ .../MainContentCoordinator+TabSwitch.swift | 12 +++++++++--- .../Extensions/MainContentView+EventHandlers.swift | 10 ++++++++++ TablePro/Views/Main/MainContentView.swift | 10 ++++++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 917a33dc6..3be6145ee 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -8,6 +8,7 @@ import AppKit import CodeEditSourceEditor +import os import SwiftUI /// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation @@ -93,6 +94,7 @@ struct MainEditorContentView: View { // MARK: - Body var body: some View { + let _ = MainContentCoordinator.logger.warning("[DBG] EditorContent.body eval selected=\(tabManager.selectedTab?.title ?? "nil", privacy: .public)") let isHistoryVisible = coordinator.toolbarState.isHistoryPanelVisible VStack(spacing: 0) { @@ -149,6 +151,7 @@ struct MainEditorContentView: View { ) } .onChange(of: tabManager.tabIds) { _, newIds in + MainContentCoordinator.logger.warning("[DBG] EC.onChange(tabIds) count=\(newIds.count)") guard !sortCache.isEmpty || !tabProviderCache.isEmpty || !erDiagramViewModels.isEmpty || !serverDashboardViewModels.isEmpty else { coordinator.cleanupSortCache(openTabIds: Set(newIds)) @@ -162,6 +165,7 @@ struct MainEditorContentView: View { serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) } } .onChange(of: tabManager.selectedTabId) { _, newId in + MainContentCoordinator.logger.warning("[DBG] EC.onChange(selectedTabId) → \(String(describing: newId))") updateHasQueryText() guard let newId, let tab = tabManager.selectedTab else { return } @@ -169,6 +173,7 @@ struct MainEditorContentView: View { if cached?.resultVersion != tab.resultVersion || cached?.metadataVersion != tab.metadataVersion { + MainContentCoordinator.logger.warning("[DBG] EC.cacheRowProvider called (cache miss)") cacheRowProvider(for: tab) } } @@ -185,14 +190,17 @@ struct MainEditorContentView: View { } } .onChange(of: tabManager.selectedTab?.resultVersion) { _, newVersion in + MainContentCoordinator.logger.warning("[DBG] EC.onChange(resultVersion) → \(String(describing: newVersion))") guard let tab = tabManager.selectedTab, newVersion != nil else { return } cacheRowProvider(for: tab) } .onChange(of: tabManager.selectedTab?.metadataVersion) { _, newVersion in + MainContentCoordinator.logger.warning("[DBG] EC.onChange(metadataVersion) → \(String(describing: newVersion))") guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } .onChange(of: tabManager.selectedTab?.activeResultSetId) { _, _ in + MainContentCoordinator.logger.warning("[DBG] EC.onChange(activeResultSetId)") guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 8d6db6187..cb781be6e 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 { /// Two-phase tab switch optimized for ZStack keep-alive. @@ -20,9 +21,10 @@ extension MainContentCoordinator { selectedRowIndices: inout Set, tabs: [QueryTab] ) { + Self.logger.warning("[DBG] handleTabChange START old=\(String(describing: oldTabId)) new=\(String(describing: newTabId))") isHandlingTabSwitch = true - // Phase 1: Synchronous — minimal mutations for immediate visual switch + // Phase 1: Synchronous if let newId = newTabId { tabManager.trackActivation(newId) } @@ -35,6 +37,7 @@ extension MainContentCoordinator { toolbarState.isTableTab = false toolbarState.isResultsCollapsed = false } + Self.logger.warning("[DBG] handleTabChange Phase1 done") // Phase 2: Deferred — save outgoing tab state for persistence. // No incoming state restoration needed: ZStack keeps each tab's view @@ -45,10 +48,13 @@ extension MainContentCoordinator { let capturedOldId = oldTabId let capturedNewId = newTabId tabSwitchTask = Task { @MainActor [weak self] in - guard let self, !Task.isCancelled else { return } + guard let self, !Task.isCancelled else { + Self.logger.warning("[DBG] Phase2 CANCELLED") + return + } defer { self.isHandlingTabSwitch = false } + Self.logger.warning("[DBG] Phase2 START") - // Save outgoing tab state (batch into single array write) if let oldId = capturedOldId, let oldIndex = self.tabManager.tabs.firstIndex(where: { $0.id == oldId }) { var tab = self.tabManager.tabs[oldIndex] diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 00c9d0b41..7d6a23197 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -6,27 +6,37 @@ // Extracted to reduce main view complexity. // +import os import SwiftUI extension MainContentView { // MARK: - Event Handlers func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { + var t = ContinuousClock.now coordinator.handleTabChange( from: oldTabId, to: newTabId, selectedRowIndices: &selectedRowIndices, tabs: tabManager.tabs ) + MainContentCoordinator.logger.warning("[DBG] EH.handleTabChange=\(ContinuousClock.now - t)") + t = ContinuousClock.now updateWindowTitleAndFileState() + MainContentCoordinator.logger.warning("[DBG] EH.updateTitle=\(ContinuousClock.now - t)") + + t = ContinuousClock.now syncSidebarToCurrentTab() + MainContentCoordinator.logger.warning("[DBG] EH.syncSidebar=\(ContinuousClock.now - t)") guard !coordinator.isTearingDown else { return } + t = ContinuousClock.now coordinator.persistence.saveNow( tabs: tabManager.tabs, selectedTabId: newTabId ) + MainContentCoordinator.logger.warning("[DBG] EH.persist=\(ContinuousClock.now - t)") } func handleTabsChange(_ newTabs: [QueryTab]) { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 610ba54bd..54309146d 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -107,6 +107,7 @@ struct MainContentView: View { // MARK: - Body var body: some View { + let _ = MainContentCoordinator.logger.warning("[DBG] MCV.body eval") bodyContent .sheet(item: Bindable(coordinator).activeSheet) { sheet in sheetContent(for: sheet) @@ -225,12 +226,12 @@ struct MainContentView: View { } } .task(id: currentTab?.tableName) { - // Only load metadata after the tab has executed at least once — - // avoids a redundant DB query racing with the initial data query + MainContentCoordinator.logger.warning("[DBG] .task(tableName) fired: \(self.currentTab?.tableName ?? "nil", privacy: .public)") guard currentTab?.lastExecutedAt != nil else { return } await loadTableMetadataIfNeeded() } .onChange(of: inspectorTrigger) { + MainContentCoordinator.logger.warning("[DBG] onChange(inspectorTrigger)") scheduleInspectorUpdate() } .onAppear { @@ -256,6 +257,7 @@ struct MainContentView: View { // during view hierarchy reconstruction and is not reliable for resource cleanup. } .onChange(of: pendingChangeTrigger) { + MainContentCoordinator.logger.warning("[DBG] onChange(pendingChangeTrigger)") updateToolbarPendingState() } .userActivity("com.TablePro.viewConnection") { activity in @@ -285,13 +287,16 @@ struct MainContentView: View { .modifier(ToolbarTintModifier(connectionColor: connection.color)) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { _, newTabId in + MainContentCoordinator.logger.warning("[DBG] onChange(selectedTabId) → \(String(describing: newTabId))") handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) previousSelectedTabId = newTabId } .onChange(of: tabManager.tabs) { _, newTabs in + MainContentCoordinator.logger.warning("[DBG] onChange(tabs) count=\(newTabs.count)") handleTabsChange(newTabs) } .onChange(of: currentTab?.resultColumns) { _, newColumns in + MainContentCoordinator.logger.warning("[DBG] onChange(resultColumns) count=\(newColumns?.count ?? -1)") handleColumnsChange(newColumns: newColumns) } .task { handleConnectionStatusChange() } @@ -303,6 +308,7 @@ struct MainContentView: View { } .onChange(of: sidebarState.selectedTables) { _, newTables in + MainContentCoordinator.logger.warning("[DBG] onChange(selectedTables) count=\(newTables.count)") handleTableSelectionChange(from: previousSelectedTables, to: newTables) previousSelectedTables = newTables } From e58a8d34f532ba53281c395b38ea3b8c6ce571aa 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: Thu, 16 Apr 2026 12:17:47 +0700 Subject: [PATCH 20/26] perf: defer title/sidebar/persist to Phase 2, skip .task during rapid switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for tab switch responsiveness: 1. Move updateWindowTitleAndFileState, syncSidebarToCurrentTab, and persistence.saveNow from synchronous handleTabSelectionChange to the deferred Phase 2 task via onTabSwitchSettled callback. These were triggering onChange(selectedTables) → handleTableSelectionChange → another full body eval chain per switch. 2. Guard .task(id: currentTab?.tableName) with isHandlingTabSwitch — during rapid Cmd+1/2/3 switching, 30+ metadata tasks queued up and all executed when the user stopped, causing ~1 second of trailing body re-evaluations. 3. handleTabSelectionChange now only calls handleTabChange (Phase 1) — all other work deferred to Phase 2 which cancels on next switch. --- .../MainContentCoordinator+TabSwitch.swift | 4 ++++ .../MainContentView+EventHandlers.swift | 21 +++---------------- .../Views/Main/MainContentCoordinator.swift | 3 +++ TablePro/Views/Main/MainContentView.swift | 12 +++++++++++ 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index cb781be6e..b6b965928 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -112,6 +112,10 @@ extension MainContentCoordinator { self.needsLazyLoad = true } } + + // Notify view layer to update title, sidebar, and persistence + // after deferred state has settled. + self.onTabSwitchSettled?() } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 7d6a23197..a7aa850c3 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -13,30 +13,15 @@ extension MainContentView { // MARK: - Event Handlers func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { - var t = ContinuousClock.now + // Phase 1 only — minimal sync mutations for instant opacity flip. + // Title, sidebar sync, and persistence are deferred to Phase 2 + // (inside handleTabChange's Task) to avoid cascading body re-evals. coordinator.handleTabChange( from: oldTabId, to: newTabId, selectedRowIndices: &selectedRowIndices, tabs: tabManager.tabs ) - MainContentCoordinator.logger.warning("[DBG] EH.handleTabChange=\(ContinuousClock.now - t)") - - t = ContinuousClock.now - updateWindowTitleAndFileState() - MainContentCoordinator.logger.warning("[DBG] EH.updateTitle=\(ContinuousClock.now - t)") - - t = ContinuousClock.now - syncSidebarToCurrentTab() - MainContentCoordinator.logger.warning("[DBG] EH.syncSidebar=\(ContinuousClock.now - t)") - - guard !coordinator.isTearingDown else { return } - t = ContinuousClock.now - coordinator.persistence.saveNow( - tabs: tabManager.tabs, - selectedTabId: newTabId - ) - MainContentCoordinator.logger.warning("[DBG] EH.persist=\(ContinuousClock.now - t)") } func handleTabsChange(_ newTabs: [QueryTab]) { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 2db53a665..0f68c5d72 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -157,6 +157,9 @@ final class MainContentCoordinator { /// Called during teardown to let the view layer release cached row providers and sort data. @ObservationIgnored var onTeardown: (() -> Void)? + /// Called from Phase 2 of tab switch after deferred state is settled. + /// View layer uses this to update title, sidebar, and persistence. + @ObservationIgnored var onTabSwitchSettled: (() -> Void)? /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 54309146d..590f5ff69 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -227,6 +227,9 @@ struct MainContentView: View { } .task(id: currentTab?.tableName) { MainContentCoordinator.logger.warning("[DBG] .task(tableName) fired: \(self.currentTab?.tableName ?? "nil", privacy: .public)") + // Skip during rapid tab switching — metadata will be loaded + // when the user settles on a tab (Phase 2 completion) + guard !coordinator.isHandlingTabSwitch else { return } guard currentTab?.lastExecutedAt != nil else { return } await loadTableMetadataIfNeeded() } @@ -247,6 +250,15 @@ struct MainContentView: View { rightPanelState.aiViewModel.schemaProvider = coordinator.schemaProvider coordinator.aiViewModel = rightPanelState.aiViewModel coordinator.rightPanelState = rightPanelState + coordinator.onTabSwitchSettled = { [self] in + updateWindowTitleAndFileState() + syncSidebarToCurrentTab() + guard !coordinator.isTearingDown else { return } + coordinator.persistence.saveNow( + tabs: tabManager.tabs, + selectedTabId: tabManager.selectedTabId + ) + } // Window registration is handled by WindowAccessor in .background } From 5ff6efee7f7338ad934822624e0a9d29296ccaad 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: Thu, 16 Apr 2026 12:21:10 +0700 Subject: [PATCH 21/26] perf: remove .task(id: tableName) that queued 28+ tasks during rapid switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .task(id: currentTab?.tableName) created a new SwiftUI-managed task for every tab switch. During rapid Cmd+1/2/3 spam, 28+ tasks queued up and all executed when the user stopped — each triggering loadTableMetadata. Moved metadata loading to Phase 2's onTabSwitchSettled callback, which is cancellable and only runs for the final settled tab. --- TablePro/Views/Main/MainContentView.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 590f5ff69..3d3a321b5 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -225,14 +225,9 @@ struct MainContentView: View { configureWindow(window) } } - .task(id: currentTab?.tableName) { - MainContentCoordinator.logger.warning("[DBG] .task(tableName) fired: \(self.currentTab?.tableName ?? "nil", privacy: .public)") - // Skip during rapid tab switching — metadata will be loaded - // when the user settles on a tab (Phase 2 completion) - guard !coordinator.isHandlingTabSwitch else { return } - guard currentTab?.lastExecutedAt != nil else { return } - await loadTableMetadataIfNeeded() - } + // Metadata loading moved to query completion (executeQueryInternal) + // and Phase 2 tab switch settlement. Removed .task(id: currentTab?.tableName) + // which created N queued tasks during rapid Cmd+1/2/3 switching. .onChange(of: inspectorTrigger) { MainContentCoordinator.logger.warning("[DBG] onChange(inspectorTrigger)") scheduleInspectorUpdate() @@ -258,6 +253,10 @@ struct MainContentView: View { tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId ) + // Load table metadata for the settled tab + if let tab = tabManager.selectedTab, tab.lastExecutedAt != nil { + Task { await loadTableMetadataIfNeeded() } + } } // Window registration is handled by WindowAccessor in .background From 199f5f7626b6680235b5b83803ea7b55b2aeb957 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: Thu, 16 Apr 2026 12:29:34 +0700 Subject: [PATCH 22/26] =?UTF-8?q?perf:=20zero=20synchronous=20mutations=20?= =?UTF-8?q?on=20tab=20switch=20=E2=80=94=20fully=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed ALL synchronous @Observable mutations from onChange(selectedTabId). The ZStack opacity flip is driven by selectedTabId binding alone — no handleTabChange Phase 1 needed. MRU tracking (lightweight array append) stays synchronous. Everything else (toolbarState, selectedRowIndices, outgoing save, title, sidebar, persist, metadata) is deferred to Phase 2 Task which coalesces rapid Cmd+1/2/3 spam via tabSwitchTask cancellation. During rapid keyboard repeat, the main thread only processes the onChange callback (~0ms) + SwiftUI body eval for opacity change. No @Observable mutations means no cascading body re-evaluations. --- .../MainContentCoordinator+TabSwitch.swift | 50 +++++++------------ .../MainContentView+EventHandlers.swift | 10 +--- TablePro/Views/Main/MainContentView.swift | 8 ++- 3 files changed, 26 insertions(+), 42 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index b6b965928..902d3fdc4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -10,50 +10,38 @@ import Foundation import os extension MainContentCoordinator { - /// Two-phase tab switch optimized for ZStack keep-alive. - /// - /// Phase 1 (synchronous, ~1ms): Update selection + toolbar for immediate opacity flip. - /// Phase 2 (deferred): Save outgoing tab state only. NO incoming state restoration — - /// with ZStack, each tab's view is kept alive with its correct state. - func handleTabChange( + /// Schedule a tab switch with zero synchronous @Observable mutations. + /// The ZStack opacity flip happens from selectedTabId binding alone. + /// All state work (save outgoing, MRU, title, sidebar, persist) is + /// deferred to Phase 2 Task which coalesces rapid Cmd+1/2/3 spam. + func scheduleTabSwitch( from oldTabId: UUID?, - to newTabId: UUID?, - selectedRowIndices: inout Set, - tabs: [QueryTab] + to newTabId: UUID? ) { - Self.logger.warning("[DBG] handleTabChange START old=\(String(describing: oldTabId)) new=\(String(describing: newTabId))") isHandlingTabSwitch = true - // Phase 1: Synchronous + // MRU tracking is lightweight (array append) — do synchronously if let newId = newTabId { tabManager.trackActivation(newId) } - if let newId = newTabId, - let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { - selectedRowIndices = tabManager.tabs[newIndex].selectedRowIndices - toolbarState.isTableTab = tabManager.tabs[newIndex].tabType == .table - } else { - toolbarState.isTableTab = false - toolbarState.isResultsCollapsed = false - } - Self.logger.warning("[DBG] handleTabChange Phase1 done") - - // Phase 2: Deferred — save outgoing tab state for persistence. - // No incoming state restoration needed: ZStack keeps each tab's view - // alive with its correct state. Restoring shared @Observable managers - // (filterStateManager, changeManager, etc.) causes 15+ body re-evaluations - // that block the main thread for ~1 second. + // Phase 2: Deferred — all state work coalesced via task cancellation. + // During rapid Cmd+1/2/3, only the LAST switch's Phase 2 executes. tabSwitchTask?.cancel() let capturedOldId = oldTabId let capturedNewId = newTabId tabSwitchTask = Task { @MainActor [weak self] in - guard let self, !Task.isCancelled else { - Self.logger.warning("[DBG] Phase2 CANCELLED") - return - } + guard let self, !Task.isCancelled else { return } defer { self.isHandlingTabSwitch = false } - Self.logger.warning("[DBG] Phase2 START") + + // Update toolbar and selection for the settled tab + if let newId = capturedNewId, + let newIndex = self.tabManager.tabs.firstIndex(where: { $0.id == newId }) { + self.toolbarState.isTableTab = self.tabManager.tabs[newIndex].tabType == .table + } else { + self.toolbarState.isTableTab = false + self.toolbarState.isResultsCollapsed = false + } if let oldId = capturedOldId, let oldIndex = self.tabManager.tabs.firstIndex(where: { $0.id == oldId }) { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index a7aa850c3..2541319da 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -13,15 +13,7 @@ extension MainContentView { // MARK: - Event Handlers func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { - // Phase 1 only — minimal sync mutations for instant opacity flip. - // Title, sidebar sync, and persistence are deferred to Phase 2 - // (inside handleTabChange's Task) to avoid cascading body re-evals. - coordinator.handleTabChange( - from: oldTabId, - to: newTabId, - selectedRowIndices: &selectedRowIndices, - tabs: tabManager.tabs - ) + coordinator.scheduleTabSwitch(from: oldTabId, to: newTabId) } func handleTabsChange(_ newTabs: [QueryTab]) { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 3d3a321b5..a476b6faf 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -298,8 +298,12 @@ struct MainContentView: View { .modifier(ToolbarTintModifier(connectionColor: connection.color)) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { _, newTabId in - MainContentCoordinator.logger.warning("[DBG] onChange(selectedTabId) → \(String(describing: newTabId))") - handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) + // ZStack opacity flip happens automatically from selectedTabId binding. + // ALL work is deferred to Phase 2 (handleTabChange's Task) which + // coalesces rapid Cmd+1/2/3 switches via tabSwitchTask cancellation. + // No synchronous mutations here — avoids triggering body re-evals + // that block the main thread during keyboard repeat spam. + coordinator.scheduleTabSwitch(from: previousSelectedTabId, to: newTabId) previousSelectedTabId = newTabId } .onChange(of: tabManager.tabs) { _, newTabs in From 073151afb98f919744f801558a2d2d041cafbd3f 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: Thu, 16 Apr 2026 12:34:04 +0700 Subject: [PATCH 23/26] perf: guard all onChange handlers with isHandlingTabSwitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tab switch triggered 6+ onChange handlers (resultColumns, inspectorTrigger, pendingChangeTrigger, EC.resultVersion, EC.metadataVersion, EC.activeResultSetId) which cascaded into 4+ extra body re-evaluations per switch cycle. Now all onChange handlers check isHandlingTabSwitch and return early during tab switching. Also onTabSwitchSettled only runs if the settled tab is still the currently selected tab — prevents stale sidebar sync from triggering onChange(selectedTables) body eval cascade. Target: reduce per-switch cycle from ~280ms to ~80ms (body eval + AppKit opacity layout only, no onChange cascades). --- TablePro/Views/Main/Child/MainEditorContentView.swift | 6 +++--- .../Main/Extensions/MainContentCoordinator+TabSwitch.swift | 7 +++++-- TablePro/Views/Main/MainContentView.swift | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 3be6145ee..ecff676d9 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -190,17 +190,17 @@ struct MainEditorContentView: View { } } .onChange(of: tabManager.selectedTab?.resultVersion) { _, newVersion in - MainContentCoordinator.logger.warning("[DBG] EC.onChange(resultVersion) → \(String(describing: newVersion))") + guard !coordinator.isHandlingTabSwitch else { return } guard let tab = tabManager.selectedTab, newVersion != nil else { return } cacheRowProvider(for: tab) } .onChange(of: tabManager.selectedTab?.metadataVersion) { _, newVersion in - MainContentCoordinator.logger.warning("[DBG] EC.onChange(metadataVersion) → \(String(describing: newVersion))") + guard !coordinator.isHandlingTabSwitch else { return } guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } .onChange(of: tabManager.selectedTab?.activeResultSetId) { _, _ in - MainContentCoordinator.logger.warning("[DBG] EC.onChange(activeResultSetId)") + guard !coordinator.isHandlingTabSwitch else { return } guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 902d3fdc4..3f0adddd4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -101,8 +101,11 @@ extension MainContentCoordinator { } } - // Notify view layer to update title, sidebar, and persistence - // after deferred state has settled. + // Only run settled callback if THIS tab is still selected. + // During rapid Cmd+1/2/3, the user may have already switched to + // another tab — running sidebar sync/title/persist for a stale + // tab causes cascading onChange(selectedTables) body re-evals. + guard self.tabManager.selectedTabId == capturedNewId else { return } self.onTabSwitchSettled?() } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index a476b6faf..2816261f8 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -229,6 +229,7 @@ struct MainContentView: View { // and Phase 2 tab switch settlement. Removed .task(id: currentTab?.tableName) // which created N queued tasks during rapid Cmd+1/2/3 switching. .onChange(of: inspectorTrigger) { + guard !coordinator.isHandlingTabSwitch else { return } MainContentCoordinator.logger.warning("[DBG] onChange(inspectorTrigger)") scheduleInspectorUpdate() } @@ -268,7 +269,7 @@ struct MainContentView: View { // during view hierarchy reconstruction and is not reliable for resource cleanup. } .onChange(of: pendingChangeTrigger) { - MainContentCoordinator.logger.warning("[DBG] onChange(pendingChangeTrigger)") + guard !coordinator.isHandlingTabSwitch else { return } updateToolbarPendingState() } .userActivity("com.TablePro.viewConnection") { activity in @@ -311,6 +312,7 @@ struct MainContentView: View { handleTabsChange(newTabs) } .onChange(of: currentTab?.resultColumns) { _, newColumns in + guard !coordinator.isHandlingTabSwitch else { return } MainContentCoordinator.logger.warning("[DBG] onChange(resultColumns) count=\(newColumns?.count ?? -1)") handleColumnsChange(newColumns: newColumns) } From 5f1a434b574e98f36920b92636c2f22f402d10e0 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: Thu, 16 Apr 2026 12:36:04 +0700 Subject: [PATCH 24/26] perf: throttle keyboard tab switch commands during active switch selectTab/selectPreviousTab/selectNextTab now check isHandlingTabSwitch and return early if a switch is still being processed. This prevents macOS keyboard repeat events (30ms interval) from queuing 20+ tab switches that continue executing after the user releases the keys. The isHandlingTabSwitch flag is set synchronously in scheduleTabSwitch and cleared in the deferred Phase 2 Task, providing a natural throttle window of ~100ms per switch. --- TablePro/Views/Main/MainContentCommandActions.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 292d81c29..b722531f2 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -493,12 +493,16 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) func selectTab(number: Int) { + // Throttle: skip if coordinator is still handling a previous tab switch. + // Prevents macOS keyboard repeat events from queuing 20+ switches. + guard coordinator?.isHandlingTabSwitch != true else { return } guard let tabs = coordinator?.tabManager.tabs, number > 0, number <= tabs.count else { return } coordinator?.tabManager.selectedTabId = tabs[number - 1].id } func selectPreviousTab() { + guard coordinator?.isHandlingTabSwitch != true else { return } guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } let newIndex = (currentIndex - 1 + tabs.count) % tabs.count @@ -506,6 +510,7 @@ final class MainContentCommandActions { } func selectNextTab() { + guard coordinator?.isHandlingTabSwitch != true else { return } guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } let newIndex = (currentIndex + 1) % tabs.count From 056b32b1012b69ba2ec93fbe69ab175c32c3dc4e 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: Thu, 16 Apr 2026 12:39:28 +0700 Subject: [PATCH 25/26] fix: replace aggressive throttle with same-tab dedup for keyboard repeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isHandlingTabSwitch throttle blocked ALL keyboard events during Phase 2 (~500ms), making the app feel unresponsive. Replaced with: 1. selectTab: skip only if already on target tab (Cmd+1 repeat → skip) 2. selectPreviousTab/selectNextTab: no throttle (always responsive) 3. isHandlingTabSwitch now synchronous-only (defer in scheduleTabSwitch) — true only during the onChange handler, not during Phase 2 Task --- .../Extensions/MainContentCoordinator+TabSwitch.swift | 5 +++-- TablePro/Views/Main/MainContentCommandActions.swift | 10 ++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 3f0adddd4..494946435 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -18,9 +18,11 @@ extension MainContentCoordinator { from oldTabId: UUID?, to newTabId: UUID? ) { + // isHandlingTabSwitch is true only during this synchronous block. + // onChange handlers check it to skip cascading work. isHandlingTabSwitch = true + defer { isHandlingTabSwitch = false } - // MRU tracking is lightweight (array append) — do synchronously if let newId = newTabId { tabManager.trackActivation(newId) } @@ -32,7 +34,6 @@ extension MainContentCoordinator { let capturedNewId = newTabId tabSwitchTask = Task { @MainActor [weak self] in guard let self, !Task.isCancelled else { return } - defer { self.isHandlingTabSwitch = false } // Update toolbar and selection for the settled tab if let newId = capturedNewId, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index b722531f2..78ff2c0fe 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -493,16 +493,15 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) func selectTab(number: Int) { - // Throttle: skip if coordinator is still handling a previous tab switch. - // Prevents macOS keyboard repeat events from queuing 20+ switches. - guard coordinator?.isHandlingTabSwitch != true else { return } guard let tabs = coordinator?.tabManager.tabs, number > 0, number <= tabs.count else { return } - coordinator?.tabManager.selectedTabId = tabs[number - 1].id + let targetId = tabs[number - 1].id + // Skip if already on this tab (keyboard repeat of same Cmd+N) + guard coordinator?.tabManager.selectedTabId != targetId else { return } + coordinator?.tabManager.selectedTabId = targetId } func selectPreviousTab() { - guard coordinator?.isHandlingTabSwitch != true else { return } guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } let newIndex = (currentIndex - 1 + tabs.count) % tabs.count @@ -510,7 +509,6 @@ final class MainContentCommandActions { } func selectNextTab() { - guard coordinator?.isHandlingTabSwitch != true else { return } guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } let newIndex = (currentIndex + 1) % tabs.count From ca5e05c4a98a06dcedf649d4fb8a34cfbee7de15 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: Thu, 16 Apr 2026 16:19:27 +0700 Subject: [PATCH 26/26] refactor: replace ZStack+opacity with AppKit tab container for instant switching Replace SwiftUI ZStack+ForEach+opacity pattern with NSViewRepresentable (TabContentContainerView) that manages one NSHostingView per tab. Tab switching toggles isHidden instead of SwiftUI opacity, eliminating body re-evaluation cascade for all inactive tabs. Key changes: - TabContentContainerView: NSViewRepresentable with per-tab NSHostingView - Tab click via .onTapGesture (removed NSView overlay that blocked close/drag) - Cmd+W intercepted via NSEvent monitor (not window.delegate overwrite) - Phase 1 synchronous outgoing save (no state loss during rapid switching) - Shared manager restore guarded to skip unchanged values - Version-gated rootView rebuild (contentVersion includes error/executing) - Rename moved to context menu, deprecated onCommit replaced with onSubmit - teardown resumes saveCompletionContinuation to prevent Task leak - Dead code removed (evictInactiveTabs, handleTabSelectionChange, DBG logs) --- TablePro/Models/Query/QueryTab.swift | 9 ++ TablePro/Models/Query/QueryTabManager.swift | 4 + .../Models/UI/ColumnVisibilityManager.swift | 2 +- TablePro/Models/UI/FilterState.swift | 10 +- TablePro/TableProApp.swift | 9 +- .../Main/Child/MainEditorContentView.swift | 38 +++-- .../Main/Child/TabContentContainerView.swift | 101 ++++++++++++++ ...MainContentCoordinator+TabOperations.swift | 8 +- .../MainContentCoordinator+TabSwitch.swift | 131 +++++++++--------- .../MainContentView+EventHandlers.swift | 5 - .../Extensions/MainContentView+Setup.swift | 23 +++ .../Main/MainContentCommandActions.swift | 20 +-- .../Views/Main/MainContentCoordinator.swift | 16 ++- TablePro/Views/Main/MainContentView.swift | 30 ++-- TablePro/Views/TabBar/EditorTabBar.swift | 7 +- TablePro/Views/TabBar/EditorTabBarItem.swift | 49 ++++--- 16 files changed, 293 insertions(+), 169 deletions(-) create mode 100644 TablePro/Views/Main/Child/TabContentContainerView.swift diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index f3e2f07ae..9611321c8 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -133,6 +133,15 @@ struct QueryTab: Identifiable, Equatable { // Version counter incremented on pagination changes, used to scroll grid to top var paginationVersion: Int + /// Composite version for NSHostingView rootView rebuild decisions. + /// Includes all state that affects visual content. + var contentVersion: Int { + var v = resultVersion &+ metadataVersion &+ paginationVersion + if errorMessage != nil { v = v &+ 1 } + if isExecuting { v = v &+ 2 } + return v + } + /// Whether the editor content differs from the last saved/loaded file content. /// Returns false for tabs not backed by a file. /// Uses O(1) length pre-check to avoid O(n) string comparison on every keystroke. diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 8be278d6c..4986b4db2 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -46,6 +46,10 @@ final class QueryTabManager { func trackActivation(_ tabId: UUID) { tabActivationOrder.removeAll { $0 == tabId } tabActivationOrder.append(tabId) + // Cap to prevent unbounded growth from open/close cycles + if tabActivationOrder.count > 50 { + tabActivationOrder.removeFirst(tabActivationOrder.count - 50) + } } /// Returns the most recently active tab ID, excluding a given ID. diff --git a/TablePro/Models/UI/ColumnVisibilityManager.swift b/TablePro/Models/UI/ColumnVisibilityManager.swift index 05924380d..2326e5c18 100644 --- a/TablePro/Models/UI/ColumnVisibilityManager.swift +++ b/TablePro/Models/UI/ColumnVisibilityManager.swift @@ -49,7 +49,7 @@ internal final class ColumnVisibilityManager { } func restoreFromColumnLayout(_ columns: Set) { - hiddenColumns = columns + if hiddenColumns != columns { hiddenColumns = columns } } // MARK: - Per-Table UserDefaults Persistence diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 55305d241..c39bf359a 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -240,12 +240,12 @@ final class FilterStateManager { ) } - /// Restore filter state from tab + /// Restore filter state from tab — skips mutations when values unchanged func restoreFromTabState(_ state: TabFilterState) { - filters = state.filters - appliedFilters = state.appliedFilters - isVisible = state.isVisible - filterLogicMode = state.filterLogicMode + if filters != state.filters { filters = state.filters } + if appliedFilters != state.appliedFilters { appliedFilters = state.appliedFilters } + if isVisible != state.isVisible { isVisible = state.isVisible } + if filterLogicMode != state.filterLogicMode { filterLogicMode = state.filterLogicMode } } /// Save filters for a table (for "Restore Last Filter" setting) diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 5ccd55f8e..0940ebe6a 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -183,14 +183,7 @@ struct AppMenuCommands: Commands { 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) - } + window.performClose(nil) } } .optionalKeyboardShortcut(shortcut(for: .closeTab)) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ecff676d9..4f515529b 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -8,7 +8,6 @@ import AppKit import CodeEditSourceEditor -import os import SwiftUI /// Cache for sorted query result rows to avoid re-sorting on every SwiftUI body evaluation @@ -91,10 +90,16 @@ struct MainEditorContentView: View { return AnyChangeManager(dataManager: changeManager) } + /// Composite version counter for the active tab — drives `updateNSView` + /// when query results, metadata, or pagination change. Hidden tabs are + /// not tracked; their rootView is refreshed when they become active. + private var activeTabContentVersion: Int { + tabManager.selectedTab?.contentVersion ?? 0 + } + // MARK: - Body var body: some View { - let _ = MainContentCoordinator.logger.warning("[DBG] EditorContent.body eval selected=\(tabManager.selectedTab?.title ?? "nil", privacy: .public)") let isHistoryVisible = coordinator.toolbarState.isHistoryPanelVisible VStack(spacing: 0) { @@ -121,18 +126,15 @@ struct MainEditorContentView: View { if tabManager.tabs.isEmpty { emptyStateView } else { - // Keep all tab views alive — only the active tab is visible. - // Matches Apple's NSTabViewController pattern: views are not - // destroyed/recreated on switch, avoiding ~200ms NSTableView - // + TreeSitter reconstruction cost. - ZStack { - ForEach(tabManager.tabs) { tab in - let isActive = tab.id == tabManager.selectedTabId - tabContent(for: tab) - .opacity(isActive ? 1 : 0) - .allowsHitTesting(isActive) - } - } + // Tab content lives in AppKit NSHostingViews managed by a + // Coordinator. Tab switching toggles NSView.isHidden — + // no SwiftUI body re-evaluation, no CALayer opacity relayout. + TabContentContainerView( + tabManager: tabManager, + tabIds: tabManager.tabIds, + activeTabContentVersion: activeTabContentVersion, + contentBuilder: { tab in AnyView(tabContent(for: tab)) } + ) } // Global History Panel @@ -151,7 +153,6 @@ struct MainEditorContentView: View { ) } .onChange(of: tabManager.tabIds) { _, newIds in - MainContentCoordinator.logger.warning("[DBG] EC.onChange(tabIds) count=\(newIds.count)") guard !sortCache.isEmpty || !tabProviderCache.isEmpty || !erDiagramViewModels.isEmpty || !serverDashboardViewModels.isEmpty else { coordinator.cleanupSortCache(openTabIds: Set(newIds)) @@ -165,7 +166,6 @@ struct MainEditorContentView: View { serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) } } .onChange(of: tabManager.selectedTabId) { _, newId in - MainContentCoordinator.logger.warning("[DBG] EC.onChange(selectedTabId) → \(String(describing: newId))") updateHasQueryText() guard let newId, let tab = tabManager.selectedTab else { return } @@ -173,8 +173,7 @@ struct MainEditorContentView: View { if cached?.resultVersion != tab.resultVersion || cached?.metadataVersion != tab.metadataVersion { - MainContentCoordinator.logger.warning("[DBG] EC.cacheRowProvider called (cache miss)") - cacheRowProvider(for: tab) + cacheRowProvider(for: tab) } } .onAppear { @@ -190,17 +189,14 @@ struct MainEditorContentView: View { } } .onChange(of: tabManager.selectedTab?.resultVersion) { _, newVersion in - guard !coordinator.isHandlingTabSwitch else { return } guard let tab = tabManager.selectedTab, newVersion != nil else { return } cacheRowProvider(for: tab) } .onChange(of: tabManager.selectedTab?.metadataVersion) { _, newVersion in - guard !coordinator.isHandlingTabSwitch else { return } guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } .onChange(of: tabManager.selectedTab?.activeResultSetId) { _, _ in - guard !coordinator.isHandlingTabSwitch else { return } guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } diff --git a/TablePro/Views/Main/Child/TabContentContainerView.swift b/TablePro/Views/Main/Child/TabContentContainerView.swift new file mode 100644 index 000000000..240c2cfd0 --- /dev/null +++ b/TablePro/Views/Main/Child/TabContentContainerView.swift @@ -0,0 +1,101 @@ +// +// TabContentContainerView.swift +// TablePro +// +// AppKit container that manages one NSHostingView per tab. +// Tab switching toggles NSView.isHidden — only the active tab +// is visible. Note: hidden NSHostingViews still run SwiftUI +// observation tracking; isHidden only suppresses rendering. +// + +import SwiftUI + +/// NSViewRepresentable that manages tab content views in AppKit. +/// Only the active tab's NSHostingView is visible (isHidden = false). +/// Inactive tabs are hidden so SwiftUI suspends their rendering. +@MainActor +struct TabContentContainerView: NSViewRepresentable { + let tabManager: QueryTabManager + let tabIds: [UUID] + let activeTabContentVersion: Int + let contentBuilder: (QueryTab) -> AnyView + + // MARK: - Coordinator + + @MainActor + final class Coordinator { + var hostingViews: [UUID: NSHostingView] = [:] + var activeTabId: UUID? + var builtVersions: [UUID: Int] = [:] + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + syncHostingViews(container: container, coordinator: context.coordinator) + return container + } + + func updateNSView(_ container: NSView, context: Context) { + let coordinator = context.coordinator + syncHostingViews(container: container, coordinator: coordinator) + + // Toggle visibility + let selectedId = tabManager.selectedTabId + if coordinator.activeTabId != selectedId { + if let oldId = coordinator.activeTabId { + coordinator.hostingViews[oldId]?.isHidden = true + } + if let newId = selectedId { + coordinator.hostingViews[newId]?.isHidden = false + } + coordinator.activeTabId = selectedId + } + + // Refresh active tab's rootView only when data version changed + if let activeId = selectedId, + let tab = tabManager.tabs.first(where: { $0.id == activeId }), + let hosting = coordinator.hostingViews[activeId] + { + if coordinator.builtVersions[activeId] != tab.contentVersion { + hosting.rootView = contentBuilder(tab) + coordinator.builtVersions[activeId] = tab.contentVersion + } + } + } + + private func syncHostingViews(container: NSView, coordinator: Coordinator) { + let currentIds = Set(tabIds) + + for id in coordinator.hostingViews.keys where !currentIds.contains(id) { + coordinator.hostingViews[id]?.removeFromSuperview() + coordinator.hostingViews.removeValue(forKey: id) + coordinator.builtVersions.removeValue(forKey: id) + } + + for tab in tabManager.tabs where coordinator.hostingViews[tab.id] == nil { + let hosting = NSHostingView(rootView: contentBuilder(tab)) + hosting.translatesAutoresizingMaskIntoConstraints = false + hosting.isHidden = (tab.id != tabManager.selectedTabId) + container.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor), + hosting.topAnchor.constraint(equalTo: container.topAnchor), + hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + coordinator.hostingViews[tab.id] = hosting + coordinator.builtVersions[tab.id] = tab.contentVersion + } + } + + static func dismantleNSView(_ container: NSView, coordinator: Coordinator) { + for hosting in coordinator.hostingViews.values { + hosting.removeFromSuperview() + } + coordinator.hostingViews.removeAll() + coordinator.builtVersions.removeAll() + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift index 90545f662..e4a19ea3a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift @@ -79,7 +79,6 @@ extension MainContentCoordinator { if wasSelected { if tabManager.tabs.isEmpty { tabManager.selectedTabId = nil - contentWindow?.close() } else { // MRU: select the most recently active tab, not just adjacent tabManager.selectedTabId = tabManager.mruTabId(excluding: id) @@ -133,8 +132,9 @@ extension MainContentCoordinator { } private func forceCloseOtherTabs(excluding id: UUID) { - for index in tabManager.tabs.indices where tabManager.tabs[index].id != id && !tabManager.tabs[index].isPinned { - tabManager.tabs[index].rowBuffer.evict() + for tab in tabManager.tabs where tab.id != id && !tab.isPinned { + tabManager.pushClosedTab(tab) + tab.rowBuffer.evict() } tabManager.tabs.removeAll { $0.id != id && !$0.isPinned } tabManager.selectedTabId = id @@ -172,6 +172,7 @@ extension MainContentCoordinator { private func forceCloseAllTabs() { let closable = tabManager.tabs.filter { !$0.isPinned } for tab in closable { + tabManager.pushClosedTab(tab) tab.rowBuffer.evict() } tabManager.tabs.removeAll { !$0.isPinned } @@ -179,7 +180,6 @@ extension MainContentCoordinator { if tabManager.tabs.isEmpty { tabManager.selectedTabId = nil persistence.clearSavedState() - contentWindow?.close() } else { // Pinned tabs remain — select the first one tabManager.selectedTabId = tabManager.tabs.first?.id diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 494946435..c1f6525e4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -10,10 +10,10 @@ import Foundation import os extension MainContentCoordinator { - /// Schedule a tab switch with zero synchronous @Observable mutations. - /// The ZStack opacity flip happens from selectedTabId binding alone. - /// All state work (save outgoing, MRU, title, sidebar, persist) is - /// deferred to Phase 2 Task which coalesces rapid Cmd+1/2/3 spam. + /// Schedule a tab switch. Phase 1 (synchronous): MRU tracking only. + /// Phase 2 (deferred Task): save outgoing state, restore incoming + /// shared managers, lazy query, sidebar/title/persist settlement. + /// Rapid Cmd+1/2/3 coalesces — only the LAST switch's Phase 2 runs. func scheduleTabSwitch( from oldTabId: UUID?, to newTabId: UUID? @@ -27,46 +27,78 @@ extension MainContentCoordinator { tabManager.trackActivation(newId) } - // Phase 2: Deferred — all state work coalesced via task cancellation. + // Save outgoing tab state synchronously (Phase 1) so it's never lost + // during rapid Cmd+1/2/3 coalescing where Phase 2 Tasks get cancelled. + if let oldId = oldTabId, + let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) + { + var tab = tabManager.tabs[oldIndex] + if changeManager.hasChanges { + tab.pendingChanges = changeManager.saveState() + } + tab.filterState = filterStateManager.saveToTabState() + tabManager.tabs[oldIndex] = tab + if let tableName = tab.tableName { + filterStateManager.saveLastFilters(for: tableName) + } + saveColumnVisibilityToTab() + saveColumnLayoutForTable() + } + + // Phase 2: Deferred — restore incoming state + lazy query. // During rapid Cmd+1/2/3, only the LAST switch's Phase 2 executes. tabSwitchTask?.cancel() - let capturedOldId = oldTabId let capturedNewId = newTabId tabSwitchTask = Task { @MainActor [weak self] in guard let self, !Task.isCancelled else { return } - - // Update toolbar and selection for the settled tab - if let newId = capturedNewId, - let newIndex = self.tabManager.tabs.firstIndex(where: { $0.id == newId }) { - self.toolbarState.isTableTab = self.tabManager.tabs[newIndex].tabType == .table - } else { - self.toolbarState.isTableTab = false - self.toolbarState.isResultsCollapsed = false - } - - if let oldId = capturedOldId, - let oldIndex = self.tabManager.tabs.firstIndex(where: { $0.id == oldId }) { - var tab = self.tabManager.tabs[oldIndex] - if self.changeManager.hasChanges { - tab.pendingChanges = self.changeManager.saveState() - } - tab.filterState = self.filterStateManager.saveToTabState() - self.tabManager.tabs[oldIndex] = tab - if let tableName = tab.tableName { - self.filterStateManager.saveLastFilters(for: tableName) - } - self.saveColumnVisibilityToTab() - self.saveColumnLayoutForTable() - } - guard !Task.isCancelled else { return } - // Lazy query check for evicted/empty tabs + // Restore incoming tab shared state. guard let newId = capturedNewId, let newIndex = self.tabManager.tabs.firstIndex(where: { $0.id == newId }) - else { return } + else { + self.toolbarState.isTableTab = false + self.toolbarState.isResultsCollapsed = false + self.filterStateManager.clearAll() + return + } let newTab = self.tabManager.tabs[newIndex] + // Guard each mutation — skip when the value is already correct. + // Avoids unnecessary @Observable notifications that would cause + // ALL NSHostingViews to re-evaluate (expensive for tabs with many rows). + let isTable = newTab.tabType == .table + if self.toolbarState.isTableTab != isTable { + self.toolbarState.isTableTab = isTable + } + if self.toolbarState.isResultsCollapsed != newTab.isResultsCollapsed { + self.toolbarState.isResultsCollapsed = newTab.isResultsCollapsed + } + self.filterStateManager.restoreFromTabState(newTab.filterState) + self.restoreColumnVisibilityFromTab(newTab) + + // Reconfigure change manager only when the table actually changed + let newTableName = newTab.tableName ?? "" + let pendingState = newTab.pendingChanges + if pendingState.hasChanges { + self.changeManager.restoreState( + from: pendingState, + tableName: newTableName, + databaseType: self.connection.type + ) + } else if self.changeManager.tableName != newTableName + || self.changeManager.columns != newTab.resultColumns + { + self.changeManager.configureForTable( + tableName: newTableName, + columns: newTab.resultColumns, + primaryKeyColumns: newTab.primaryKeyColumns.isEmpty + ? Array(newTab.resultColumns.prefix(1)) + : newTab.primaryKeyColumns, + databaseType: self.connection.type, + triggerReload: false + ) + } // Database switch check if !newTab.databaseName.isEmpty { let currentDatabase = DatabaseManager.shared.session(for: self.connectionId)?.activeDatabase @@ -110,37 +142,4 @@ extension MainContentCoordinator { self.onTabSwitchSettled?() } } - - private func evictInactiveTabs(excluding activeTabIds: Set) { - let candidates = tabManager.tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges - } - - let sorted = candidates.sorted { - let t0 = $0.lastExecutedAt ?? .distantFuture - let t1 = $1.lastExecutedAt ?? .distantFuture - if t0 != t1 { return t0 < t1 } - let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.rowBuffer.rows.count, - columnCount: $0.rowBuffer.columns.count - ) - let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.rowBuffer.rows.count, - columnCount: $1.rowBuffer.columns.count - ) - return size0 > size1 - } - - let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs() - guard sorted.count > maxInactiveLoaded else { return } - let toEvict = sorted.dropLast(maxInactiveLoaded) - - for tab in toEvict { - tab.rowBuffer.evict() - } - } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 2541319da..3e98576e9 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -6,16 +6,11 @@ // Extracted to reduce main view complexity. // -import os import SwiftUI extension MainContentView { // MARK: - Event Handlers - func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { - coordinator.scheduleTabSwitch(from: oldTabId, to: newTabId) - } - func handleTabsChange(_ newTabs: [QueryTab]) { // Skip during tab switch — handleTabChange saves outgoing tab state which // mutates tabs[], triggering this handler redundantly. The tab selection diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 8549c68bf..ab2642fd9 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -187,6 +187,29 @@ extension MainContentView { coordinator.contentWindow = window isKeyWindow = window.isKeyWindow + // Intercept Cmd+W via NSEvent monitor — close the active in-app tab + // instead of the window. Uses event monitor (like VimKeyInterceptor) + // instead of window.delegate to avoid overwriting SwiftUI's internal delegate. + if coordinator.closeTabMonitor == nil { + let capturedTabManager = tabManager + let capturedCoordinator = coordinator + weak var capturedWindow = window + coordinator.closeTabMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.modifierFlags.contains(.command), + !event.modifierFlags.contains(.shift), + event.charactersIgnoringModifiers == "w", + NSApp.keyWindow === capturedWindow + else { return event } + + if let selectedId = capturedTabManager.selectedTabId { + capturedCoordinator.closeInAppTab(selectedId) + } else { + capturedWindow?.close() + } + return nil // Consume the event + } + } + if let payloadId = payload?.id { WindowOpener.shared.acknowledgePayload(payloadId) } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 78ff2c0fe..2b916df50 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -374,18 +374,12 @@ final class MainContentCommandActions { return } - // Multiple in-app tabs: close the selected tab - if coordinator.tabManager.tabs.count > 1, let selectedId = coordinator.tabManager.selectedTabId { + // Close the active in-app tab. Empty state is shown when no tabs remain. + if let selectedId = coordinator.tabManager.selectedTabId { coordinator.closeInAppTab(selectedId) } else { - // Last tab or no tabs: close the window - for tab in coordinator.tabManager.tabs { - tab.rowBuffer.evict() - } - coordinator.tabManager.tabs.removeAll() - coordinator.tabManager.selectedTabId = nil - coordinator.toolbarState.isTableTab = false - NSApp.keyWindow?.close() + // No tabs open — close the connection window + coordinator.contentWindow?.close() } } @@ -457,6 +451,12 @@ final class MainContentCommandActions { pendingTruncates.wrappedValue.removeAll() pendingDeletes.wrappedValue.removeAll() rightPanelState.editState.clearEdits() + // Clear file dirty state to prevent closeInAppTab from showing a second dialog + if let tab = coordinator?.tabManager.selectedTab, tab.isFileDirty, + let index = coordinator?.tabManager.selectedTabIndex + { + coordinator?.tabManager.tabs[index].savedFileContent = tab.query + } performClose() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 0f68c5d72..f65cddd91 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -108,6 +108,10 @@ final class MainContentCoordinator { /// Avoids NSApp.keyWindow which may return a sheet window, causing stuck dialogs. @ObservationIgnored weak var contentWindow: NSWindow? + /// NSEvent monitor that intercepts Cmd+W to close tabs instead of the window. + /// Removed in teardown. + @ObservationIgnored var closeTabMonitor: Any? + // MARK: - Published State var schemaProvider: SQLSchemaProvider @@ -207,7 +211,7 @@ final class MainContentCoordinator { /// Check whether any active coordinator has unsaved edits. static func hasAnyUnsavedChanges() -> Bool { activeCoordinators.values.contains { coordinator in - coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges } + coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } } } @@ -410,6 +414,16 @@ final class MainContentCoordinator { func teardown() { _didTeardown.withLock { $0 = true } + // Resume any pending save continuation to prevent Task leak + saveCompletionContinuation?.resume(returning: false) + saveCompletionContinuation = nil + + // Remove Cmd+W event monitor + if let monitor = closeTabMonitor { + NSEvent.removeMonitor(monitor) + closeTabMonitor = nil + } + unregisterFromPersistence() for observer in urlFilterObservers { NotificationCenter.default.removeObserver(observer) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 2816261f8..50d4f2d13 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -14,7 +14,6 @@ // import Combine -import os import SwiftUI import TableProPluginKit @@ -107,7 +106,6 @@ struct MainContentView: View { // MARK: - Body var body: some View { - let _ = MainContentCoordinator.logger.warning("[DBG] MCV.body eval") bodyContent .sheet(item: Bindable(coordinator).activeSheet) { sheet in sheetContent(for: sheet) @@ -229,8 +227,6 @@ struct MainContentView: View { // and Phase 2 tab switch settlement. Removed .task(id: currentTab?.tableName) // which created N queued tasks during rapid Cmd+1/2/3 switching. .onChange(of: inspectorTrigger) { - guard !coordinator.isHandlingTabSwitch else { return } - MainContentCoordinator.logger.warning("[DBG] onChange(inspectorTrigger)") scheduleInspectorUpdate() } .onAppear { @@ -246,17 +242,18 @@ struct MainContentView: View { rightPanelState.aiViewModel.schemaProvider = coordinator.schemaProvider coordinator.aiViewModel = rightPanelState.aiViewModel coordinator.rightPanelState = rightPanelState - coordinator.onTabSwitchSettled = { [self] in - updateWindowTitleAndFileState() - syncSidebarToCurrentTab() - guard !coordinator.isTearingDown else { return } - coordinator.persistence.saveNow( - tabs: tabManager.tabs, - selectedTabId: tabManager.selectedTabId + coordinator.onTabSwitchSettled = { + // Capture reference types explicitly — MainContentView is a struct, + // but @State/@Binding storage is reference-stable. + self.updateWindowTitleAndFileState() + self.syncSidebarToCurrentTab() + guard !self.coordinator.isTearingDown else { return } + self.coordinator.persistence.saveNow( + tabs: self.tabManager.tabs, + selectedTabId: self.tabManager.selectedTabId ) - // Load table metadata for the settled tab - if let tab = tabManager.selectedTab, tab.lastExecutedAt != nil { - Task { await loadTableMetadataIfNeeded() } + if let tab = self.tabManager.selectedTab, tab.lastExecutedAt != nil { + Task { await self.loadTableMetadataIfNeeded() } } } @@ -269,7 +266,6 @@ struct MainContentView: View { // during view hierarchy reconstruction and is not reliable for resource cleanup. } .onChange(of: pendingChangeTrigger) { - guard !coordinator.isHandlingTabSwitch else { return } updateToolbarPendingState() } .userActivity("com.TablePro.viewConnection") { activity in @@ -308,12 +304,9 @@ struct MainContentView: View { previousSelectedTabId = newTabId } .onChange(of: tabManager.tabs) { _, newTabs in - MainContentCoordinator.logger.warning("[DBG] onChange(tabs) count=\(newTabs.count)") handleTabsChange(newTabs) } .onChange(of: currentTab?.resultColumns) { _, newColumns in - guard !coordinator.isHandlingTabSwitch else { return } - MainContentCoordinator.logger.warning("[DBG] onChange(resultColumns) count=\(newColumns?.count ?? -1)") handleColumnsChange(newColumns: newColumns) } .task { handleConnectionStatusChange() } @@ -325,7 +318,6 @@ struct MainContentView: View { } .onChange(of: sidebarState.selectedTables) { _, newTables in - MainContentCoordinator.logger.warning("[DBG] onChange(selectedTables) count=\(newTables.count)") handleTableSelectionChange(from: previousSelectedTables, to: newTables) previousSelectedTables = newTables } diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift index 70bd34ba2..6fb3c889d 100644 --- a/TablePro/Views/TabBar/EditorTabBar.swift +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -48,9 +48,7 @@ struct EditorTabBar: View { } .onChange(of: selectedTabId, initial: true) { _, newId in if let id = newId { - withAnimation(.easeInOut(duration: 0.15)) { - proxy.scrollTo(id, anchor: .center) - } + proxy.scrollTo(id, anchor: .center) } } } @@ -141,6 +139,7 @@ private struct TabDropDelegate: DropDelegate { } func dropExited(info: DropInfo) { - draggedTabId = nil + // Don't clear draggedTabId here — during reorder, dropEntered on the + // next tab needs it. It's cleared in performDrop when the drag ends. } } diff --git a/TablePro/Views/TabBar/EditorTabBarItem.swift b/TablePro/Views/TabBar/EditorTabBarItem.swift index 697437c8c..d402a7b5d 100644 --- a/TablePro/Views/TabBar/EditorTabBarItem.swift +++ b/TablePro/Views/TabBar/EditorTabBarItem.swift @@ -47,22 +47,23 @@ struct EditorTabBarItem: View { .foregroundStyle(.secondary) if isEditing { - TextField("", text: $editingTitle, onCommit: { - let trimmed = editingTitle.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - onRename(trimmed) - } - isEditing = false - }) - .textFieldStyle(.plain) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .frame(minWidth: 40, maxWidth: 120) - .focused($isEditingFocused) - .onChange(of: isEditingFocused) { _, focused in - if !focused && isEditing { + TextField("", text: $editingTitle) + .textFieldStyle(.plain) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .frame(minWidth: 40, maxWidth: 120) + .focused($isEditingFocused) + .onSubmit { + let trimmed = editingTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + onRename(trimmed) + } isEditing = false } - } + .onChange(of: isEditingFocused) { _, focused in + if !focused && isEditing { + isEditing = false + } + } } else { Text(tab.title) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) @@ -106,18 +107,16 @@ struct EditorTabBarItem: View { ) .contentShape(Rectangle()) .onHover { isHovering = $0 } - .gesture( - TapGesture(count: 2).onEnded { - guard tab.tabType == .query else { return } - editingTitle = tab.title - isEditing = true - isEditingFocused = true - } - .exclusively(before: TapGesture(count: 1).onEnded { - onSelect() - }) - ) + .onTapGesture { onSelect() } .contextMenu { + if tab.tabType == .query { + Button(String(localized: "Rename")) { + editingTitle = tab.title + isEditing = true + isEditingFocused = true + } + Divider() + } Button(tab.isPinned ? String(localized: "Unpin Tab") : String(localized: "Pin Tab")) { onTogglePin() }