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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] =?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/36] 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/36] 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/36] 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/36] 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/36] 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/36] =?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/36] 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/36] 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/36] 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/36] 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() } From 9edf7809f007877454dae7e1dcb922e03f0af482 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 20:13:19 +0700 Subject: [PATCH 27/36] fix: resolve remaining critical/high tab bar bugs (round 2) - Guard background tab close against pendingChanges (data loss) - Add changeManager.hasChanges to quit-time unsaved check - Guard saveCompletionContinuation against concurrent access - Clear pendingChanges on reopened closed tab - Set isEditable on preview tab creation path - Show dirty indicator for active tab via isActiveTabDirty prop - Add persistTabs() helper to consistently exclude preview tabs --- .../Main/Child/MainEditorContentView.swift | 3 +- .../MainContentCoordinator+Navigation.swift | 1 + ...MainContentCoordinator+TabOperations.swift | 57 ++++++++++++++----- .../Views/Main/MainContentCoordinator.swift | 3 +- TablePro/Views/TabBar/EditorTabBar.swift | 2 + TablePro/Views/TabBar/EditorTabBarItem.swift | 3 +- 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 4f515529b..6427acc7e 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -118,7 +118,8 @@ struct MainEditorContentView: View { onRename: { id, name in coordinator.renameTab(id, to: name) }, onAddTab: { coordinator.addNewQueryTab() }, onDuplicate: { id in coordinator.duplicateTab(id) }, - onTogglePin: { id in coordinator.togglePinTab(id) } + onTogglePin: { id in coordinator.togglePinTab(id) }, + isActiveTabDirty: changeManager.hasChanges ) Divider() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 821d87f20..b1c27f2d7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -314,6 +314,7 @@ extension MainContentCoordinator { ) 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 diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift index e4a19ea3a..da4598c94 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift @@ -21,8 +21,13 @@ extension MainContentCoordinator { let isSelected = tabManager.selectedTabId == id - // Check for unsaved changes on this specific tab - if isSelected && changeManager.hasChanges { + // Check for unsaved changes — live changeManager for selected tab, + // persisted pendingChanges for background tabs + let hasUnsavedData = isSelected + ? changeManager.hasChanges + : tab.pendingChanges.hasChanges + + if hasUnsavedData { Task { @MainActor in let result = await AlertHelper.confirmSaveChanges( message: String(localized: "Your changes will be lost if you don't save them."), @@ -30,9 +35,17 @@ extension MainContentCoordinator { ) switch result { case .save: - await self.saveDataChangesAndClose(tabId: id) + if isSelected { + await self.saveDataChangesAndClose(tabId: id) + } else { + // Background tabs can't be saved through changeManager — discard and close. + // The dialog gives the user a chance to cancel and switch to the tab first. + removeTab(id) + } case .dontSave: - changeManager.clearChangesAndUndoHistory() + if isSelected { + changeManager.clearChangesAndUndoHistory() + } removeTab(id) case .cancel: return @@ -86,10 +99,11 @@ extension MainContentCoordinator { } } - persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + persistTabs() } private func saveDataChangesAndClose(tabId: UUID) async { + guard saveCompletionContinuation == nil else { return } var truncates: Set = [] var deletes: Set = [] var options: [String: TableOperationOptions] = [:] @@ -117,6 +131,9 @@ extension MainContentCoordinator { ) switch result { case .save, .dontSave: + // Bulk close can't individually save each tab's changes — changeManager + // only holds the active tab's state. Both options discard and close. + // The dialog gives users a chance to cancel and save individual tabs first. if selectedIsBeingClosed { changeManager.clearChangesAndUndoHistory() } @@ -138,7 +155,7 @@ extension MainContentCoordinator { } tabManager.tabs.removeAll { $0.id != id && !$0.isPinned } tabManager.selectedTabId = id - persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + persistTabs() } func closeAllTabs() { @@ -157,6 +174,7 @@ extension MainContentCoordinator { ) switch result { case .save, .dontSave: + // Bulk close can't individually save each tab — see closeOtherTabs comment changeManager.clearChangesAndUndoHistory() forceCloseAllTabs() case .cancel: @@ -179,12 +197,10 @@ extension MainContentCoordinator { if tabManager.tabs.isEmpty { tabManager.selectedTabId = nil - persistence.clearSavedState() } else { - // Pinned tabs remain — select the first one tabManager.selectedTabId = tabManager.tabs.first?.id - persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) } + persistTabs() } // MARK: - Reopen Closed Tab (Cmd+Shift+T) @@ -192,12 +208,13 @@ extension MainContentCoordinator { func reopenClosedTab() { guard var tab = tabManager.popClosedTab() else { return } tab.rowBuffer = RowBuffer() + tab.pendingChanges = TabPendingChanges() 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) + persistTabs() } // MARK: - Pin Tab @@ -211,14 +228,28 @@ extension MainContentCoordinator { let unpinned = tabManager.tabs.filter { !$0.isPinned } tabManager.tabs = pinned + unpinned - persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + persistTabs() + } + + // MARK: - Persistence Helper + + /// Persist tabs to disk, excluding preview tabs (consistent with handleTabsChange). + private func persistTabs() { + let persistableTabs = tabManager.tabs.filter { !$0.isPreview } + if persistableTabs.isEmpty { + persistence.clearSavedState() + } else { + let selectedId = persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) + ? tabManager.selectedTabId : persistableTabs.first?.id + persistence.saveNow(tabs: persistableTabs, selectedTabId: selectedId) + } } // MARK: - Tab Reorder func reorderTabs(_ newOrder: [QueryTab]) { tabManager.tabs = newOrder - persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + persistTabs() } // MARK: - Tab Rename @@ -226,7 +257,7 @@ extension MainContentCoordinator { 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) + persistTabs() } // MARK: - Add Tab diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f65cddd91..1522c0b59 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -211,7 +211,8 @@ 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 || $0.isFileDirty } + coordinator.changeManager.hasChanges + || coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } } } diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift index 6fb3c889d..8947c352f 100644 --- a/TablePro/Views/TabBar/EditorTabBar.swift +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -21,6 +21,7 @@ struct EditorTabBar: View { var onAddTab: () -> Void var onDuplicate: (UUID) -> Void var onTogglePin: (UUID) -> Void + var isActiveTabDirty: Bool = false @State private var draggedTabId: UUID? @@ -76,6 +77,7 @@ struct EditorTabBar: View { EditorTabBarItem( tab: tab, isSelected: tab.id == selectedTabId, + isActiveTabDirty: tab.id == selectedTabId && isActiveTabDirty, databaseType: databaseType, onSelect: { selectedTabId = tab.id }, onClose: { onClose(tab.id) }, diff --git a/TablePro/Views/TabBar/EditorTabBarItem.swift b/TablePro/Views/TabBar/EditorTabBarItem.swift index d402a7b5d..3678ccd8a 100644 --- a/TablePro/Views/TabBar/EditorTabBarItem.swift +++ b/TablePro/Views/TabBar/EditorTabBarItem.swift @@ -10,6 +10,7 @@ import SwiftUI struct EditorTabBarItem: View { let tab: QueryTab let isSelected: Bool + var isActiveTabDirty: Bool = false let databaseType: DatabaseType var onSelect: () -> Void var onClose: () -> Void @@ -71,7 +72,7 @@ struct EditorTabBarItem: View { .lineLimit(1) } - if tab.isFileDirty || tab.pendingChanges.hasChanges { + if tab.isFileDirty || tab.pendingChanges.hasChanges || isActiveTabDirty { Circle() .fill(Color.primary.opacity(0.5)) .frame(width: 6, height: 6) From ce538b842fb2e6ec38b6d6b49040ad0c8efdb1fe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 20:17:09 +0700 Subject: [PATCH 28/36] fix: resolve medium/low tab bar issues (round 3) - Move closeTabsToRight to coordinator with aggregated unsaved-changes check - Enforce pinned/unpinned boundary in drag-and-drop reorder - Commit rename text on focus loss instead of discarding - Add reopenClosedTab to ShortcutAction for remappable Cmd+Shift+T - Add accessibility labels to tab bar items and close button --- .../Models/UI/KeyboardShortcutModels.swift | 5 +- TablePro/TableProApp.swift | 2 +- .../Main/Child/MainEditorContentView.swift | 1 + ...MainContentCoordinator+TabOperations.swift | 46 +++++++++++++++++++ TablePro/Views/TabBar/EditorTabBar.swift | 14 ++---- TablePro/Views/TabBar/EditorTabBarItem.swift | 8 ++++ 6 files changed, 65 insertions(+), 11 deletions(-) diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 54ef8822a..abda504fa 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -86,6 +86,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { // Tabs case showPreviousTab case showNextTab + case reopenClosedTab // AI case aiExplainQuery @@ -107,7 +108,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab, .nextResultTab, .closeResultTab: return .view - case .showPreviousTab, .showNextTab: + case .showPreviousTab, .showNextTab, .reopenClosedTab: return .tabs case .aiExplainQuery, .aiOptimizeQuery: return .ai @@ -158,6 +159,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .closeResultTab: return String(localized: "Close Result Tab") case .showPreviousTab: return String(localized: "Show Previous Tab") case .showNextTab: return String(localized: "Show Next Tab") + case .reopenClosedTab: return String(localized: "Reopen Closed Tab") case .aiExplainQuery: return String(localized: "Explain with AI") case .aiOptimizeQuery: return String(localized: "Optimize with AI") } @@ -500,6 +502,7 @@ struct KeyboardSettings: Codable, Equatable { // Tabs .showPreviousTab: KeyCombo(key: "[", command: true, shift: true), .showNextTab: KeyCombo(key: "]", command: true, shift: true), + .reopenClosedTab: KeyCombo(key: "t", command: true, shift: true), // AI .aiExplainQuery: KeyCombo(key: "l", command: true), diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 0940ebe6a..d70f86f34 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -469,7 +469,7 @@ struct AppMenuCommands: Commands { Button(String(localized: "Reopen Closed Tab")) { actions?.reopenClosedTab() } - .keyboardShortcut("t", modifiers: [.command, .shift]) + .optionalKeyboardShortcut(shortcut(for: .reopenClosedTab)) .disabled(!(actions?.canReopenClosedTab ?? false)) Divider() diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 6427acc7e..802cbfbbd 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -113,6 +113,7 @@ struct MainEditorContentView: View { databaseType: connection.type, onClose: { id in coordinator.closeInAppTab(id) }, onCloseOthers: { id in coordinator.closeOtherTabs(excluding: id) }, + onCloseTabsToRight: { id in coordinator.closeTabsToRight(of: id) }, onCloseAll: { coordinator.closeAllTabs() }, onReorder: { tabs in coordinator.reorderTabs(tabs) }, onRename: { id, name in coordinator.renameTab(id, to: name) }, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift index da4598c94..9688d33bd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift @@ -158,6 +158,52 @@ extension MainContentCoordinator { persistTabs() } + func closeTabsToRight(of id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + let tabsToClose = Array(tabManager.tabs[(index + 1)...]).filter { !$0.isPinned } + guard !tabsToClose.isEmpty else { return } + + let selectedIsBeingClosed = tabsToClose.contains { $0.id == tabManager.selectedTabId } + 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 to the right have unsaved changes that will be lost."), + window: contentWindow + ) + switch result { + case .save, .dontSave: + if selectedIsBeingClosed { + changeManager.clearChangesAndUndoHistory() + } + forceCloseTabsToRight(of: id) + case .cancel: + return + } + } + return + } + + forceCloseTabsToRight(of: id) + } + + private func forceCloseTabsToRight(of id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + let toClose = Array(tabManager.tabs[(index + 1)...]).filter { !$0.isPinned } + for tab in toClose { + tabManager.pushClosedTab(tab) + tab.rowBuffer.evict() + } + let closeIds = Set(toClose.map(\.id)) + tabManager.tabs.removeAll { closeIds.contains($0.id) } + if let selectedId = tabManager.selectedTabId, closeIds.contains(selectedId) { + tabManager.selectedTabId = id + } + persistTabs() + } + func closeAllTabs() { // Skip pinned tabs — they survive "Close All" let closableTabs = tabManager.tabs.filter { !$0.isPinned } diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift index 8947c352f..577f54570 100644 --- a/TablePro/Views/TabBar/EditorTabBar.swift +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -15,6 +15,7 @@ struct EditorTabBar: View { let databaseType: DatabaseType var onClose: (UUID) -> Void var onCloseOthers: (UUID) -> Void + var onCloseTabsToRight: (UUID) -> Void var onCloseAll: () -> Void var onReorder: ([QueryTab]) -> Void var onRename: (UUID, String) -> Void @@ -82,7 +83,7 @@ struct EditorTabBar: View { onSelect: { selectedTabId = tab.id }, onClose: { onClose(tab.id) }, onCloseOthers: { onCloseOthers(tab.id) }, - onCloseTabsToRight: { closeTabsToRight(of: tab.id) }, + onCloseTabsToRight: { onCloseTabsToRight(tab.id) }, onCloseAll: onCloseAll, onDuplicate: { onDuplicate(tab.id) }, onRename: { name in onRename(tab.id, name) }, @@ -100,14 +101,6 @@ struct EditorTabBar: View { onReorder: onReorder )) } - - private func closeTabsToRight(of id: UUID) { - guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } - let idsToClose = tabs[(index + 1)...].filter { !$0.isPinned }.map(\.id) - for tabId in idsToClose { - onClose(tabId) - } - } } // MARK: - Drag & Drop @@ -130,6 +123,9 @@ private struct TabDropDelegate: DropDelegate { let toIndex = tabs.firstIndex(where: { $0.id == targetId }) else { return } + // Don't allow dragging across the pinned/unpinned boundary + guard tabs[fromIndex].isPinned == tabs[toIndex].isPinned else { return } + var reordered = tabs let moved = reordered.remove(at: fromIndex) reordered.insert(moved, at: toIndex) diff --git a/TablePro/Views/TabBar/EditorTabBarItem.swift b/TablePro/Views/TabBar/EditorTabBarItem.swift index 3678ccd8a..6698bad70 100644 --- a/TablePro/Views/TabBar/EditorTabBarItem.swift +++ b/TablePro/Views/TabBar/EditorTabBarItem.swift @@ -62,6 +62,10 @@ struct EditorTabBarItem: View { } .onChange(of: isEditingFocused) { _, focused in if !focused && isEditing { + let trimmed = editingTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + onRename(trimmed) + } isEditing = false } } @@ -95,6 +99,7 @@ struct EditorTabBarItem: View { } .buttonStyle(.plain) .frame(width: 14, height: 14) + .accessibilityLabel(String(localized: "Close tab")) } else { Color.clear .frame(width: 14, height: 14) @@ -109,6 +114,9 @@ struct EditorTabBarItem: View { .contentShape(Rectangle()) .onHover { isHovering = $0 } .onTapGesture { onSelect() } + .accessibilityElement(children: .combine) + .accessibilityLabel(tab.title) + .accessibilityAddTraits(isSelected ? .isSelected : []) .contextMenu { if tab.tabType == .query { Button(String(localized: "Rename")) { From c8f146e28a4419feb2694a8e911d697c0073a82f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 20:38:50 +0700 Subject: [PATCH 29/36] fix: contentVersion hash collision causing empty DataGrid after query contentVersion used simple addition (resultVersion + metadataVersion + isExecuting?2:0), causing collisions like 0+0+2 == 1+1+0. The TabContentContainerView saw identical versions and skipped rebuilding the rootView, leaving the DataGrid showing only the "#" column. Fix: use prime multipliers (97, 31, 13) to avoid collisions. Also: - Defer query from addTableTabInApp to tab switch Phase 2 - Guard lazy query launch against stale selectedTabId - Add debug logging across query/apply/container pipeline --- TablePro/Models/Query/QueryTab.swift | 9 +++-- TablePro/Resources/Localizable.xcstrings | 15 +++++++ .../Main/Child/TabContentContainerView.swift | 7 +++- .../MainContentCoordinator+Navigation.swift | 7 +++- .../MainContentCoordinator+QueryHelpers.swift | 19 ++++++++- .../MainContentCoordinator+TabSwitch.swift | 23 ++++++++++- .../Views/Main/MainContentCoordinator.swift | 40 ++++++++++++++----- 7 files changed, 103 insertions(+), 17 deletions(-) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 9611321c8..64b0d8f20 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -136,9 +136,12 @@ struct QueryTab: Identifiable, Equatable { /// 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 } + // Use prime multipliers to avoid hash collisions between states. + // Previous formula used simple addition, causing collisions like: + // isExecuting(+2) == resultVersion(1) + metadataVersion(1) + var v = resultVersion &* 97 &+ metadataVersion &* 31 &+ paginationVersion &* 13 + if errorMessage != nil { v = v &+ 7 } + if isExecuting { v = v &+ 3 } return v } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 0f4668711..4fc49d186 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7387,6 +7387,9 @@ } } } + }, + "Close tab" : { + }, "Close Tab" : { "localizations" : { @@ -25619,6 +25622,9 @@ } } } + }, + "Pin Tab" : { + }, "Pink" : { "localizations" : { @@ -28818,6 +28824,9 @@ } } } + }, + "Reopen Closed Tab" : { + }, "Reopen Last Session" : { "localizations" : { @@ -32540,6 +32549,9 @@ }, "Some tabs have unsaved edits. Quitting will discard these changes." : { + }, + "Some tabs to the right have unsaved changes that will be lost." : { + }, "Something went wrong (error %d). Try again in a moment." : { "localizations" : { @@ -37522,6 +37534,9 @@ } } } + }, + "Unpin Tab" : { + }, "Unset" : { "localizations" : { diff --git a/TablePro/Views/Main/Child/TabContentContainerView.swift b/TablePro/Views/Main/Child/TabContentContainerView.swift index 240c2cfd0..4ce16e228 100644 --- a/TablePro/Views/Main/Child/TabContentContainerView.swift +++ b/TablePro/Views/Main/Child/TabContentContainerView.swift @@ -8,8 +8,11 @@ // observation tracking; isHidden only suppresses rendering. // +import os import SwiftUI +private let containerLogger = Logger(subsystem: "com.TablePro", category: "TabContentContainer") + /// 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. @@ -59,7 +62,9 @@ struct TabContentContainerView: NSViewRepresentable { let tab = tabManager.tabs.first(where: { $0.id == activeId }), let hosting = coordinator.hostingViews[activeId] { - if coordinator.builtVersions[activeId] != tab.contentVersion { + let builtVersion = coordinator.builtVersions[activeId] ?? -1 + if builtVersion != tab.contentVersion { + containerLogger.info("[CONTAINER] updateNSView: rebuilding \"\(tab.title, privacy: .public)\" builtVersion=\(builtVersion) → contentVersion=\(tab.contentVersion) resultCols=\(tab.resultColumns.count) rows=\(tab.resultRows.count)") hosting.rootView = contentBuilder(tab) coordinator.builtVersions[activeId] = tab.contentVersion } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index b1c27f2d7..23d329afc 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -169,6 +169,7 @@ extension MainContentCoordinator { isView: Bool = false, showStructure: Bool = false ) { + navigationLogger.info("[TAB-NAV] addTableTabInApp: \"\(tableName, privacy: .public)\" — creating new tab (query deferred to tab switch Phase 2)") tabManager.addTableTab( tableName: tableName, databaseType: connection.type, @@ -186,7 +187,11 @@ extension MainContentCoordinator { } restoreColumnLayoutForTable(tableName) restoreFiltersForTable(tableName) - runQuery() + // Query execution is deferred to scheduleTabSwitch Phase 2, which detects + // needsLazyQuery (rows empty, never executed) and runs the query only when + // the user actually settles on this tab. This prevents the double-query + // pattern where addTableTabInApp starts a query that gets immediately + // cancelled by the next tab's creation bumping queryGeneration. } // MARK: - Preview Tabs diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index cf4de5a3b..63496a9cf 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -9,6 +9,9 @@ import AppKit import Foundation import os + +private let queryHelpersLogger = Logger(subsystem: "com.TablePro", category: "QueryHelpers") +import os import TableProPluginKit // MARK: - Query Execution Helpers @@ -119,7 +122,15 @@ extension MainContentCoordinator { sql: String, connection conn: DatabaseConnection ) { - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + queryHelpersLogger.info("[APPLY] applyPhase1Result: tab not found for id=\(tabId)") + return + } + + let isSelected = tabManager.selectedTabId == tabId + let tabTitle = tabManager.tabs[idx].title + let beforeVersion = tabManager.tabs[idx].contentVersion + queryHelpersLogger.info("[APPLY] applyPhase1Result START: \"\(tabTitle, privacy: .public)\" isSelected=\(isSelected) cols=\(columns.count) rows=\(rows.count) beforeContentVersion=\(beforeVersion)") var updatedTab = tabManager.tabs[idx] updatedTab.resultColumns = columns @@ -185,6 +196,7 @@ extension MainContentCoordinator { toolbarState.isResultsCollapsed = false tabManager.tabs[idx] = updatedTab + queryHelpersLogger.info("[APPLY] applyPhase1Result WROTE: \"\(updatedTab.title, privacy: .public)\" resultVersion=\(updatedTab.resultVersion) contentVersion=\(updatedTab.contentVersion) resultCols=\(updatedTab.resultColumns.count) resultRows=\(updatedTab.resultRows.count)") // Cache column types for selective queries on subsequent page/filter/sort reloads. // Only cache from schema-backed table loads (not arbitrary SELECTs which may have partial columns). @@ -209,12 +221,17 @@ extension MainContentCoordinator { } if tabManager.selectedTabId == tabId { + queryHelpersLogger.info("[APPLY] configureForTable: \"\(tableName ?? "?", privacy: .public)\" cols=\(columns.count) pks=\(resolvedPKs.count) → triggers reloadVersion bump") changeManager.configureForTable( tableName: tableName ?? "", columns: columns, primaryKeyColumns: resolvedPKs, databaseType: conn.type ) + } else { + let selId = String(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") + let tId = String(tabId.uuidString.prefix(8)) + queryHelpersLogger.info("[APPLY] skipping configureForTable — tab \"\(updatedTab.title, privacy: .public)\" not selected (sel=\(selId, privacy: .public) tab=\(tId, privacy: .public))") } QueryHistoryManager.shared.recordQuery( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index c1f6525e4..153f6ea36 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -9,6 +9,8 @@ import Foundation import os +private let switchLogger = Logger(subsystem: "com.TablePro", category: "TabSwitch") + extension MainContentCoordinator { /// Schedule a tab switch. Phase 1 (synchronous): MRU tracking only. /// Phase 2 (deferred Task): save outgoing state, restore incoming @@ -47,11 +49,17 @@ extension MainContentCoordinator { // Phase 2: Deferred — restore incoming state + lazy query. // During rapid Cmd+1/2/3, only the LAST switch's Phase 2 executes. + let hadPreviousSwitchTask = tabSwitchTask != nil tabSwitchTask?.cancel() let capturedNewId = newTabId + let fromTitle = oldTabId.flatMap { id in tabManager.tabs.first { $0.id == id }?.title } ?? "nil" + let toTitle = newTabId.flatMap { id in tabManager.tabs.first { $0.id == id }?.title } ?? "nil" + switchLogger.info("[TAB-SWITCH] scheduleTabSwitch: \"\(fromTitle, privacy: .public)\" → \"\(toTitle, privacy: .public)\" cancelledPrevPhase2=\(hadPreviousSwitchTask)") tabSwitchTask = Task { @MainActor [weak self] in - guard let self, !Task.isCancelled else { return } - guard !Task.isCancelled else { return } + guard let self, !Task.isCancelled else { + switchLogger.info("[TAB-SWITCH] Phase 2 cancelled before start for \"\(toTitle, privacy: .public)\"") + return + } // Restore incoming tab shared state. guard let newId = capturedNewId, @@ -112,6 +120,7 @@ extension MainContentCoordinator { // Clear stale isExecuting flag if newTab.isExecuting && newTab.resultRows.isEmpty && newTab.lastExecutedAt == nil { + switchLogger.info("[TAB-SWITCH] clearing stale isExecuting for \"\(newTab.title, privacy: .public)\"") if let idx = self.tabManager.tabs.firstIndex(where: { $0.id == newId }), self.tabManager.tabs[idx].isExecuting { self.tabManager.tabs[idx].isExecuting = false @@ -125,10 +134,20 @@ extension MainContentCoordinator { && newTab.errorMessage == nil && !newTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + switchLogger.info("[TAB-SWITCH] Phase 2 \"\(newTab.title, privacy: .public)\": isExecuting=\(newTab.isExecuting) rows=\(newTab.resultRows.count) evicted=\(isEvicted) lastExec=\(newTab.lastExecutedAt?.description ?? "nil", privacy: .public) error=\(newTab.errorMessage ?? "nil", privacy: .public) needsLazy=\(needsLazyQuery)") + if needsLazyQuery { + // Only launch the query if this tab is still selected — during rapid switching + // the user may have already moved to another tab. + guard self.tabManager.selectedTabId == newId else { + switchLogger.info("[TAB-SWITCH] → skipping lazy query for \"\(newTab.title, privacy: .public)\" (no longer selected)") + return + } if let session = DatabaseManager.shared.session(for: self.connectionId), session.isConnected { + switchLogger.info("[TAB-SWITCH] → launching lazy query for \"\(newTab.title, privacy: .public)\"") self.executeTableTabQueryDirectly() } else { + switchLogger.info("[TAB-SWITCH] → not connected, deferring lazy load for \"\(newTab.title, privacy: .public)\"") self.changeManager.reloadVersion += 1 self.needsLazyLoad = true } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1522c0b59..d077e5adf 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -714,11 +714,22 @@ final class MainContentCoordinator { /// Table tab queries are always app-generated SELECTs, so they skip dangerous-query /// checks but still respect safe mode levels that apply to all queries. func executeTableTabQueryDirectly() { - guard let index = tabManager.selectedTabIndex else { return } - guard !tabManager.tabs[index].isExecuting else { return } + guard let index = tabManager.selectedTabIndex else { + Self.logger.info("[QUERY] executeTableTabQueryDirectly: no selectedTabIndex") + return + } + let directTab = tabManager.tabs[index] + guard !directTab.isExecuting else { + Self.logger.info("[QUERY] executeTableTabQueryDirectly: tab \"\(directTab.title, privacy: .public)\" already executing, skipping") + return + } - let sql = tabManager.tabs[index].query - guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let sql = directTab.query + guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + Self.logger.info("[QUERY] executeTableTabQueryDirectly: tab \"\(directTab.title, privacy: .public)\" empty query, skipping") + return + } + Self.logger.info("[QUERY] executeTableTabQueryDirectly: tab \"\(directTab.title, privacy: .public)\" rows=\(directTab.resultRows.count) lastExec=\(directTab.lastExecutedAt?.description ?? "nil", privacy: .public)") let level = safeModeLevel if level.appliesToAllQueries && level.requiresConfirmation, @@ -880,12 +891,21 @@ final class MainContentCoordinator { private func executeQueryInternal( _ sql: String ) { - guard let index = tabManager.selectedTabIndex else { return } - guard !tabManager.tabs[index].isExecuting else { return } + guard let index = tabManager.selectedTabIndex else { + Self.logger.info("[QUERY] executeQueryInternal: no selectedTabIndex, skipping") + return + } + let currentTab = tabManager.tabs[index] + guard !currentTab.isExecuting else { + Self.logger.info("[QUERY] executeQueryInternal: tab \"\(currentTab.title, privacy: .public)\" already executing, skipping") + return + } + let hadPreviousTask = currentQueryTask != nil currentQueryTask?.cancel() queryGeneration += 1 let capturedGeneration = queryGeneration + Self.logger.info("[QUERY] executeQueryInternal: tab \"\(currentTab.title, privacy: .public)\" gen=\(capturedGeneration) cancelledPrevious=\(hadPreviousTask) sql=\(String(sql.prefix(80)), privacy: .public)") // Batch mutations into a single array write to avoid multiple @Published // notifications — each notification triggers a full SwiftUI update cycle. @@ -1013,12 +1033,15 @@ final class MainContentCoordinator { // Always reset isExecuting even if generation is stale if capturedGeneration != queryGeneration || Task.isCancelled { + let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.title ?? "?" + Self.logger.info("[QUERY] generation stale or cancelled: tab \"\(tabTitle, privacy: .public)\" captured=\(capturedGeneration) current=\(queryGeneration) cancelled=\(Task.isCancelled) — clearing isExecuting, discarding results") if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].isExecuting = false } return } + Self.logger.info("[QUERY] applyPhase1Result: tabId=\(tabId) gen=\(capturedGeneration) rows=\(safeRows.count) cols=\(safeColumns.count)") applyPhase1Result( tabId: tabId, columns: safeColumns, @@ -1064,9 +1087,8 @@ final class MainContentCoordinator { } } } catch { - // Always reset isExecuting even if generation is stale — - // skipping this leaves the tab permanently stuck in "executing" - // state, requiring a reconnect to recover. + let tabTitle = await MainActor.run { tabManager.tabs.first(where: { $0.id == tabId })?.title ?? "?" } + Self.logger.info("[QUERY] error for tab \"\(tabTitle, privacy: .public)\" gen=\(capturedGeneration): \(error.localizedDescription, privacy: .public)") await MainActor.run { [weak self] in guard let self else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { From 96d0e9f1f2632a8270dffbdb8ff4f3f1bd03b919 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 20:50:58 +0700 Subject: [PATCH 30/36] fix: route deeplinks and Handoff to in-app tabs instead of new windows When a connection is already open, deeplinks (tablepro://), Handoff, and database URL schemes (mysql://, postgres://) now add content as in-app tabs via the existing coordinator instead of creating duplicate NSWindows. Falls back to openNativeTab only when no coordinator exists. Add routeToExistingWindow helper that dispatches to openTableTab or tabManager.addTab based on payload type, then brings the window to front. --- TablePro/AppDelegate+ConnectionHandler.swift | 4 +- TablePro/AppDelegate+FileOpen.swift | 59 ++++++++++++++++---- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index f54617817..3d1066fea 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -350,7 +350,9 @@ extension AppDelegate { tableName: tableName, isView: parsed.isView ) - WindowOpener.shared.openNativeTab(payload) + if !routeToExistingWindow(connectionId: connectionId, payload: payload) { + WindowOpener.shared.openNativeTab(payload) + } if parsed.filterColumn != nil || parsed.filterCondition != nil { await waitForNotification(.refreshData, timeout: .seconds(3)) diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 2c24d3554..63d20d88a 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -32,19 +32,20 @@ extension AppDelegate { let tableName = activity.userInfo?["tableName"] as? String + // Already connected — route to existing window's in-app tab bar if DatabaseManager.shared.activeSessions[connectionId]?.driver != nil { if let tableName { let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName) - WindowOpener.shared.openNativeTab(payload) - } else { - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - return + if !routeToExistingWindow(connectionId: connectionId, payload: payload) { + WindowOpener.shared.openNativeTab(payload) } + } else { + bringConnectionWindowToFront(connectionId) } return } + // Not connected — create window, connect, then route content as in-app tab let initialPayload = EditorTabPayload(connectionId: connectionId) WindowOpener.shared.openNativeTab(initialPayload) @@ -56,7 +57,9 @@ extension AppDelegate { } if let tableName { let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName) - WindowOpener.shared.openNativeTab(payload) + if !routeToExistingWindow(connectionId: connectionId, payload: payload) { + WindowOpener.shared.openNativeTab(payload) + } } } catch { fileOpenLogger.error("Handoff connect failed: \(error.localizedDescription)") @@ -155,6 +158,36 @@ extension AppDelegate { } } + // MARK: - In-App Tab Routing + + /// Route content to an existing connection window's in-app tab bar when possible. + /// Returns true if the content was routed to an existing window. + /// Falls back gracefully (returns false) when no coordinator exists for the connection. + @discardableResult + func routeToExistingWindow( + connectionId: UUID, + payload: EditorTabPayload + ) -> Bool { + guard let coordinator = MainContentCoordinator.firstCoordinator(for: connectionId) else { + return false + } + switch payload.tabType { + case .table: + if let tableName = payload.tableName { + coordinator.openTableTab(tableName, showStructure: payload.showStructure, isView: payload.isView) + } + case .query: + coordinator.tabManager.addTab( + initialQuery: payload.initialQuery, + databaseName: payload.databaseName ?? coordinator.connection.database + ) + default: + coordinator.addNewQueryTab() + } + coordinator.contentWindow?.makeKeyAndOrderFront(nil) + return true + } + // MARK: - Welcome Window Suppression func suppressWelcomeWindow() { @@ -225,14 +258,14 @@ extension AppDelegate { return } + // Already connected — route to existing window's in-app tab bar if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { if let payload = makePayload?(connection.id) { - WindowOpener.shared.openNativeTab(payload) - } else { - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - return + if !routeToExistingWindow(connectionId: connection.id, payload: payload) { + WindowOpener.shared.openNativeTab(payload) } + } else { + bringConnectionWindowToFront(connection.id) } return } @@ -266,7 +299,9 @@ extension AppDelegate { window.close() } if let payload = makePayload?(connection.id) { - WindowOpener.shared.openNativeTab(payload) + if !self.routeToExistingWindow(connectionId: connection.id, payload: payload) { + WindowOpener.shared.openNativeTab(payload) + } } } catch { fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)") From bffcdbd9869084f2b662bc99e8b26ccd6024abfd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 21:17:25 +0700 Subject: [PATCH 31/36] fix: prevent duplicate windows on cold launch via deeplink - Suppress auto-reconnect when app is launched by URL/deeplink - Defer auto-reconnect to next run loop so URL handler can set flag - Add connectingURLConnectionIds guard to connectViaDeeplink - Check pendingPayloads for race with auto-reconnect window creation - Fix closeRestoredMainWindows killing deeplink window --- TablePro/AppDelegate+FileOpen.swift | 53 ++++++++++++++++++++++++++--- TablePro/AppDelegate.swift | 39 ++++++++++++--------- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 63d20d88a..379725106 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -45,8 +45,15 @@ extension AppDelegate { return } + // Window already pending (e.g., auto-reconnect in progress) — just bring to front + let hasPending = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connectionId } + if hasPending { + bringConnectionWindowToFront(connectionId) + return + } + // Not connected — create window, connect, then route content as in-app tab - let initialPayload = EditorTabPayload(connectionId: connectionId) + let initialPayload = EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault) WindowOpener.shared.openNativeTab(initialPayload) Task { @MainActor in @@ -89,6 +96,10 @@ extension AppDelegate { // MARK: - Main Dispatch func handleOpenURLs(_ urls: [URL]) { + // Suppress auto-reconnect when the app is launched by a URL — the URL handler + // will create the appropriate window. This prevents duplicate windows on cold launch. + suppressAutoReconnect = true + let deeplinks = urls.filter { $0.scheme == "tablepro" } if !deeplinks.isEmpty { Task { @MainActor in @@ -249,7 +260,7 @@ extension AppDelegate { makePayload: (@Sendable (UUID) -> EditorTabPayload)? = nil ) { guard let connection = DeeplinkHandler.resolveConnection(named: connectionName) else { - fileOpenLogger.error("Deep link: no connection named '\(connectionName, privacy: .public)'") + fileOpenLogger.error("[DEEPLINK] no connection named '\(connectionName, privacy: .public)'") AlertHelper.showErrorSheet( title: String(localized: "Connection Not Found"), message: String(format: String(localized: "No saved connection named \"%@\"."), connectionName), @@ -258,10 +269,28 @@ extension AppDelegate { return } + let session = DatabaseManager.shared.activeSessions[connection.id] + let hasDriver = session?.driver != nil + let isConnected = session?.isConnected ?? false + let hasCoordinator = MainContentCoordinator.firstCoordinator(for: connection.id) != nil + let windowCount = WindowLifecycleMonitor.shared.windows(for: connection.id).count + fileOpenLogger.info("[DEEPLINK] connectViaDeeplink: name=\"\(connectionName, privacy: .public)\" connId=\(connection.id) hasSession=\(session != nil) hasDriver=\(hasDriver) isConnected=\(isConnected) hasCoordinator=\(hasCoordinator) windows=\(windowCount) hasPayload=\(makePayload != nil)") + + // Prevent duplicate connections from rapid deeplink invocations + let hasPendingWindow = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connection.id } + let isAlreadyConnecting = connectingURLConnectionIds.contains(connection.id) + guard !isAlreadyConnecting, !hasPendingWindow else { + fileOpenLogger.info("[DEEPLINK] → skipping duplicate (connecting=\(isAlreadyConnecting) pending=\(hasPendingWindow))") + bringConnectionWindowToFront(connection.id) + return + } + // Already connected — route to existing window's in-app tab bar - if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { + if hasDriver { + fileOpenLogger.info("[DEEPLINK] → already connected, routing to existing window") if let payload = makePayload?(connection.id) { if !routeToExistingWindow(connectionId: connection.id, payload: payload) { + fileOpenLogger.info("[DEEPLINK] → no coordinator found, falling back to openNativeTab") WindowOpener.shared.openNativeTab(payload) } } else { @@ -270,15 +299,26 @@ extension AppDelegate { return } + // Has coordinator but no driver — window exists, connection may be in progress + if hasCoordinator { + fileOpenLogger.info("[DEEPLINK] → coordinator exists but no driver, bringing window to front") + bringConnectionWindowToFront(connection.id) + return + } + + fileOpenLogger.info("[DEEPLINK] → not connected, creating new window") let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs { NSWindow.allowsAutomaticWindowTabbing = false } - let deeplinkPayload = EditorTabPayload(connectionId: connection.id) + connectingURLConnectionIds.insert(connection.id) + + let deeplinkPayload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) WindowOpener.shared.openNativeTab(deeplinkPayload) Task { @MainActor in + defer { self.connectingURLConnectionIds.remove(connection.id) } do { // Confirm pre-connect script if present (deep links are external, so always confirm) if let script = connection.preConnectScript, @@ -294,17 +334,20 @@ extension AppDelegate { guard confirmed else { return } } + fileOpenLogger.info("[DEEPLINK] connecting to \"\(connectionName, privacy: .public)\"...") try await DatabaseManager.shared.connectToSession(connection) + fileOpenLogger.info("[DEEPLINK] connected successfully to \"\(connectionName, privacy: .public)\"") for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } if let payload = makePayload?(connection.id) { if !self.routeToExistingWindow(connectionId: connection.id, payload: payload) { + fileOpenLogger.info("[DEEPLINK] post-connect: no coordinator, falling back to openNativeTab") WindowOpener.shared.openNativeTab(payload) } } } catch { - fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)") + fileOpenLogger.error("[DEEPLINK] connect failed for \"\(connectionName, privacy: .public)\": \(error.localizedDescription, privacy: .public)") await self.handleConnectionFailure(error) } } diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 7b7cef60d..ba897bf9e 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -47,6 +47,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// True while auto-reconnect is in progress at startup var isAutoReconnecting = false + /// Set by deeplink/URL handlers in application(_:open:) to suppress auto-reconnect. + /// Checked by the deferred auto-reconnect in applicationDidFinishLaunching. + var suppressAutoReconnect = false + /// ConnectionIds currently being connected from URL handlers. /// Prevents duplicate connections when the same URL is opened twice rapidly. var connectingURLConnectionIds = Set() @@ -113,25 +117,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { let settings = AppSettingsStorage.shared.loadGeneral() if settings.startupBehavior == .reopenLast { - let connectionIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() - if !connectionIds.isEmpty { - closeWelcomeWindowEagerly() - attemptAutoReconnectAll(connectionIds: connectionIds) - } else if let lastConnectionId = AppSettingsStorage.shared.loadLastConnectionId() { - // Backward compat: fall back to single lastConnectionId for upgrades - closeWelcomeWindowEagerly() - attemptAutoReconnect(connectionId: lastConnectionId) - } else { - // Crash recovery: if the app crashed before applicationWillTerminate - // could save the list, scan the TabState directory for connections - // that still have saved tab state on disk. - Task { @MainActor [weak self] in + // Defer auto-reconnect to next run loop so application(_:open:) can set + // suppressAutoReconnect when the app is launched by a deeplink/URL. + // Without this, both auto-reconnect and the deeplink create windows + // for the same connection, causing duplicates. + Task { @MainActor [weak self] in + guard let self, !self.suppressAutoReconnect else { + return + } + let connectionIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() + if !connectionIds.isEmpty { + self.closeWelcomeWindowEagerly() + self.attemptAutoReconnectAll(connectionIds: connectionIds) + } else if let lastConnectionId = AppSettingsStorage.shared.loadLastConnectionId() { + self.closeWelcomeWindowEagerly() + self.attemptAutoReconnect(connectionId: lastConnectionId) + } else { let diskIds = await TabDiskActor.shared.connectionIdsWithSavedState() if !diskIds.isEmpty { - self?.closeWelcomeWindowEagerly() - self?.attemptAutoReconnectAll(connectionIds: diskIds) + self.closeWelcomeWindowEagerly() + self.attemptAutoReconnectAll(connectionIds: diskIds) } else { - self?.closeRestoredMainWindows() + self.closeRestoredMainWindows() } } } From e07733a4fa7c7070dd809d67300cb979e752100f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 21:22:16 +0700 Subject: [PATCH 32/36] refactor: clean up debug logging and extract AppDelegate helpers - Remove ~35 [DEEPLINK], [TAB-NAV], [TAB-SWITCH], [QUERY], [APPLY], [CONTAINER] debug log statements added during tab bar refactor - Extract closeAllWelcomeWindows() helper (replaces 8 inline patterns) - Merge attemptAutoReconnectAll + attemptAutoReconnect into single method - Fix closeRestoredMainWindows: remove unnecessary DispatchQueue.main.async - Clean connectViaDeeplink guard chain (remove redundant variables) - Add timing comment to suppressAutoReconnect mechanism Net reduction: -178 lines --- TablePro/AppDelegate+ConnectionHandler.swift | 22 ++++--- TablePro/AppDelegate+FileOpen.swift | 51 ++++++---------- TablePro/AppDelegate+WindowConfig.swift | 60 ++----------------- TablePro/AppDelegate.swift | 6 +- .../Main/Child/TabContentContainerView.swift | 4 -- .../MainContentCoordinator+Navigation.swift | 1 - .../MainContentCoordinator+QueryHelpers.swift | 19 +----- .../MainContentCoordinator+TabSwitch.swift | 22 +------ .../Views/Main/MainContentCoordinator.swift | 37 ++---------- 9 files changed, 45 insertions(+), 177 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 3d1066fea..a27ff9920 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -92,9 +92,7 @@ extension AppDelegate { do { try await DatabaseManager.shared.connectToSession(connection) self.openNewConnectionWindow(for: connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() self.handlePostConnectionActions(parsed, connectionId: connection.id) } catch { connectionLogger.error("Database URL connect failed: \(error.localizedDescription)") @@ -143,9 +141,7 @@ extension AppDelegate { do { try await DatabaseManager.shared.connectToSession(connection) self.openNewConnectionWindow(for: connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() } catch { connectionLogger.error("SQLite file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)") await self.handleConnectionFailure(error) @@ -193,9 +189,7 @@ extension AppDelegate { do { try await DatabaseManager.shared.connectToSession(connection) self.openNewConnectionWindow(for: connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() } catch { connectionLogger.error("DuckDB file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)") await self.handleConnectionFailure(error) @@ -243,9 +237,7 @@ extension AppDelegate { do { try await DatabaseManager.shared.connectToSession(connection) self.openNewConnectionWindow(for: connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() } catch { connectionLogger.error("File open failed for '\(filePath, privacy: .public)' (\(dbType.rawValue)): \(error.localizedDescription)") await self.handleConnectionFailure(error) @@ -477,6 +469,12 @@ extension AppDelegate { return "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)\(rdb)" } + func closeAllWelcomeWindows() { + for window in NSApp.windows where isWelcomeWindow(window) { + window.close() + } + } + func bringConnectionWindowToFront(_ connectionId: UUID) { let windows = WindowLifecycleMonitor.shared.windows(for: connectionId) if let window = windows.first { diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 379725106..88ec6b49f 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -59,9 +59,7 @@ extension AppDelegate { Task { @MainActor in do { try await DatabaseManager.shared.connectToSession(connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() if let tableName { let payload = EditorTabPayload(connectionId: connectionId, tabType: .table, tableName: tableName) if !routeToExistingWindow(connectionId: connectionId, payload: payload) { @@ -96,8 +94,9 @@ extension AppDelegate { // MARK: - Main Dispatch func handleOpenURLs(_ urls: [URL]) { - // Suppress auto-reconnect when the app is launched by a URL — the URL handler - // will create the appropriate window. This prevents duplicate windows on cold launch. + // application(_:open:) fires in the same run loop pass as applicationDidFinishLaunching + // on cold launch from URL. The deferred auto-reconnect Task yields to the next run loop, + // so this flag is guaranteed to be set before the Task checks it. suppressAutoReconnect = true let deeplinks = urls.filter { $0.scheme == "tablepro" } @@ -157,9 +156,7 @@ extension AppDelegate { for window in NSApp.windows where isMainWindow(window) { window.makeKeyAndOrderFront(nil) } - for window in NSApp.windows where isWelcomeWindow(window) { - window.close() - } + closeAllWelcomeWindows() NotificationCenter.default.post(name: .openSQLFiles, object: sqlFiles) endFileOpenSuppression() } else { @@ -260,7 +257,7 @@ extension AppDelegate { makePayload: (@Sendable (UUID) -> EditorTabPayload)? = nil ) { guard let connection = DeeplinkHandler.resolveConnection(named: connectionName) else { - fileOpenLogger.error("[DEEPLINK] no connection named '\(connectionName, privacy: .public)'") + fileOpenLogger.error("No connection named '\(connectionName, privacy: .public)'") AlertHelper.showErrorSheet( title: String(localized: "Connection Not Found"), message: String(format: String(localized: "No saved connection named \"%@\"."), connectionName), @@ -269,28 +266,13 @@ extension AppDelegate { return } - let session = DatabaseManager.shared.activeSessions[connection.id] - let hasDriver = session?.driver != nil - let isConnected = session?.isConnected ?? false + let hasDriver = DatabaseManager.shared.activeSessions[connection.id]?.driver != nil let hasCoordinator = MainContentCoordinator.firstCoordinator(for: connection.id) != nil - let windowCount = WindowLifecycleMonitor.shared.windows(for: connection.id).count - fileOpenLogger.info("[DEEPLINK] connectViaDeeplink: name=\"\(connectionName, privacy: .public)\" connId=\(connection.id) hasSession=\(session != nil) hasDriver=\(hasDriver) isConnected=\(isConnected) hasCoordinator=\(hasCoordinator) windows=\(windowCount) hasPayload=\(makePayload != nil)") - - // Prevent duplicate connections from rapid deeplink invocations - let hasPendingWindow = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connection.id } - let isAlreadyConnecting = connectingURLConnectionIds.contains(connection.id) - guard !isAlreadyConnecting, !hasPendingWindow else { - fileOpenLogger.info("[DEEPLINK] → skipping duplicate (connecting=\(isAlreadyConnecting) pending=\(hasPendingWindow))") - bringConnectionWindowToFront(connection.id) - return - } // Already connected — route to existing window's in-app tab bar if hasDriver { - fileOpenLogger.info("[DEEPLINK] → already connected, routing to existing window") if let payload = makePayload?(connection.id) { if !routeToExistingWindow(connectionId: connection.id, payload: payload) { - fileOpenLogger.info("[DEEPLINK] → no coordinator found, falling back to openNativeTab") WindowOpener.shared.openNativeTab(payload) } } else { @@ -299,14 +281,20 @@ extension AppDelegate { return } + // Prevent duplicate connections from rapid deeplink invocations + let hasPendingWindow = WindowOpener.shared.pendingPayloads.contains { $0.connectionId == connection.id } + let isAlreadyConnecting = connectingURLConnectionIds.contains(connection.id) + guard !isAlreadyConnecting, !hasPendingWindow else { + bringConnectionWindowToFront(connection.id) + return + } + // Has coordinator but no driver — window exists, connection may be in progress if hasCoordinator { - fileOpenLogger.info("[DEEPLINK] → coordinator exists but no driver, bringing window to front") bringConnectionWindowToFront(connection.id) return } - fileOpenLogger.info("[DEEPLINK] → not connected, creating new window") let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } if hadExistingMain && !AppSettingsManager.shared.tabs.groupAllConnectionTabs { NSWindow.allowsAutomaticWindowTabbing = false @@ -334,20 +322,15 @@ extension AppDelegate { guard confirmed else { return } } - fileOpenLogger.info("[DEEPLINK] connecting to \"\(connectionName, privacy: .public)\"...") try await DatabaseManager.shared.connectToSession(connection) - fileOpenLogger.info("[DEEPLINK] connected successfully to \"\(connectionName, privacy: .public)\"") - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() if let payload = makePayload?(connection.id) { if !self.routeToExistingWindow(connectionId: connection.id, payload: payload) { - fileOpenLogger.info("[DEEPLINK] post-connect: no coordinator, falling back to openNativeTab") WindowOpener.shared.openNativeTab(payload) } } } catch { - fileOpenLogger.error("[DEEPLINK] connect failed for \"\(connectionName, privacy: .public)\": \(error.localizedDescription, privacy: .public)") + fileOpenLogger.error("Deeplink connect failed for \"\(connectionName, privacy: .public)\": \(error.localizedDescription, privacy: .public)") await self.handleConnectionFailure(error) } } diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 1de812485..12b3cb836 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -89,9 +89,7 @@ extension AppDelegate { do { try await DatabaseManager.shared.connectToSession(connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() } catch { windowLogger.error("Dock connection failed for '\(connection.name)': \(error.localizedDescription)") @@ -334,7 +332,7 @@ extension AppDelegate { // MARK: - Auto-Reconnect - func attemptAutoReconnectAll(connectionIds: [UUID]) { + func attemptAutoReconnect(connectionIds: [UUID]) { let connections = ConnectionStorage.shared.loadConnections() let validConnections = connectionIds.compactMap { id in connections.first { $0.id == id } @@ -366,6 +364,7 @@ extension AppDelegate { } continue } catch { + windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { window.close() } @@ -373,9 +372,7 @@ extension AppDelegate { } } - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } + self.closeAllWelcomeWindows() // If all connections failed, show the welcome window if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) { @@ -384,54 +381,9 @@ extension AppDelegate { } } - func attemptAutoReconnect(connectionId: UUID) { - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { - AppSettingsStorage.shared.saveLastConnectionId(nil) - closeRestoredMainWindows() - openWelcomeWindow() - return - } - - isAutoReconnecting = true - - Task { @MainActor [weak self] in - guard let self else { return } - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowOpener.shared.openNativeTab(payload) - - defer { self.isAutoReconnecting = false } - do { - try await DatabaseManager.shared.connectToSession(connection) - - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - } catch is CancellationError { - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) { - self.openWelcomeWindow() - } - } catch { - windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") - - for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { - window.close() - } - if !NSApp.windows.contains(where: { self.isMainWindow($0) && $0.isVisible }) { - self.openWelcomeWindow() - } - } - } - } - func closeRestoredMainWindows() { - DispatchQueue.main.async { [weak self] in - for window in NSApp.windows where self?.isMainWindow(window) == true { - window.close() - } + for window in NSApp.windows where isMainWindow(window) { + window.close() } } } diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index ba897bf9e..8b1607a52 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -128,15 +128,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { let connectionIds = AppSettingsStorage.shared.loadLastOpenConnectionIds() if !connectionIds.isEmpty { self.closeWelcomeWindowEagerly() - self.attemptAutoReconnectAll(connectionIds: connectionIds) + self.attemptAutoReconnect(connectionIds: connectionIds) } else if let lastConnectionId = AppSettingsStorage.shared.loadLastConnectionId() { self.closeWelcomeWindowEagerly() - self.attemptAutoReconnect(connectionId: lastConnectionId) + self.attemptAutoReconnect(connectionIds: [lastConnectionId]) } else { let diskIds = await TabDiskActor.shared.connectionIdsWithSavedState() if !diskIds.isEmpty { self.closeWelcomeWindowEagerly() - self.attemptAutoReconnectAll(connectionIds: diskIds) + self.attemptAutoReconnect(connectionIds: diskIds) } else { self.closeRestoredMainWindows() } diff --git a/TablePro/Views/Main/Child/TabContentContainerView.swift b/TablePro/Views/Main/Child/TabContentContainerView.swift index 4ce16e228..cc533851d 100644 --- a/TablePro/Views/Main/Child/TabContentContainerView.swift +++ b/TablePro/Views/Main/Child/TabContentContainerView.swift @@ -8,11 +8,8 @@ // observation tracking; isHidden only suppresses rendering. // -import os import SwiftUI -private let containerLogger = Logger(subsystem: "com.TablePro", category: "TabContentContainer") - /// 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. @@ -64,7 +61,6 @@ struct TabContentContainerView: NSViewRepresentable { { let builtVersion = coordinator.builtVersions[activeId] ?? -1 if builtVersion != tab.contentVersion { - containerLogger.info("[CONTAINER] updateNSView: rebuilding \"\(tab.title, privacy: .public)\" builtVersion=\(builtVersion) → contentVersion=\(tab.contentVersion) resultCols=\(tab.resultColumns.count) rows=\(tab.resultRows.count)") hosting.rootView = contentBuilder(tab) coordinator.builtVersions[activeId] = tab.contentVersion } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 23d329afc..e0332844a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -169,7 +169,6 @@ extension MainContentCoordinator { isView: Bool = false, showStructure: Bool = false ) { - navigationLogger.info("[TAB-NAV] addTableTabInApp: \"\(tableName, privacy: .public)\" — creating new tab (query deferred to tab switch Phase 2)") tabManager.addTableTab( tableName: tableName, databaseType: connection.type, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 63496a9cf..cf4de5a3b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -9,9 +9,6 @@ import AppKit import Foundation import os - -private let queryHelpersLogger = Logger(subsystem: "com.TablePro", category: "QueryHelpers") -import os import TableProPluginKit // MARK: - Query Execution Helpers @@ -122,15 +119,7 @@ extension MainContentCoordinator { sql: String, connection conn: DatabaseConnection ) { - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - queryHelpersLogger.info("[APPLY] applyPhase1Result: tab not found for id=\(tabId)") - return - } - - let isSelected = tabManager.selectedTabId == tabId - let tabTitle = tabManager.tabs[idx].title - let beforeVersion = tabManager.tabs[idx].contentVersion - queryHelpersLogger.info("[APPLY] applyPhase1Result START: \"\(tabTitle, privacy: .public)\" isSelected=\(isSelected) cols=\(columns.count) rows=\(rows.count) beforeContentVersion=\(beforeVersion)") + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } var updatedTab = tabManager.tabs[idx] updatedTab.resultColumns = columns @@ -196,7 +185,6 @@ extension MainContentCoordinator { toolbarState.isResultsCollapsed = false tabManager.tabs[idx] = updatedTab - queryHelpersLogger.info("[APPLY] applyPhase1Result WROTE: \"\(updatedTab.title, privacy: .public)\" resultVersion=\(updatedTab.resultVersion) contentVersion=\(updatedTab.contentVersion) resultCols=\(updatedTab.resultColumns.count) resultRows=\(updatedTab.resultRows.count)") // Cache column types for selective queries on subsequent page/filter/sort reloads. // Only cache from schema-backed table loads (not arbitrary SELECTs which may have partial columns). @@ -221,17 +209,12 @@ extension MainContentCoordinator { } if tabManager.selectedTabId == tabId { - queryHelpersLogger.info("[APPLY] configureForTable: \"\(tableName ?? "?", privacy: .public)\" cols=\(columns.count) pks=\(resolvedPKs.count) → triggers reloadVersion bump") changeManager.configureForTable( tableName: tableName ?? "", columns: columns, primaryKeyColumns: resolvedPKs, databaseType: conn.type ) - } else { - let selId = String(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") - let tId = String(tabId.uuidString.prefix(8)) - queryHelpersLogger.info("[APPLY] skipping configureForTable — tab \"\(updatedTab.title, privacy: .public)\" not selected (sel=\(selId, privacy: .public) tab=\(tId, privacy: .public))") } QueryHistoryManager.shared.recordQuery( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 153f6ea36..70cadc678 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -7,9 +7,6 @@ // import Foundation -import os - -private let switchLogger = Logger(subsystem: "com.TablePro", category: "TabSwitch") extension MainContentCoordinator { /// Schedule a tab switch. Phase 1 (synchronous): MRU tracking only. @@ -49,17 +46,10 @@ extension MainContentCoordinator { // Phase 2: Deferred — restore incoming state + lazy query. // During rapid Cmd+1/2/3, only the LAST switch's Phase 2 executes. - let hadPreviousSwitchTask = tabSwitchTask != nil tabSwitchTask?.cancel() let capturedNewId = newTabId - let fromTitle = oldTabId.flatMap { id in tabManager.tabs.first { $0.id == id }?.title } ?? "nil" - let toTitle = newTabId.flatMap { id in tabManager.tabs.first { $0.id == id }?.title } ?? "nil" - switchLogger.info("[TAB-SWITCH] scheduleTabSwitch: \"\(fromTitle, privacy: .public)\" → \"\(toTitle, privacy: .public)\" cancelledPrevPhase2=\(hadPreviousSwitchTask)") tabSwitchTask = Task { @MainActor [weak self] in - guard let self, !Task.isCancelled else { - switchLogger.info("[TAB-SWITCH] Phase 2 cancelled before start for \"\(toTitle, privacy: .public)\"") - return - } + guard let self, !Task.isCancelled else { return } // Restore incoming tab shared state. guard let newId = capturedNewId, @@ -120,7 +110,6 @@ extension MainContentCoordinator { // Clear stale isExecuting flag if newTab.isExecuting && newTab.resultRows.isEmpty && newTab.lastExecutedAt == nil { - switchLogger.info("[TAB-SWITCH] clearing stale isExecuting for \"\(newTab.title, privacy: .public)\"") if let idx = self.tabManager.tabs.firstIndex(where: { $0.id == newId }), self.tabManager.tabs[idx].isExecuting { self.tabManager.tabs[idx].isExecuting = false @@ -134,20 +123,13 @@ extension MainContentCoordinator { && newTab.errorMessage == nil && !newTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - switchLogger.info("[TAB-SWITCH] Phase 2 \"\(newTab.title, privacy: .public)\": isExecuting=\(newTab.isExecuting) rows=\(newTab.resultRows.count) evicted=\(isEvicted) lastExec=\(newTab.lastExecutedAt?.description ?? "nil", privacy: .public) error=\(newTab.errorMessage ?? "nil", privacy: .public) needsLazy=\(needsLazyQuery)") - if needsLazyQuery { // Only launch the query if this tab is still selected — during rapid switching // the user may have already moved to another tab. - guard self.tabManager.selectedTabId == newId else { - switchLogger.info("[TAB-SWITCH] → skipping lazy query for \"\(newTab.title, privacy: .public)\" (no longer selected)") - return - } + guard self.tabManager.selectedTabId == newId else { return } if let session = DatabaseManager.shared.session(for: self.connectionId), session.isConnected { - switchLogger.info("[TAB-SWITCH] → launching lazy query for \"\(newTab.title, privacy: .public)\"") self.executeTableTabQueryDirectly() } else { - switchLogger.info("[TAB-SWITCH] → not connected, deferring lazy load for \"\(newTab.title, privacy: .public)\"") self.changeManager.reloadVersion += 1 self.needsLazyLoad = true } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index d077e5adf..dd171a925 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -714,22 +714,11 @@ final class MainContentCoordinator { /// Table tab queries are always app-generated SELECTs, so they skip dangerous-query /// checks but still respect safe mode levels that apply to all queries. func executeTableTabQueryDirectly() { - guard let index = tabManager.selectedTabIndex else { - Self.logger.info("[QUERY] executeTableTabQueryDirectly: no selectedTabIndex") - return - } - let directTab = tabManager.tabs[index] - guard !directTab.isExecuting else { - Self.logger.info("[QUERY] executeTableTabQueryDirectly: tab \"\(directTab.title, privacy: .public)\" already executing, skipping") - return - } + guard let index = tabManager.selectedTabIndex else { return } + guard !tabManager.tabs[index].isExecuting else { return } - let sql = directTab.query - guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - Self.logger.info("[QUERY] executeTableTabQueryDirectly: tab \"\(directTab.title, privacy: .public)\" empty query, skipping") - return - } - Self.logger.info("[QUERY] executeTableTabQueryDirectly: tab \"\(directTab.title, privacy: .public)\" rows=\(directTab.resultRows.count) lastExec=\(directTab.lastExecutedAt?.description ?? "nil", privacy: .public)") + let sql = tabManager.tabs[index].query + guard !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } let level = safeModeLevel if level.appliesToAllQueries && level.requiresConfirmation, @@ -891,21 +880,12 @@ final class MainContentCoordinator { private func executeQueryInternal( _ sql: String ) { - guard let index = tabManager.selectedTabIndex else { - Self.logger.info("[QUERY] executeQueryInternal: no selectedTabIndex, skipping") - return - } - let currentTab = tabManager.tabs[index] - guard !currentTab.isExecuting else { - Self.logger.info("[QUERY] executeQueryInternal: tab \"\(currentTab.title, privacy: .public)\" already executing, skipping") - return - } + guard let index = tabManager.selectedTabIndex else { return } + guard !tabManager.tabs[index].isExecuting else { return } - let hadPreviousTask = currentQueryTask != nil currentQueryTask?.cancel() queryGeneration += 1 let capturedGeneration = queryGeneration - Self.logger.info("[QUERY] executeQueryInternal: tab \"\(currentTab.title, privacy: .public)\" gen=\(capturedGeneration) cancelledPrevious=\(hadPreviousTask) sql=\(String(sql.prefix(80)), privacy: .public)") // Batch mutations into a single array write to avoid multiple @Published // notifications — each notification triggers a full SwiftUI update cycle. @@ -1033,15 +1013,12 @@ final class MainContentCoordinator { // Always reset isExecuting even if generation is stale if capturedGeneration != queryGeneration || Task.isCancelled { - let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.title ?? "?" - Self.logger.info("[QUERY] generation stale or cancelled: tab \"\(tabTitle, privacy: .public)\" captured=\(capturedGeneration) current=\(queryGeneration) cancelled=\(Task.isCancelled) — clearing isExecuting, discarding results") if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { tabManager.tabs[idx].isExecuting = false } return } - Self.logger.info("[QUERY] applyPhase1Result: tabId=\(tabId) gen=\(capturedGeneration) rows=\(safeRows.count) cols=\(safeColumns.count)") applyPhase1Result( tabId: tabId, columns: safeColumns, @@ -1087,8 +1064,6 @@ final class MainContentCoordinator { } } } catch { - let tabTitle = await MainActor.run { tabManager.tabs.first(where: { $0.id == tabId })?.title ?? "?" } - Self.logger.info("[QUERY] error for tab \"\(tabTitle, privacy: .public)\" gen=\(capturedGeneration): \(error.localizedDescription, privacy: .public)") await MainActor.run { [weak self] in guard let self else { return } if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { From 0d2f9402a7388a6f11a6d39d761af375f70040d7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 22:19:42 +0700 Subject: [PATCH 33/36] docs: update CHANGELOG for in-app tab bar refactor --- CHANGELOG.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9066ece05..365bda9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Reopen closed tab with Cmd+Shift+T (up to 20 tabs in history) +- In-app tab bar replacing native macOS window tabs — instant tab switching (was 600-900ms per tab) +- Reopen closed tab with Cmd+Shift+T (up to 20 tabs in history, remappable in Settings) - 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 +- MRU tab selection — closing a tab selects the most recently active tab (browser behavior) +- Preview tab protection — tabs with unsaved changes are promoted instead of replaced +- Dirty indicator shows immediately for active tab edits (not just after tab switch) +- Drag-and-drop tab reorder with pinned/unpinned boundary enforcement +- Accessibility labels on tab bar items for VoiceOver +- Deeplinks and Handoff now route to in-app tabs instead of creating duplicate windows ### 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 +- Tab restoration loads all tabs in a single window instead of opening N separate windows +- Tab content preserved across switches via AppKit NSHostingView container (no view destruction) +- Query execution deferred to tab switch settlement — prevents wasted queries during rapid navigation +- Coordinator teardown moved from SwiftUI onDisappear to NSWindow willCloseNotification (deterministic) +- Open connection IDs persisted incrementally on connect/disconnect (survives SIGKILL) +- Auto-reconnect deferred on cold launch to prevent race with deeplink URL handlers ### Fixed From 77335563b56046c92f37b0173b616e21795c7a0a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 22:21:24 +0700 Subject: [PATCH 34/36] docs: simplify CHANGELOG entries --- CHANGELOG.md | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 365bda9dd..68b9502b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,24 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- In-app tab bar replacing native macOS window tabs — instant tab switching (was 600-900ms per tab) -- Reopen closed tab with Cmd+Shift+T (up to 20 tabs in history, remappable in Settings) -- Pinned tabs — pin important tabs to prevent accidental close, always at left side -- MRU tab selection — closing a tab selects the most recently active tab (browser behavior) -- Preview tab protection — tabs with unsaved changes are promoted instead of replaced -- Dirty indicator shows immediately for active tab edits (not just after tab switch) -- Drag-and-drop tab reorder with pinned/unpinned boundary enforcement -- Accessibility labels on tab bar items for VoiceOver -- Deeplinks and Handoff now route to in-app tabs instead of creating duplicate windows +- In-app tab bar with instant switching, drag reorder, pinned tabs, and dirty indicators +- Reopen closed tab (Cmd+Shift+T), MRU tab selection on close +- Deeplinks and Handoff route to in-app tabs instead of creating duplicate windows ### Changed -- Tab restoration loads all tabs in a single window instead of opening N separate windows -- Tab content preserved across switches via AppKit NSHostingView container (no view destruction) -- Query execution deferred to tab switch settlement — prevents wasted queries during rapid navigation -- Coordinator teardown moved from SwiftUI onDisappear to NSWindow willCloseNotification (deterministic) -- Open connection IDs persisted incrementally on connect/disconnect (survives SIGKILL) -- Auto-reconnect deferred on cold launch to prevent race with deeplink URL handlers +- Replace native macOS window tabs with in-app tab bar (600ms+ → instant) +- Tab content preserved across switches (no view destruction/recreation) +- Connection state persisted incrementally (survives force quit) ### Fixed From dac05dfde61814ee7d3205d651aa0f2a91d0cfab Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 22:25:03 +0700 Subject: [PATCH 35/36] docs: update tabs, keyboard shortcuts, and deeplinks for in-app tab bar - Add Cmd+Shift+T (Reopen Closed Tab) to keyboard shortcuts - Add Close Tabs to Right, Close All, Reopen Closed Tab to tabs docs - Document MRU tab selection, reopening history (20 tabs), remappable shortcut - Expand pinned tabs: left position, divider, drag boundary enforcement - Add Rename and full context menu items - Note preview tab promotion on data changes - Update deeplinks: opens as in-app tab in existing window --- docs/features/deep-links.mdx | 2 +- docs/features/keyboard-shortcuts.mdx | 1 + docs/features/tabs.mdx | 32 +++++++++++++++++++++------- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/features/deep-links.mdx b/docs/features/deep-links.mdx index ce9a8d0fe..70b3a8504 100644 --- a/docs/features/deep-links.mdx +++ b/docs/features/deep-links.mdx @@ -47,7 +47,7 @@ open "redis://localhost:6379" -a TablePro open "sqlite:///Users/me/data/app.db" -a TablePro ``` -TablePro checks if a saved connection matches the URL. If found, it reuses that connection. If the connection is already open, the existing window comes to front. Otherwise, a temporary connection is created. +TablePro checks if a saved connection matches the URL. If found, it reuses that connection. If the connection is already open, the table or query opens as a new tab in the existing window. Otherwise, a temporary connection is created. ### Query Parameters diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index d96c23119..c0c6883b9 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -146,6 +146,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut |--------|----------| | Close window / tab | `Cmd+W` | | New query tab | `Cmd+T` | +| Reopen closed tab | `Cmd+Shift+T` | | Switch to tab 1-9 | `Cmd+1` through `Cmd+9` | | Next tab | `Cmd+Shift+]` | | Previous tab | `Cmd+Shift+[` | diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index c7a37b9b2..3c2bde582 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -44,6 +44,7 @@ A preview tab becomes permanent when you: - **Double-click** a table in the sidebar - **Interact** with the tab (sort, filter, edit data, select rows) +- **Make data changes** — tabs with unsaved edits are promoted to protect your work Preview tabs are not saved across restarts. @@ -82,10 +83,21 @@ Disable preview tabs in **Settings** > **Tabs** if you prefer every click to ope | Close current tab | `Cmd+W` | | Close specific tab | Hover and click **x** | | Close other tabs | Right-click > **Close Other Tabs** | +| Close tabs to the right | Right-click > **Close Tabs to the Right** | +| Close all tabs | Right-click > **Close All** | +| Reopen closed tab | `Cmd+Shift+T` | - -Closing a tab with unsaved changes discards those changes. TablePro warns you before closing if there are pending data modifications. - +Closing a tab with unsaved changes shows a confirmation dialog. Closing the last tab closes the window. + +When you close a tab, TablePro selects the most recently used tab (not just the adjacent one), matching browser behavior. + +### Reopening Closed Tabs + +Press `Cmd+Shift+T` to reopen the last closed tab. TablePro keeps a history of up to 20 closed tabs per window. Reopened tabs re-execute their query to fetch fresh data. + + +Remap `Cmd+Shift+T` in **Settings** > **Keyboard** if it conflicts with your workflow. + ### Switching Tabs @@ -117,9 +129,10 @@ Drag any tab to a new position. The tab bar scrolls horizontally when tabs overf Right-click a tab > **Pin Tab**. Pinned tabs: +- Always appear at the left side of the tab bar, separated by a divider - Cannot be closed via close button, `Cmd+W`, or context menu -- Are not closed by **Close Other Tabs** -- Are not replaced by table tab reuse +- Survive **Close Other Tabs**, **Close Tabs to the Right**, and **Close All** +- Cannot be dragged across the pinned/unpinned boundary - Persist across sessions Unpin via right-click > **Unpin Tab**. @@ -191,10 +204,13 @@ Right-click any tab: | Action | Description | |--------|-------------| -| **Duplicate Tab** | Create a copy of this tab | +| **Rename** | Rename a query tab (query tabs only) | | **Pin Tab** / **Unpin Tab** | Toggle pin state | -| **Close Tab** | Close this tab | -| **Close Other Tabs** | Close all except this one (respects pins) | +| **Close** | Close this tab | +| **Close Others** | Close all except this one (respects pins) | +| **Close Tabs to the Right** | Close tabs after this one (respects pins) | +| **Close All** | Close all unpinned tabs | +| **Duplicate** | Create a copy of this tab | {/* Screenshot: Tab context menu */} From 6fd3988cc85a3e02152972ea081255ba4621f987 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 16 Apr 2026 22:37:55 +0700 Subject: [PATCH 36/36] fix: resolve shared state not triggering NSHostingView rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TabContentContainerView only rebuilt rootView when contentVersion changed, but several shared manager states that affect rendering were not included — toggling them had no visual effect. - Add filterStateManager.hasAppliedFilters to activeTabContentVersion - Add columnVisibilityManager.hiddenColumns hash to version - Add safeModeLevel.blocksAllWrites to version - Add showRowNumbers setting to version - Fix builtVersions init to use activeTabContentVersion - Replace NSApp.keyWindow with contentWindow in 5 confirmation dialogs - Set window subtitle for preview tabs created via "no reusable tab" path - Clear selectedRowIndices on tab switch to prevent stale selection - Remove dead didBecomeKey handler in ContentView --- TablePro/ContentView.swift | 23 ------------------- .../Main/Child/MainEditorContentView.swift | 12 +++++++++- .../Main/Child/TabContentContainerView.swift | 10 ++++---- .../MainContentCoordinator+Navigation.swift | 1 + .../Views/Main/MainContentCoordinator.swift | 15 +++++------- TablePro/Views/Main/MainContentView.swift | 1 + 6 files changed, 25 insertions(+), 37 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index dea3b24dd..178c35899 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -114,29 +114,6 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .connectionStatusDidChange)) { _ in handleConnectionStatusChange() } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in - // Only process notifications for our own window to avoid every - // ContentView instance re-rendering on every window focus change. - // Match by checking if the window is registered for our connectionId - // in WindowLifecycleMonitor (subtitle may not be set yet on first appear). - guard let notificationWindow = notification.object as? NSWindow, - let windowId = notificationWindow.identifier?.rawValue, - windowId == "main" || windowId.hasPrefix("main-"), - let connectionId = payload?.connectionId - else { return } - - // Verify this notification is for our window. Check WindowLifecycleMonitor - // first (reliable after onAppear registers), fall back to subtitle match - // for the brief window before registration completes. - let isOurWindow = WindowLifecycleMonitor.shared.windows(for: connectionId) - .contains(where: { $0 === notificationWindow }) - || { - guard let name = currentSession?.connection.name, !name.isEmpty else { return false } - return notificationWindow.subtitle == name - || notificationWindow.subtitle == "\(name) — Preview" - }() - guard isOurWindow else { return } - } } // MARK: - View Components diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 802cbfbbd..077d3ef27 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -94,7 +94,17 @@ struct MainEditorContentView: View { /// 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 + var v = tabManager.selectedTab?.contentVersion ?? 0 + // Include shared manager state that affects tab content rendering + // but isn't part of QueryTab's contentVersion (which only tracks per-tab data). + // Without this, NSHostingView rootView isn't rebuilt when these toggle. + if filterStateManager.isVisible { v = v &+ 17 } + if filterStateManager.hasAppliedFilters { v = v &+ 23 } + if coordinator.toolbarState.isHistoryPanelVisible { v = v &+ 19 } + if coordinator.safeModeLevel.blocksAllWrites { v = v &+ 29 } + v = v &+ columnVisibilityManager.hiddenColumns.hashValue &* 37 + if AppSettingsManager.shared.dataGrid.showRowNumbers { v = v &+ 41 } + return v } // MARK: - Body diff --git a/TablePro/Views/Main/Child/TabContentContainerView.swift b/TablePro/Views/Main/Child/TabContentContainerView.swift index cc533851d..e5b21d34e 100644 --- a/TablePro/Views/Main/Child/TabContentContainerView.swift +++ b/TablePro/Views/Main/Child/TabContentContainerView.swift @@ -54,15 +54,17 @@ struct TabContentContainerView: NSViewRepresentable { coordinator.activeTabId = selectedId } - // Refresh active tab's rootView only when data version changed + // Refresh active tab's rootView when content version changed. + // activeTabContentVersion includes both per-tab state (resultVersion, metadataVersion) + // and shared manager state (filterStateManager.isVisible, history panel). if let activeId = selectedId, let tab = tabManager.tabs.first(where: { $0.id == activeId }), let hosting = coordinator.hostingViews[activeId] { let builtVersion = coordinator.builtVersions[activeId] ?? -1 - if builtVersion != tab.contentVersion { + if builtVersion != activeTabContentVersion { hosting.rootView = contentBuilder(tab) - coordinator.builtVersions[activeId] = tab.contentVersion + coordinator.builtVersions[activeId] = activeTabContentVersion } } } @@ -88,7 +90,7 @@ struct TabContentContainerView: NSViewRepresentable { hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) coordinator.hostingViews[tab.id] = hosting - coordinator.builtVersions[tab.id] = tab.contentVersion + coordinator.builtVersions[tab.id] = activeTabContentVersion } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index e0332844a..04e0878e2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -316,6 +316,7 @@ extension MainContentCoordinator { databaseType: connection.type, databaseName: databaseName ) + contentWindow?.subtitle = "\(connection.name) — Preview" if let tabIndex = tabManager.selectedTabIndex { tabManager.tabs[tabIndex].isView = isView tabManager.tabs[tabIndex].isEditable = !isView diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index dd171a925..5185bd4b0 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -658,13 +658,13 @@ final class MainContentCoordinator { if level == .silent { if statements.count == 1 { Task { @MainActor in - let window = NSApp.keyWindow + let window = self.contentWindow guard await confirmDangerousQueryIfNeeded(statements[0], window: window) else { return } executeQueryInternal(statements[0]) } } else { Task { @MainActor in - let window = NSApp.keyWindow + let window = self.contentWindow let dangerousStatements = statements.filter { isDangerousQuery($0) } if !dangerousStatements.isEmpty { guard await confirmDangerousQueries(dangerousStatements, window: window) else { return } @@ -677,7 +677,7 @@ final class MainContentCoordinator { isShowingSafeModePrompt = true Task { @MainActor in defer { isShowingSafeModePrompt = false } - let window = NSApp.keyWindow + let window = self.contentWindow let combinedSQL = statements.joined(separator: "\n") let hasWrite = statements.contains { isWriteQuery($0) } let permission = await SafeModeGuard.checkPermission( @@ -728,13 +728,12 @@ final class MainContentCoordinator { isShowingSafeModePrompt = true Task { @MainActor in defer { isShowingSafeModePrompt = false } - let window = NSApp.keyWindow let permission = await SafeModeGuard.checkPermission( level: level, isWriteOperation: false, sql: sql, operationDescription: String(localized: "Execute Query"), - window: window, + window: self.contentWindow, databaseType: connection.type ) switch permission { @@ -827,13 +826,12 @@ final class MainContentCoordinator { if !explainVariants.isEmpty { if needsConfirmation { Task { @MainActor in - let window = NSApp.keyWindow let permission = await SafeModeGuard.checkPermission( level: level, isWriteOperation: false, sql: "EXPLAIN", operationDescription: String(localized: "Execute Query"), - window: window, + window: self.contentWindow, databaseType: connection.type ) if case .allowed = permission { @@ -856,13 +854,12 @@ final class MainContentCoordinator { if needsConfirmation { Task { @MainActor in - let window = NSApp.keyWindow let permission = await SafeModeGuard.checkPermission( level: level, isWriteOperation: false, sql: explainSQL, operationDescription: String(localized: "Execute Query"), - window: window, + window: self.contentWindow, databaseType: connection.type ) if case .allowed = permission { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 50d4f2d13..ce1d9156d 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -245,6 +245,7 @@ struct MainContentView: View { coordinator.onTabSwitchSettled = { // Capture reference types explicitly — MainContentView is a struct, // but @State/@Binding storage is reference-stable. + self.selectedRowIndices = [] self.updateWindowTitleAndFileState() self.syncSidebarToCurrentTab() guard !self.coordinator.isTearingDown else { return }