From 8d2359bd2251667e43a1b4b264b4455c82781a02 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Jul 2026 18:15:52 +0700 Subject: [PATCH 1/2] fix(coordinator): stop restored tabs from auto-running queries and spamming errors on launch (#1796) --- CHANGELOG.md | 4 + .../QueryExecutionCoordinator+Helpers.swift | 13 +-- .../Infrastructure/AppLaunchCoordinator.swift | 4 +- .../RestorationGroupRegistry.swift | 6 ++ .../Infrastructure/RestoreWindowPlan.swift | 15 ++++ .../Infrastructure/TabWindowController.swift | 8 +- .../Infrastructure/TabWindowRestoration.swift | 81 ------------------- .../Infrastructure/WindowManager.swift | 16 ++-- TablePro/Models/Query/TableLoadTrigger.swift | 15 ++++ .../MainContentCoordinator+QueryHelpers.swift | 10 ++- ...ainContentCoordinator+TableFirstLoad.swift | 4 +- ...inContentCoordinator+WindowLifecycle.swift | 23 ++++-- .../Extensions/MainContentView+Helpers.swift | 59 +++++++------- .../Extensions/MainContentView+Setup.swift | 72 ++++++++++++----- .../Views/Main/MainContentCoordinator.swift | 22 ++--- .../MultiWindowRestorationTests.swift | 16 ++++ .../Services/RestoreWindowPlanTests.swift | 52 ++++++++++++ .../Models/TableLoadTriggerTests.swift | 21 +++++ .../MainContentCoordinatorLazyLoadTests.swift | 67 +++++++++++---- docs/features/tabs.mdx | 2 +- 20 files changed, 325 insertions(+), 185 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift delete mode 100644 TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift create mode 100644 TablePro/Models/Query/TableLoadTrigger.swift create mode 100644 TableProTests/Core/Services/RestoreWindowPlanTests.swift create mode 100644 TableProTests/Models/TableLoadTriggerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fb87083e0..b8acffcd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796) + ## [0.54.0] - 2026-06-30 ### Added diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 44ffedb09..11c2e5f4a 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -485,7 +485,8 @@ extension QueryExecutionCoordinator { _ error: Error, sql: String, tabId: UUID, - connection conn: DatabaseConnection + connection conn: DatabaseConnection, + trigger: TableLoadTrigger = .userInitiated ) { parent.currentQueryTask = nil parent.tabManager.mutate(tabId: tabId) { tab in @@ -505,6 +506,8 @@ extension QueryExecutionCoordinator { errorMessage: error.localizedDescription ) + guard !trigger.suppressesFailureModal else { return } + let errorMessage = error.localizedDescription let queryCopy = sql Task { [weak self, parent] in @@ -529,14 +532,14 @@ extension QueryExecutionCoordinator { } } - func restoreSchemaAndRunQuery(_ schema: String) async { + func restoreSchemaAndRunQuery(_ schema: String, trigger: TableLoadTrigger = .userInitiated) async { guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { - parent.needsLazyLoad = true + parent.pendingLoadTrigger = trigger return } guard let schemaDriver = driver as? SchemaSwitchable, schemaDriver.currentSchema != nil else { - parent.runQuery() + parent.runQuery(trigger: trigger) return } do { @@ -550,7 +553,7 @@ extension QueryExecutionCoordinator { helpersLogger.warning("Failed to restore schema '\(schema, privacy: .public)': \(error.localizedDescription, privacy: .public)") return } - parent.runQuery() + parent.runQuery(trigger: trigger) } } diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index c76466146..8b82ded02 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -131,11 +131,11 @@ internal final class AppLaunchCoordinator { window.close() } case .reopenLast: - reopenLastSessionIfArchiveMissing() + reopenLastSession() } } - private func reopenLastSessionIfArchiveMissing() { + private func reopenLastSession() { guard !NSApp.windows.contains(where: { Self.isMainWindow($0) }) else { return } let connectionIds = LastOpenConnectionsStorage.shared.load() diff --git a/TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift b/TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift index f0ca428b7..cf0899672 100644 --- a/TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift +++ b/TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift @@ -5,11 +5,17 @@ import Foundation +enum RestoreLoadTiming { + case immediate + case deferred +} + @MainActor enum RestorationGroupRegistry { struct WindowGroup { let tabs: [QueryTab] let selectedTabId: UUID? + var loadTiming: RestoreLoadTiming = .immediate } private static var groups: [UUID: WindowGroup] = [:] diff --git a/TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift b/TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift new file mode 100644 index 000000000..280e19c47 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift @@ -0,0 +1,15 @@ +// +// RestoreWindowPlan.swift +// TablePro +// + +import Foundation + +enum RestoreWindowPlan { + static func resolveFrontTabId(remainingTabIds: [UUID], firstTabId: UUID, selectedId: UUID?) -> UUID { + guard let selectedId else { return firstTabId } + if selectedId == firstTabId { return firstTabId } + if remainingTabIds.contains(selectedId) { return selectedId } + return firstTabId + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index acbb4c479..389c196ce 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -52,8 +52,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { ) window.identifier = NSUserInterfaceItemIdentifier("main") window.minSize = NSSize(width: 720, height: 480) - window.isRestorable = AppSettingsStorage.shared.loadGeneral().startupBehavior == .reopenLast - window.restorationClass = TabWindowRestoration.self + window.isRestorable = false window.toolbarStyle = .unified window.titleVisibility = .visible window.tabbingMode = .preferred @@ -88,11 +87,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { fatalError("TabWindowController does not support NSCoder init") } - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - coder.encode(payload.connectionId.uuidString as NSString, forKey: TabWindowRestoration.connectionIdKey) - } - // MARK: - NSWindowDelegate internal func windowDidResize(_ notification: Notification) { diff --git a/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift b/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift deleted file mode 100644 index 68cf21fe1..000000000 --- a/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// TabWindowRestoration.swift -// TablePro -// - -import AppKit -import os - -@MainActor -final class TabWindowRestoration: NSObject, NSWindowRestoration { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "WindowRestoration") - nonisolated static let connectionIdKey = "TablePro.connectionId" - - nonisolated static func restoreWindow( - withIdentifier identifier: NSUserInterfaceItemIdentifier, - state: NSCoder, - completionHandler: @escaping (NSWindow?, Error?) -> Void - ) { - let uuidString = state.decodeObject(of: NSString.self, forKey: connectionIdKey) as String? - - Task { @MainActor in - guard let uuidString, - let connectionId = UUID(uuidString: uuidString) else { - logger.warning("[restore] Missing or invalid connectionId in state") - completionHandler(nil, restorationError(.missingConnectionId)) - return - } - - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { - logger.warning("[restore] Connection \(uuidString, privacy: .public) no longer exists") - completionHandler(nil, restorationError(.connectionNotFound)) - return - } - - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) - - let restored = NSApp.windows.first { candidate in - guard candidate.isVisible, - let controller = candidate.windowController as? TabWindowController - else { return false } - return controller.payload.connectionId == connection.id - } - - if let restored { - logger.info( - "[restore] connId=\(connection.id, privacy: .public) name=\(connection.name, privacy: .public)" - ) - completionHandler(restored, nil) - - Task { - do { - try await DatabaseManager.shared.ensureConnected(connection) - } catch { - logger.error( - "[restore] connect failed for \(connection.id, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) - } - } - } else { - logger.error("[restore] WindowManager opened tab but no window found") - completionHandler(nil, restorationError(.windowNotCreated)) - } - } - } - - private enum RestorationFailure: Int { - case missingConnectionId = 1 - case connectionNotFound = 2 - case windowNotCreated = 3 - } - - nonisolated private static func restorationError(_ failure: RestorationFailure) -> NSError { - NSError( - domain: "com.TablePro.WindowRestoration", - code: failure.rawValue, - userInfo: [NSLocalizedDescriptionKey: "Window restoration failed (\(failure))"] - ) - } -} diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index d720d7382..63f000a3c 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -20,10 +20,10 @@ internal final class WindowManager { // MARK: - Open - internal func openTab(payload: EditorTabPayload) { + internal func openTab(payload: EditorTabPayload, activate: Bool = true) { let t0 = Date() Self.lifecycleLogger.info( - "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" + "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute) activate=\(activate)" ) let resolvedConnection = DatabaseManager.shared.activeSessions[payload.connectionId]?.connection @@ -66,13 +66,19 @@ internal final class WindowManager { } let target = sibling.tabbedWindows?.last ?? sibling target.addTabbedWindow(window, ordered: .above) - window.makeKeyAndOrderFront(nil) + if activate { + window.makeKeyAndOrderFront(nil) + } Self.lifecycleLogger.info( "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) } else { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) + if activate { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } else { + window.orderFront(nil) + } Self.lifecycleLogger.info( "[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) diff --git a/TablePro/Models/Query/TableLoadTrigger.swift b/TablePro/Models/Query/TableLoadTrigger.swift new file mode 100644 index 000000000..737230a35 --- /dev/null +++ b/TablePro/Models/Query/TableLoadTrigger.swift @@ -0,0 +1,15 @@ +// +// TableLoadTrigger.swift +// TablePro +// + +import Foundation + +internal enum TableLoadTrigger { + case userInitiated + case restore + + var suppressesFailureModal: Bool { + self == .restore + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index ad1120f66..1c1a2f095 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -105,17 +105,19 @@ extension MainContentCoordinator { _ error: Error, sql: String, tabId: UUID, - connection conn: DatabaseConnection + connection conn: DatabaseConnection, + trigger: TableLoadTrigger = .userInitiated ) { queryExecutionCoordinator.handleQueryExecutionError( error, sql: sql, tabId: tabId, - connection: conn + connection: conn, + trigger: trigger ) } - func restoreSchemaAndRunQuery(_ schema: String) async { - await queryExecutionCoordinator.restoreSchemaAndRunQuery(schema) + func restoreSchemaAndRunQuery(_ schema: String, trigger: TableLoadTrigger = .userInitiated) async { + await queryExecutionCoordinator.restoreSchemaAndRunQuery(schema, trigger: trigger) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index c05d06ad4..cec8e81ee 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -7,9 +7,9 @@ import Foundation import TableProPluginKit extension MainContentCoordinator { - func openTableTabQuery(tabId: UUID) async { + func openTableTabQuery(tabId: UUID, trigger: TableLoadTrigger = .userInitiated) async { guard await prepareTableTabFirstLoad(tabId: tabId) else { return } - executeTableTabQueryDirectly() + executeTableTabQueryDirectly(trigger: trigger) } @discardableResult diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index bbec05ef2..97af91ede 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -18,8 +18,10 @@ extension MainContentCoordinator { /// Called from `TabWindowController.windowDidBecomeKey(_:)`. /// Updates focus state, refreshes file-based schema if stale, and syncs the - /// sidebar selection to the active tab. No query work runs here — lazy-load - /// is owned by `MainEditorContentView`'s `.task(id:)` modifier. + /// sidebar selection to the active tab. The one query-related action here is + /// consuming a deferred restore load: a restored background tab loads its data + /// the first time its window becomes key. All other lazy-load is owned by + /// `MainEditorContentView`'s `.task(id:)` modifier. func handleWindowDidBecomeKey() { let t0 = Date() Self.lifecycleLogger.debug( @@ -29,6 +31,8 @@ extension MainContentCoordinator { evictionTask?.cancel() evictionTask = nil + consumeDeferredRestoreLoadIfNeeded() + syncSidebarToSelectedTab() announceActiveTabToVoiceOver() @@ -133,8 +137,9 @@ extension MainContentCoordinator { // MARK: - Lazy Load - func lazyLoadCurrentTabIfNeeded() { + func lazyLoadCurrentTabIfNeeded(trigger: TableLoadTrigger = .userInitiated) { guard let tab = tabManager.selectedTab else { return } + guard deferredRestoreLoadTabId != tab.id else { return } guard canAutoLoadTableTab(tab) else { return } guard tableLoadTasks[tab.id] == nil else { return } @@ -142,7 +147,7 @@ extension MainContentCoordinator { guard let session = DatabaseManager.shared.session(for: connectionId), session.isConnected else { - needsLazyLoad = true + pendingLoadTrigger = trigger return } @@ -158,7 +163,7 @@ extension MainContentCoordinator { self.tableLoadTasks[tabId] = nil } } - await self.openTableTabQuery(tabId: tabId) + await self.openTableTabQuery(tabId: tabId, trigger: trigger) if let queryTask = self.currentQueryTask { await queryTask.value } @@ -171,6 +176,14 @@ extension MainContentCoordinator { tableLoadTasks[tabId] = nil } + func consumeDeferredRestoreLoadIfNeeded() { + guard isKeyWindow else { return } + guard let deferredId = deferredRestoreLoadTabId, + deferredId == tabManager.selectedTabId else { return } + deferredRestoreLoadTabId = nil + lazyLoadCurrentTabIfNeeded(trigger: .restore) + } + private func canAutoLoadTableTab(_ tab: QueryTab) -> Bool { guard tab.tabType == .table else { return false } guard tab.execution.errorMessage == nil else { return false } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 3b26b1729..d47e54721 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -22,36 +22,19 @@ extension MainContentView { func handleConnectionStatusChange() { let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } - if session.isConnected && coordinator.needsLazyLoad { - let hasPendingEdits = - changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) - if !hasPendingEdits { - coordinator.needsLazyLoad = false - if let selectedTab = tabManager.selectedTab, - !selectedTab.tableContext.databaseName.isEmpty, - selectedTab.tableContext.databaseName != session.activeDatabase - { - Task { - await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) - coordinator.lazyLoadCurrentTabIfNeeded() - } - } else if let selectedTab = tabManager.selectedTab, - let tabSchema = selectedTab.tableContext.schemaName, - !tabSchema.isEmpty, - tabSchema != session.currentSchema - { - Task { - await coordinator.restoreSchemaAndRunQuery(tabSchema) - } - } else { - coordinator.runQuery() + if session.isConnected { + if let trigger = coordinator.pendingLoadTrigger { + let hasPendingEdits = + changeManager.hasChanges + || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) + if !hasPendingEdits { + coordinator.pendingLoadTrigger = nil + consumePendingLoad(trigger: trigger, session: session) } + } else { + coordinator.lazyLoadCurrentTabIfNeeded() } } - if session.isConnected { - coordinator.lazyLoadCurrentTabIfNeeded() - } let mappedState = mapSessionStatus(session.status) if mappedState != toolbarState.connectionState { toolbarState.connectionState = mappedState @@ -59,6 +42,28 @@ extension MainContentView { toolbarState.syncFromSession(for: connection) } + private func consumePendingLoad(trigger: TableLoadTrigger, session: ConnectionSession) { + if let selectedTab = tabManager.selectedTab, + !selectedTab.tableContext.databaseName.isEmpty, + selectedTab.tableContext.databaseName != session.activeDatabase + { + Task { + await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) + coordinator.lazyLoadCurrentTabIfNeeded(trigger: trigger) + } + } else if let selectedTab = tabManager.selectedTab, + let tabSchema = selectedTab.tableContext.schemaName, + !tabSchema.isEmpty, + tabSchema != session.currentSchema + { + Task { + await coordinator.restoreSchemaAndRunQuery(tabSchema, trigger: trigger) + } + } else { + coordinator.runQuery(trigger: trigger) + } + } + private func mapSessionStatus(_ status: ConnectionStatus) -> ToolbarConnectionState { switch status { case .connected: return .connected diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 98627f68f..9b228ebfc 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -71,7 +71,7 @@ extension MainContentView { coordinator.lazyLoadCurrentTabIfNeeded() } } else { - coordinator.needsLazyLoad = true + coordinator.pendingLoadTrigger = .userInitiated } } if let sourceURL = payload.sourceFileURL { @@ -91,7 +91,7 @@ extension MainContentView { private func handleRestoreOrDefault() async { if let group = RestorationGroupRegistry.consume(for: payload?.id) { - applyRestoredGroup(group.tabs, selectedTabId: group.selectedTabId) + applyRestoredGroup(group.tabs, selectedTabId: group.selectedTabId, loadTiming: group.loadTiming) return } @@ -130,23 +130,33 @@ extension MainContentView { // First tab gets the current window to preserve order; the rest open as // native window tabs, each carrying its full restored state via the registry. + // Only the frontmost restored tab loads its data immediately; the others + // load the first time the user switches to them. let firstTab = restoredTabs[0] + let remainingTabs = Array(restoredTabs.dropFirst()) + let frontTabId = RestoreWindowPlan.resolveFrontTabId( + remainingTabIds: remainingTabs.map(\.id), + firstTabId: firstTab.id, + selectedId: selectedId + ) + applyRestoredGroup( [firstTab], selectedTabId: firstTab.id, activeDatabase: result.lastActiveDatabase, - activeSchema: result.lastActiveSchema + activeSchema: result.lastActiveSchema, + loadTiming: frontTabId == firstTab.id ? .immediate : .deferred ) - let remainingTabs = Array(restoredTabs.dropFirst()) - if !remainingTabs.isEmpty { - let selectedWasFirst = firstTab.id == selectedId - for tab in remainingTabs { - openRestoredTabWindow(tab) - } - if selectedWasFirst { - viewWindow?.makeKeyAndOrderFront(nil) - } + for tab in remainingTabs { + openRestoredTabWindow( + tab, + activate: tab.id == frontTabId, + loadTiming: tab.id == frontTabId ? .immediate : .deferred + ) + } + if frontTabId == firstTab.id, !remainingTabs.isEmpty { + viewWindow?.makeKeyAndOrderFront(nil) } } @@ -154,7 +164,8 @@ extension MainContentView { _ tabs: [QueryTab], selectedTabId: UUID?, activeDatabase: String? = nil, - activeSchema: String? = nil + activeSchema: String? = nil, + loadTiming: RestoreLoadTiming = .immediate ) { guard let firstTab = tabs.first else { return } tabManager.tabs = tabs @@ -169,17 +180,36 @@ extension MainContentView { coordinator.restoreFiltersForTable(tableName) } - restoreConnectionContext(for: selected, activeDatabase: activeDatabase, activeSchema: activeSchema) + restoreConnectionContext( + for: selected, + activeDatabase: activeDatabase, + activeSchema: activeSchema, + loadTiming: loadTiming + ) } /// Restore the connection's database and schema, then load the selected tab, in a single - /// sequenced task so the database and schema switches never race each other. - private func restoreConnectionContext(for selected: QueryTab, activeDatabase: String?, activeSchema: String?) { + /// sequenced task so the database and schema switches never race each other. A deferred + /// tab records its id and loads only when its window first becomes key. + private func restoreConnectionContext( + for selected: QueryTab, + activeDatabase: String?, + activeSchema: String?, + loadTiming: RestoreLoadTiming + ) { let isTableTab = selected.tabType == .table && !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard loadTiming == .immediate else { + if isTableTab { + coordinator.deferredRestoreLoadTabId = selected.id + coordinator.consumeDeferredRestoreLoadIfNeeded() + } + return + } + guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { - if isTableTab { coordinator.needsLazyLoad = true } + if isTableTab { coordinator.pendingLoadTrigger = .restore } return } @@ -195,12 +225,12 @@ extension MainContentView { await coordinator.switchSchema(to: activeSchema) } if isTableTab { - coordinator.lazyLoadCurrentTabIfNeeded() + coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore) } } } - private func openRestoredTabWindow(_ tab: QueryTab) { + private func openRestoredTabWindow(_ tab: QueryTab, activate: Bool, loadTiming: RestoreLoadTiming) { let restorePayload = EditorTabPayload( connectionId: connection.id, tabType: tab.tabType, @@ -214,10 +244,10 @@ extension MainContentView { intent: .restoreOrDefault ) RestorationGroupRegistry.register( - .init(tabs: [tab], selectedTabId: tab.id), + .init(tabs: [tab], selectedTabId: tab.id, loadTiming: loadTiming), for: restorePayload.id ) - WindowManager.shared.openTab(payload: restorePayload) + WindowManager.shared.openTab(payload: restorePayload, activate: activate) } // MARK: - Command Actions Setup diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 173f37891..388179859 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -166,7 +166,8 @@ final class MainContentCoordinator { var databaseToDrop: String? var importFileURL: URL? var exportPreselectedTableNames: Set? - var needsLazyLoad = false + var pendingLoadTrigger: TableLoadTrigger? + @ObservationIgnored var deferredRestoreLoadTabId: UUID? @ObservationIgnored var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] @@ -861,12 +862,12 @@ final class MainContentCoordinator { // MARK: - Query Execution - func runQuery() { + func runQuery(trigger: TableLoadTrigger = .userInitiated) { guard let (tab, index) = tabManager.selectedTabAndIndex, !tab.execution.isExecuting else { return } if tab.tabType == .table { - executeTableTabQueryDirectly() + executeTableTabQueryDirectly(trigger: trigger) return } @@ -936,7 +937,7 @@ final class MainContentCoordinator { /// Execute table tab query directly. /// 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() { + func executeTableTabQueryDirectly(trigger: TableLoadTrigger = .userInitiated) { guard let (tab, index) = tabManager.selectedTabAndIndex, !tab.execution.isExecuting else { return } @@ -964,13 +965,13 @@ final class MainContentCoordinator { ) switch decision { case .authorized: - executeQueryInternal(sql, isAutoLoad: true) + executeQueryInternal(sql, isAutoLoad: true, trigger: trigger) case .denied(let reason): tabManager.mutate(at: index) { $0.execution.errorMessage = reason } } } } else { - executeQueryInternal(sql, isAutoLoad: true) + executeQueryInternal(sql, isAutoLoad: true, trigger: trigger) } } @@ -1118,7 +1119,8 @@ final class MainContentCoordinator { internal func executeQueryInternal( _ sql: String, - isAutoLoad: Bool = false + isAutoLoad: Bool = false, + trigger: TableLoadTrigger = .userInitiated ) { guard let (selectedTab, index) = tabManager.selectedTabAndIndex, !selectedTab.execution.isExecuting else { return } @@ -1186,7 +1188,7 @@ final class MainContentCoordinator { tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } currentQueryTask = nil toolbarState.setExecuting(false) - needsLazyLoad = true + pendingLoadTrigger = trigger } return } @@ -1290,10 +1292,10 @@ final class MainContentCoordinator { if error is CancellationError || Task.isCancelled { return } guard capturedGeneration == queryGeneration else { return } if isAutoLoad, services.databaseManager.driver(for: connectionId)?.status != .connected { - needsLazyLoad = true + pendingLoadTrigger = trigger return } - handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) + handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn, trigger: trigger) } } } diff --git a/TableProTests/Core/Services/MultiWindowRestorationTests.swift b/TableProTests/Core/Services/MultiWindowRestorationTests.swift index 4a4d69d8b..af9ca91f7 100644 --- a/TableProTests/Core/Services/MultiWindowRestorationTests.swift +++ b/TableProTests/Core/Services/MultiWindowRestorationTests.swift @@ -34,6 +34,22 @@ struct MultiWindowRestorationTests { #expect(RestorationGroupRegistry.consume(for: nil) == nil) } + @Test("Window group defaults to immediate load timing") + func windowGroupDefaultsToImmediate() { + let group = RestorationGroupRegistry.WindowGroup(tabs: [tab("A")], selectedTabId: nil) + #expect(group.loadTiming == .immediate) + } + + @Test("Registry round-trips deferred load timing") + func registryRoundTripsLoadTiming() { + let payloadId = UUID() + RestorationGroupRegistry.register( + .init(tabs: [tab("A")], selectedTabId: nil, loadTiming: .deferred), + for: payloadId + ) + #expect(RestorationGroupRegistry.consume(for: payloadId)?.loadTiming == .deferred) + } + @Test("Restored sort columns resolve to indices, preserving order and dropping missing columns") func resolveRestoredSortColumns() { let persisted = [ diff --git a/TableProTests/Core/Services/RestoreWindowPlanTests.swift b/TableProTests/Core/Services/RestoreWindowPlanTests.swift new file mode 100644 index 000000000..fab86da4d --- /dev/null +++ b/TableProTests/Core/Services/RestoreWindowPlanTests.swift @@ -0,0 +1,52 @@ +// +// RestoreWindowPlanTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("RestoreWindowPlan") +struct RestoreWindowPlanTests { + @Test("Selected tab is the first tab: front is the first tab") + func selectedIsFirst() { + let first = UUID() + let remaining = [UUID(), UUID()] + let front = RestoreWindowPlan.resolveFrontTabId( + remainingTabIds: remaining, firstTabId: first, selectedId: first + ) + #expect(front == first) + } + + @Test("Selected tab is one of the remaining tabs: front is that tab") + func selectedIsRemaining() { + let first = UUID() + let target = UUID() + let remaining = [UUID(), target, UUID()] + let front = RestoreWindowPlan.resolveFrontTabId( + remainingTabIds: remaining, firstTabId: first, selectedId: target + ) + #expect(front == target) + } + + @Test("Selected id matches neither: falls back to the first tab") + func selectedMatchesNeither() { + let first = UUID() + let remaining = [UUID()] + let front = RestoreWindowPlan.resolveFrontTabId( + remainingTabIds: remaining, firstTabId: first, selectedId: UUID() + ) + #expect(front == first) + } + + @Test("Nil selected id: falls back to the first tab") + func selectedNil() { + let first = UUID() + let front = RestoreWindowPlan.resolveFrontTabId( + remainingTabIds: [UUID()], firstTabId: first, selectedId: nil + ) + #expect(front == first) + } +} diff --git a/TableProTests/Models/TableLoadTriggerTests.swift b/TableProTests/Models/TableLoadTriggerTests.swift new file mode 100644 index 000000000..2ba9348de --- /dev/null +++ b/TableProTests/Models/TableLoadTriggerTests.swift @@ -0,0 +1,21 @@ +// +// TableLoadTriggerTests.swift +// TableProTests +// + +import Testing + +@testable import TablePro + +@Suite("TableLoadTrigger") +struct TableLoadTriggerTests { + @Test("Restore trigger suppresses the failure modal") + func restoreSuppressesModal() { + #expect(TableLoadTrigger.restore.suppressesFailureModal == true) + } + + @Test("User-initiated trigger does not suppress the failure modal") + func userInitiatedShowsModal() { + #expect(TableLoadTrigger.userInitiated.suppressesFailureModal == false) + } +} diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index 6e64f2747..dfe09f910 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -74,7 +74,7 @@ struct MainContentCoordinatorLazyLoadTests { func skipsWhenNoSelectedTab() { let (coordinator, _) = makeCoordinator() coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) } @Test("Returns early when selected tab is a query tab (not a table tab)") @@ -82,7 +82,7 @@ struct MainContentCoordinatorLazyLoadTests { let (coordinator, tabManager) = makeCoordinator() _ = addQueryTab(to: tabManager) coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) } @Test("Returns early when tab has an error message") @@ -95,7 +95,7 @@ struct MainContentCoordinatorLazyLoadTests { } tabManager.tabs[idx].execution.errorMessage = "boom" coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) } @Test("Returns early when tab query is whitespace-only") @@ -103,7 +103,7 @@ struct MainContentCoordinatorLazyLoadTests { let (coordinator, tabManager) = makeCoordinator() _ = addTableTab(to: tabManager, query: " ") coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) } @Test("Returns early when tab has fresh row data already loaded") @@ -118,7 +118,7 @@ struct MainContentCoordinatorLazyLoadTests { tabManager.tabs[idx].execution.lastExecutedAt = Date() coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 5) } @@ -134,7 +134,7 @@ struct MainContentCoordinatorLazyLoadTests { tabManager.tabs[idx].pendingChanges.deletedRowIndices = [0] coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) } @Test("Returns early when a load Task is already registered for this tab") @@ -149,31 +149,68 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.tableLoadTasks.count == 1) #expect(coordinator.tableLoadTasks[tabId] != nil) - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) } // MARK: - Connection guard - @Test("Sets needsLazyLoad when a fresh table tab is not connected") + @Test("Sets pendingLoadTrigger when a fresh table tab is not connected") func defersWhenDisconnected() { let (coordinator, tabManager) = makeCoordinator() _ = addTableTab(to: tabManager) - coordinator.needsLazyLoad = false + coordinator.pendingLoadTrigger = nil coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.needsLazyLoad == true) + #expect(coordinator.pendingLoadTrigger == .userInitiated) } - @Test("restoreSchemaAndRunQuery defers via needsLazyLoad instead of running a query when the driver is not ready") + @Test("Carries the restore trigger into pendingLoadTrigger when disconnected") + func carriesRestoreTriggerWhenDisconnected() { + let (coordinator, tabManager) = makeCoordinator() + _ = addTableTab(to: tabManager) + coordinator.pendingLoadTrigger = nil + + coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore) + + #expect(coordinator.pendingLoadTrigger == .restore) + } + + @Test("A deferred restore tab does not load until its window becomes key") + func deferredRestoreTabDoesNotLoadEagerly() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + coordinator.deferredRestoreLoadTabId = tabId + coordinator.pendingLoadTrigger = nil + + coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore) + + #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.tableLoadTasks[tabId] == nil) + } + + @Test("handleWindowDidBecomeKey consumes a deferred restore load with the restore trigger") + func windowDidBecomeKeyConsumesDeferredRestoreLoad() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + coordinator.deferredRestoreLoadTabId = tabId + coordinator.pendingLoadTrigger = nil + + coordinator.handleWindowDidBecomeKey() + + #expect(coordinator.deferredRestoreLoadTabId == nil) + #expect(coordinator.pendingLoadTrigger == .restore) + } + + @Test("restoreSchemaAndRunQuery defers via pendingLoadTrigger instead of running a query when the driver is not ready") func restoreSchemaDefersWhenDriverNil() async { let (coordinator, tabManager) = makeCoordinator() let tabId = addTableTab(to: tabManager) - coordinator.needsLazyLoad = false + coordinator.pendingLoadTrigger = nil await coordinator.restoreSchemaAndRunQuery("public") - #expect(coordinator.needsLazyLoad == true) + #expect(coordinator.pendingLoadTrigger == .userInitiated) if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { #expect(tabManager.tabs[idx].execution.isExecuting == false) } @@ -196,7 +233,7 @@ struct MainContentCoordinatorLazyLoadTests { coordinator.lazyLoadCurrentTabIfNeeded() } #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 4) - #expect(coordinator.needsLazyLoad == false) + #expect(coordinator.pendingLoadTrigger == nil) } @Test("Clears an abandoned executing flag when no in-flight task remains") @@ -213,7 +250,7 @@ struct MainContentCoordinatorLazyLoadTests { coordinator.lazyLoadCurrentTabIfNeeded() #expect(tabManager.tabs[idx].execution.isExecuting == false) - #expect(coordinator.needsLazyLoad == true) + #expect(coordinator.pendingLoadTrigger == .userInitiated) } // MARK: - loadEpoch bump triggers reload after eviction diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index b4d52567d..f9720e9cc 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -160,7 +160,7 @@ Each tab maintains its own pending changes. Switching tabs saves and restores ea | Active database and schema | | | Pin state | | -Tab state auto-saves 500ms after any change, plus a background save every 30 seconds so a crash or force quit keeps your recent work. On reconnect, TablePro restores your tabs and re-runs table queries with the sort, filters, and page you left them on. Connections reconnect lazily, when you first interact with a restored tab, so launch stays fast and unreachable hosts do not block startup. +Tab state auto-saves 500ms after any change, plus a background save every 30 seconds so a crash or force quit keeps your recent work. On reconnect, TablePro restores your tabs with the sort, filters, and page you left them on. Only the frontmost tab loads its data right away; other restored tabs load the first time you switch to them, so launch stays fast and a connection storm never hits the database. Connections reconnect lazily on first interaction, and if a restored tab fails to load the error shows inline in that tab rather than as a dialog. Whether the session reopens on launch is controlled by [Startup Behavior](/customization/settings#general), which defaults to reopening your last session. From 8dac706d7862e468cb365d309e890fac81d58117 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Jul 2026 18:33:18 +0700 Subject: [PATCH 2/2] fix(coordinator): keep the initial restored window's non-front tab deferred (#1796) --- .../Extensions/MainContentView+Setup.swift | 27 ++++++++++++++----- .../MainContentCoordinatorLazyLoadTests.swift | 15 +++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 9b228ebfc..feffa5538 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -91,7 +91,12 @@ extension MainContentView { private func handleRestoreOrDefault() async { if let group = RestorationGroupRegistry.consume(for: payload?.id) { - applyRestoredGroup(group.tabs, selectedTabId: group.selectedTabId, loadTiming: group.loadTiming) + applyRestoredGroup( + group.tabs, + selectedTabId: group.selectedTabId, + loadTiming: group.loadTiming, + consumeDeferredWhenKey: true + ) return } @@ -165,7 +170,8 @@ extension MainContentView { selectedTabId: UUID?, activeDatabase: String? = nil, activeSchema: String? = nil, - loadTiming: RestoreLoadTiming = .immediate + loadTiming: RestoreLoadTiming = .immediate, + consumeDeferredWhenKey: Bool = false ) { guard let firstTab = tabs.first else { return } tabManager.tabs = tabs @@ -184,18 +190,25 @@ extension MainContentView { for: selected, activeDatabase: activeDatabase, activeSchema: activeSchema, - loadTiming: loadTiming + loadTiming: loadTiming, + consumeDeferredWhenKey: consumeDeferredWhenKey ) } /// Restore the connection's database and schema, then load the selected tab, in a single /// sequenced task so the database and schema switches never race each other. A deferred - /// tab records its id and loads only when its window first becomes key. + /// tab records its id and loads only when its window becomes key from a user switch. + /// + /// `consumeDeferredWhenKey` is true only for sibling windows opened by restoration, which + /// may already be key because the user is showing them. The initial window is transiently + /// key at launch before the front window activates, so it must never consume here — it loads + /// its deferred tab through `windowDidBecomeKey` when the user switches back to it. private func restoreConnectionContext( for selected: QueryTab, activeDatabase: String?, activeSchema: String?, - loadTiming: RestoreLoadTiming + loadTiming: RestoreLoadTiming, + consumeDeferredWhenKey: Bool ) { let isTableTab = selected.tabType == .table && !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -203,7 +216,9 @@ extension MainContentView { guard loadTiming == .immediate else { if isTableTab { coordinator.deferredRestoreLoadTabId = selected.id - coordinator.consumeDeferredRestoreLoadIfNeeded() + if consumeDeferredWhenKey { + coordinator.consumeDeferredRestoreLoadIfNeeded() + } } return } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index dfe09f910..f80d51dba 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -189,6 +189,21 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.tableLoadTasks[tabId] == nil) } + @Test("consumeDeferredRestoreLoadIfNeeded is a no-op while the window is not key") + func consumeDeferredIsNoOpWhenNotKey() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + coordinator.deferredRestoreLoadTabId = tabId + coordinator.isKeyWindow = false + coordinator.pendingLoadTrigger = nil + + coordinator.consumeDeferredRestoreLoadIfNeeded() + + #expect(coordinator.deferredRestoreLoadTabId == tabId) + #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.tableLoadTasks[tabId] == nil) + } + @Test("handleWindowDidBecomeKey consumes a deferred restore load with the restore trigger") func windowDidBecomeKeyConsumesDeferredRestoreLoad() { let (coordinator, tabManager) = makeCoordinator()