From 913e3a248b066b7b96915eae36cd2a09c13f49fd Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Thu, 18 Jun 2026 16:32:01 +0800 Subject: [PATCH 1/2] fix(sidebar): refresh table tree after dropping a table --- CHANGELOG.md | 1 + .../Query/DatabaseTreeMetadataService.swift | 13 +++++- .../Views/Main/MainContentCoordinator.swift | 1 + .../DatabaseTreeMetadataServiceTests.swift | 46 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6023b3c5f..ae975ecf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A new database group now appears in the connection list right away instead of only after restarting the app. (#1704) - The SQL formatter keeps nested indentation for UNION, UNION ALL, INTERSECT, and EXCEPT inside a derived table or CTE, and puts the closing parenthesis of a subquery on its own line instead of collapsing it onto the last SELECT. (#1698) - Toolbar button tooltips now show each action's real keyboard shortcut and follow your custom bindings, instead of a fixed value. The Switch Connection tooltip showed the wrong shortcut. (#1694) +- Deleting a table from the sidebar now removes it from the tree right away on multi-database servers like MySQL and PostgreSQL. The tree kept showing the dropped table until you refreshed it by hand. ## [0.51.1] - 2026-06-16 diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index d1dd93e77..cd370869a 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -208,6 +208,17 @@ final class DatabaseTreeMetadataService { _ = await (tables, routines) } + func refreshLoadedTables(connectionId: UUID) async { + let keys = tablesState.keys.filter { $0.connectionId == connectionId } + for key in keys { + await tablesDedup.cancel(key: key) + tablesState.removeValue(forKey: key) + } + for key in keys { + await loadTables(connectionId: connectionId, database: key.database, schema: key.schema) + } + } + // MARK: - Lifecycle func handleReconnect(connectionId: UUID) async { @@ -292,7 +303,7 @@ final class DatabaseTreeMetadataService { return ObjectsKey(connectionId: connectionId, database: database, schema: normalized) } - static func connectionObjectKeys( + nonisolated static func connectionObjectKeys( tableKeys: some Sequence, routineKeys: some Sequence, connectionId: UUID diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f5cf66aef..4997354a3 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -615,6 +615,7 @@ final class MainContentCoordinator { } catch { Self.logger.warning("Schema refresh failed: \(error.localizedDescription, privacy: .public)") } + await DatabaseTreeMetadataService.shared.refreshLoadedTables(connectionId: connectionId) await reconcilePostSchemaLoad() } diff --git a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift index 853ab846d..89bde780a 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing @Suite("DatabaseTreeMetadataService") @@ -52,3 +53,48 @@ struct DatabaseTreeMetadataServiceTests { #expect(keys == [mine]) } } + +@Suite("DatabaseTreeMetadataService refreshLoadedTables") +@MainActor +struct DatabaseTreeMetadataServiceRefreshTests { + @Test("reload drops previously loaded tables and refetches the current list") + func refreshReloadsLoadedTables() async { + let connection = TestFixtures.makeConnection() + let driver = MockDatabaseDriver(connection: connection) + driver.schemaTablesToReturn = ["public": [TestFixtures.makeTableInfo(name: "users")]] + + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + + let service = DatabaseTreeMetadataService.shared + let database = connection.database + + await service.loadTables(connectionId: connection.id, database: database, schema: "public") + let initial = service.tables(connectionId: connection.id, database: database, schema: "public") + #expect(initial.map(\.name) == ["users"]) + + driver.schemaTablesToReturn = ["public": []] + await service.refreshLoadedTables(connectionId: connection.id) + + let refreshed = service.tables(connectionId: connection.id, database: database, schema: "public") + #expect(refreshed.isEmpty) + + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + @Test("refresh is a no-op when no tables are loaded for the connection") + func refreshWithoutLoadedTablesIsNoOp() async { + let connection = TestFixtures.makeConnection() + + await DatabaseTreeMetadataService.shared.refreshLoadedTables(connectionId: connection.id) + + let tables = DatabaseTreeMetadataService.shared.tables( + connectionId: connection.id, + database: connection.database, + schema: "public" + ) + #expect(tables.isEmpty) + } +} From a9449544989ec884a37cd3a035f91eccdaa917e8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 18:31:19 +0700 Subject: [PATCH 2/2] refactor(sidebar): refresh dropped tables in place to avoid tree churn --- CHANGELOG.md | 2 +- .../Query/DatabaseTreeMetadataService.swift | 30 +++++++++++++--- .../DatabaseTreeMetadataServiceTests.swift | 34 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa7dd148..983290b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A new database group now appears in the connection list right away instead of only after restarting the app. (#1704) - The SQL formatter keeps nested indentation for UNION, UNION ALL, INTERSECT, and EXCEPT inside a derived table or CTE, and puts the closing parenthesis of a subquery on its own line instead of collapsing it onto the last SELECT. (#1698) - Toolbar button tooltips now show each action's real keyboard shortcut and follow your custom bindings, instead of a fixed value. The Switch Connection tooltip showed the wrong shortcut. (#1694) -- Deleting a table from the sidebar now removes it from the tree right away on multi-database servers like MySQL and PostgreSQL. The tree kept showing the dropped table until you refreshed it by hand. +- Deleting a table from the sidebar now removes it from the tree right away on multi-database servers like MySQL and PostgreSQL. The tree kept showing the dropped table until you refreshed it by hand. (#1714) - 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. diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index cd370869a..6806db7b3 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -210,12 +210,32 @@ final class DatabaseTreeMetadataService { func refreshLoadedTables(connectionId: UUID) async { let keys = tablesState.keys.filter { $0.connectionId == connectionId } - for key in keys { - await tablesDedup.cancel(key: key) - tablesState.removeValue(forKey: key) + await withTaskGroup(of: Void.self) { group in + for key in keys { + group.addTask { @MainActor in + await self.reloadTablesInPlace(key) + } + } } - for key in keys { - await loadTables(connectionId: connectionId, database: key.database, schema: key.schema) + } + + private func reloadTablesInPlace(_ key: ObjectsKey) async { + guard isConnected(key.connectionId) else { return } + await tablesDedup.cancel(key: key) + do { + let list = try await tablesDedup.execute(key: key) { [self] in + try await withDriver(connectionId: key.connectionId, database: key.database) { driver in + try await driver.fetchTables(schema: key.schema) + } + } + let next: MetadataLoadState<[TableInfo]> = .loaded(list) + guard tablesState[key] != next else { return } + tablesState[key] = next + } catch is CancellationError { + } catch { + Self.logger.warning( + "tables refresh failed db=\(key.database, privacy: .public) schema=\(key.schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) } } diff --git a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift index 89bde780a..9cc3446b3 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift @@ -84,6 +84,40 @@ struct DatabaseTreeMetadataServiceRefreshTests { DatabaseManager.shared.removeSession(for: connection.id) } + @Test("reload refetches every loaded schema, not just the one that changed") + func refreshReloadsAllLoadedSchemas() async { + let connection = TestFixtures.makeConnection() + let driver = MockDatabaseDriver(connection: connection) + driver.schemaTablesToReturn = [ + "public": [TestFixtures.makeTableInfo(name: "users")], + "sales": [TestFixtures.makeTableInfo(name: "orders")] + ] + + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + + let service = DatabaseTreeMetadataService.shared + let database = connection.database + + await service.loadTables(connectionId: connection.id, database: database, schema: "public") + await service.loadTables(connectionId: connection.id, database: database, schema: "sales") + + driver.schemaTablesToReturn = [ + "public": [], + "sales": [TestFixtures.makeTableInfo(name: "orders"), TestFixtures.makeTableInfo(name: "invoices")] + ] + await service.refreshLoadedTables(connectionId: connection.id) + + let publicTables = service.tables(connectionId: connection.id, database: database, schema: "public") + let salesTables = service.tables(connectionId: connection.id, database: database, schema: "sales") + #expect(publicTables.isEmpty) + #expect(salesTables.map(\.name) == ["orders", "invoices"]) + + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + @Test("refresh is a no-op when no tables are loaded for the connection") func refreshWithoutLoadedTablesIsNoOp() async { let connection = TestFixtures.makeConnection()