From 5479130f6f00b4f12bb1cfbe5f213607018bfe2c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 22:15:56 +0700 Subject: [PATCH 1/2] fix(tabs): keep a restored table tab on its own database and load it after reconnect --- CHANGELOG.md | 1 + .../QueryExecutionCoordinator+Helpers.swift | 7 +++-- .../Database/DatabaseManager+Health.swift | 3 +++ .../MainContentCoordinator+Navigation.swift | 16 +++++++----- .../MainContentCoordinator+TabSwitch.swift | 5 ++-- .../Extensions/MainContentView+Helpers.swift | 7 ++++- .../Extensions/MainContentView+Setup.swift | 7 ++--- .../Views/Main/MainContentCoordinator.swift | 26 ++++++++++++++++--- .../MainContentCoordinatorLazyLoadTests.swift | 14 ++++++++++ 9 files changed, 66 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7326828..eb92d95d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The row detail panel no longer stays blank when a table is opened in a second tab. The panel now shows the selected row right away instead of only after the inspector is toggled. - The sidebar filter now stays put when you open another tab. Before, opening a second table tab cleared the filter text and reset the table list to show everything. - Fixed a crash when browsing the database tree on servers with many schemas, such as PostgreSQL. The sidebar tree is now a native outline view that loads each database and schema on demand. +- Reopening the app no longer shows a "Not connected to database" error when it restores a table tab on a connection that is still reconnecting, such as one over SSH. The tab waits for the connection and loads on its own. ## [0.51.1] - 2026-06-16 diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index de8be7003..6c8d7c250 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -524,8 +524,11 @@ extension QueryExecutionCoordinator { } func restoreSchemaAndRunQuery(_ schema: String) async { - guard let driver = DatabaseManager.shared.driver(for: parent.connectionId), - let schemaDriver = driver as? SchemaSwitchable, + guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { + parent.needsLazyLoad = true + return + } + guard let schemaDriver = driver as? SchemaSwitchable, schemaDriver.currentSchema != nil else { parent.runQuery() return diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 7fd1178fe..c2113f569 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -280,6 +280,9 @@ extension DatabaseManager { session.driver = driver session.status = .connected session.effectiveConnection = effectiveConnection + if let schemaDriver = driver as? SchemaSwitchable { + session.currentSchema = schemaDriver.currentSchema + } if let passwordOverride, !session.connection.usesAWSIAM { session.cachedPassword = passwordOverride } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 4c85b0dd3..a88b1d53e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -383,19 +383,21 @@ extension MainContentCoordinator { } /// Switch to a different database (called from database switcher) - func switchDatabase(to database: String) async { - clearFilterState() + func switchDatabase(to database: String, clearTabs: Bool = true) async { + if clearTabs { clearFilterState() } let previousDatabase = toolbarState.currentDatabase toolbarState.currentDatabase = database do { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) - closeSiblingNativeWindows() - persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) - tabSessionRegistry.removeAll() - tabManager.tabs = [] - tabManager.selectedTabId = nil + if clearTabs { + closeSiblingNativeWindows() + persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + tabSessionRegistry.removeAll() + tabManager.tabs = [] + tabManager.selectedTabId = nil + } await SchemaService.shared.invalidate(connectionId: connectionId) await refreshTables() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 236ac2668..18ee6850e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -96,9 +96,10 @@ extension MainContentCoordinator { ) changeManager.reloadVersion += 1 Task { - await switchDatabase(to: newTab.tableContext.databaseName) + await switchDatabase(to: newTab.tableContext.databaseName, clearTabs: false) + lazyLoadCurrentTabIfNeeded() } - return // switchDatabase will re-execute the query + return } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 388e277ac..efbe40ef3 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -32,7 +32,12 @@ extension MainContentView { !selectedTab.tableContext.databaseName.isEmpty, selectedTab.tableContext.databaseName != session.activeDatabase { - Task { await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) } + Task { + await coordinator.switchDatabase( + to: selectedTab.tableContext.databaseName, clearTabs: false + ) + coordinator.lazyLoadCurrentTabIfNeeded() + } } else if let selectedTab = tabManager.selectedTab, let tabSchema = selectedTab.tableContext.schemaName, !tabSchema.isEmpty, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 875bc2e1d..943299753 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -188,16 +188,13 @@ extension MainContentView { : activeDatabase.flatMap { $0.isEmpty ? nil : $0 } Task { - var contextChanged = false if let targetDatabase, targetDatabase != session.activeDatabase { - await coordinator.switchDatabase(to: targetDatabase) - contextChanged = true + await coordinator.switchDatabase(to: targetDatabase, clearTabs: false) } if let activeSchema, !activeSchema.isEmpty, activeSchema != session.currentSchema { await coordinator.switchSchema(to: activeSchema) - contextChanged = true } - if isTableTab, !contextChanged { + if isTableTab { coordinator.lazyLoadCurrentTabIfNeeded() } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f5cf66aef..96dfda6da 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -963,13 +963,13 @@ final class MainContentCoordinator { ) switch decision { case .authorized: - executeQueryInternal(sql) + executeQueryInternal(sql, isAutoLoad: true) case .denied(let reason): tabManager.mutate(at: index) { $0.execution.errorMessage = reason } } } } else { - executeQueryInternal(sql) + executeQueryInternal(sql, isAutoLoad: true) } } @@ -1116,7 +1116,8 @@ final class MainContentCoordinator { } internal func executeQueryInternal( - _ sql: String + _ sql: String, + isAutoLoad: Bool = false ) { guard let (selectedTab, index) = tabManager.selectedTabAndIndex, !selectedTab.execution.isExecuting else { return } @@ -1169,6 +1170,21 @@ final class MainContentCoordinator { currentQueryTask = Task { [weak self] in guard let self else { return } + if isAutoLoad { + do { + try await services.databaseManager.ensureConnected(conn) + } catch { + await MainActor.run { [weak self] in + guard let self else { return } + tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } + currentQueryTask = nil + toolbarState.setExecuting(false) + needsLazyLoad = true + } + return + } + } + let schemaTask: Task? if needsMetadataFetch, let tableName { schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } @@ -1262,6 +1278,10 @@ final class MainContentCoordinator { toolbarState.setExecuting(false) if error is CancellationError || Task.isCancelled { return } guard capturedGeneration == queryGeneration else { return } + if isAutoLoad, services.databaseManager.driver(for: connectionId)?.status != .connected { + needsLazyLoad = true + return + } handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) } } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index dbb904b94..6e64f2747 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -165,6 +165,20 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.needsLazyLoad == true) } + @Test("restoreSchemaAndRunQuery defers via needsLazyLoad 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 + + await coordinator.restoreSchemaAndRunQuery("public") + + #expect(coordinator.needsLazyLoad == true) + if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { + #expect(tabManager.tabs[idx].execution.isExecuting == false) + } + } + // MARK: - Idempotency @Test("Idempotent: repeated calls with the same loaded state are no-ops") From c5d50298c25b0e414edf2d42dfe125b0c54162cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 22:23:38 +0700 Subject: [PATCH 2/2] fix(connections): sync schema on health-monitor reconnect and skip the no-op database switch --- TablePro/Core/Database/DatabaseManager+Health.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index c2113f569..b4ed9241c 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -62,6 +62,9 @@ extension DatabaseManager { session.driver = result.driver session.effectiveConnection = result.effectiveConnection session.status = .connected + if let schemaDriver = result.driver as? SchemaSwitchable { + session.currentSchema = schemaDriver.currentSchema + } } return true } catch { @@ -155,7 +158,7 @@ extension DatabaseManager { await restoreSchemaAndDatabase( on: driver, savedSchema: session.currentSchema, - savedDatabase: session.currentDatabase + savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : session.currentDatabase ) return ReconnectResult(driver: driver, effectiveConnection: connectionForDriver) @@ -178,6 +181,11 @@ extension DatabaseManager { await executeStartupCommands(startupCommands, on: driver, connectionName: connectionName) } + private func databaseSwitchRequiresReconnect(_ connection: DatabaseConnection) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: connection.type.pluginTypeId)? + .capabilities.requiresReconnectForDatabaseSwitch ?? false + } + func restoreSchemaAndDatabase( on driver: DatabaseDriver, savedSchema: String?, @@ -272,7 +280,7 @@ extension DatabaseManager { await restoreSchemaAndDatabase( on: driver, savedSchema: activeSessions[sessionId]?.currentSchema, - savedDatabase: activeSessions[sessionId]?.currentDatabase + savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : activeSessions[sessionId]?.currentDatabase ) // Update session