diff --git a/CHANGELOG.md b/CHANGELOG.md index eb92d95d4..7af8d349a 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. (#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. - 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. diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index d1dd93e77..6806db7b3 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -208,6 +208,37 @@ final class DatabaseTreeMetadataService { _ = await (tables, routines) } + func refreshLoadedTables(connectionId: UUID) async { + let keys = tablesState.keys.filter { $0.connectionId == connectionId } + await withTaskGroup(of: Void.self) { group in + for key in keys { + group.addTask { @MainActor in + await self.reloadTablesInPlace(key) + } + } + } + } + + 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)" + ) + } + } + // MARK: - Lifecycle func handleReconnect(connectionId: UUID) async { @@ -292,7 +323,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 96dfda6da..6baf302a2 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 c9bd4b4ac..c2042bccb 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") @@ -53,3 +54,82 @@ 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("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() + + await DatabaseTreeMetadataService.shared.refreshLoadedTables(connectionId: connection.id) + + let tables = DatabaseTreeMetadataService.shared.tables( + connectionId: connection.id, + database: connection.database, + schema: "public" + ) + #expect(tables.isEmpty) + } +}