From 1de0bcfd1f4b550ec968af9a82e81ce417b3de2d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 13:18:17 +0700 Subject: [PATCH 1/6] fix: sidebar not refreshing after creating or dropping tables --- CHANGELOG.md | 1 + .../Core/Autocomplete/SQLSchemaProvider.swift | 15 +++++ TablePro/ViewModels/SidebarViewModel.swift | 29 +++++---- .../Views/Main/MainContentCoordinator.swift | 5 +- .../ViewModels/LiveTableFetcherTests.swift | 62 +++++++++++++++++-- .../ViewModels/SidebarViewModelTests.swift | 2 +- .../Main/CoordinatorReloadSidebarTests.swift | 14 +++-- TableProTests/Views/SwitchDatabaseTests.swift | 2 +- 8 files changed, 107 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f5a7341..d553dc343 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 - SSH port field accepting invalid values - DROP TABLE and TRUNCATE TABLE sidebar operations producing no SQL for plugin-based drivers - Foreign key navigation arrows not appearing after switching databases with Cmd+K on MySQL +- Sidebar not refreshing after creating or dropping tables ## [0.19.1] - 2026-03-16 diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 30e922e20..100caeba4 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -101,6 +101,21 @@ actor SQLSchemaProvider { cachedDriver = nil } + func invalidateTables() { + tables.removeAll() + } + + func updateTables(_ newTables: [TableInfo]) { + tables = newTables + } + + func fetchFreshTables() async throws -> [TableInfo] { + guard let driver = cachedDriver else { return [] } + let fresh = try await driver.fetchTables() + tables = fresh + return fresh + } + /// Find table name from alias func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? { // First check if it's an alias diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 0e1b448c0..318b727a0 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -13,7 +13,7 @@ import SwiftUI /// Abstraction over table fetching for testability protocol TableFetcher: Sendable { - func fetchTables() async throws -> [TableInfo] + func fetchTables(force: Bool) async throws -> [TableInfo] } /// Production implementation that uses DatabaseManager, with optional schema provider cache @@ -26,17 +26,24 @@ struct LiveTableFetcher: TableFetcher { self.schemaProvider = schemaProvider } - func fetchTables() async throws -> [TableInfo] { + func fetchTables(force: Bool) async throws -> [TableInfo] { if let provider = schemaProvider { - let cached = await provider.getTables() - if !cached.isEmpty { - return cached + if force { + let fresh = try await provider.fetchFreshTables() + if !fresh.isEmpty { return fresh } + } else { + let cached = await provider.getTables() + if !cached.isEmpty { return cached } } } guard let driver = await DatabaseManager.shared.driver(for: connectionId) else { return [] } - return try await driver.fetchTables() + let fetched = try await driver.fetchTables() + if let provider = schemaProvider { + await provider.updateTables(fetched) + } + return fetched } } @@ -145,12 +152,12 @@ final class SidebarViewModel { // MARK: - Table Loading - func loadTables() { + func loadTables(force: Bool = false) { guard !isLoading else { return } isLoading = true errorMessage = nil loadTask = Task { - await loadTablesAsync() + await loadTablesAsync(force: force) } } @@ -158,14 +165,14 @@ final class SidebarViewModel { loadTask?.cancel() loadTask = nil isLoading = false - loadTables() + loadTables(force: true) } - private func loadTablesAsync() async { + private func loadTablesAsync(force: Bool = false) async { let previousSelectedName: String? = tables.isEmpty ? nil : selectedTables.first?.name do { - let fetchedTables = try await tableFetcher.fetchTables() + let fetchedTables = try await tableFetcher.fetchTables(force: force) tables = fetchedTables // Clean up stale entries for tables that no longer exist diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index c6dcb4513..b0851b9c5 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -313,7 +313,10 @@ final class MainContentCoordinator { } func reloadSidebar() { - sidebarViewModel?.forceLoadTables() + Task { + await schemaProvider.invalidateTables() + sidebarViewModel?.forceLoadTables() + } } /// Explicit cleanup called from `onDisappear`. Releases schema provider diff --git a/TableProTests/ViewModels/LiveTableFetcherTests.swift b/TableProTests/ViewModels/LiveTableFetcherTests.swift index c0fc487d3..8025e6a9b 100644 --- a/TableProTests/ViewModels/LiveTableFetcherTests.swift +++ b/TableProTests/ViewModels/LiveTableFetcherTests.swift @@ -90,7 +90,7 @@ struct LiveTableFetcherTests { #expect(initialCallCount == 1) let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) - let result = try await fetcher.fetchTables() + let result = try await fetcher.fetchTables(force: false) #expect(result.count == 3) #expect(result.map(\.name) == ["users", "orders", "products"]) @@ -102,7 +102,7 @@ struct LiveTableFetcherTests { let provider = SQLSchemaProvider() let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) - let result = try await fetcher.fetchTables() + let result = try await fetcher.fetchTables(force: false) #expect(result.isEmpty) } @@ -110,7 +110,7 @@ struct LiveTableFetcherTests { @Test("works without schema provider using direct driver fetch") func worksWithoutSchemaProvider() async throws { let fetcher = LiveTableFetcher(connectionId: UUID()) - let result = try await fetcher.fetchTables() + let result = try await fetcher.fetchTables(force: false) #expect(result.isEmpty) } @@ -131,11 +131,65 @@ struct LiveTableFetcherTests { let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) for _ in 0..<3 { - let result = try await fetcher.fetchTables() + let result = try await fetcher.fetchTables(force: false) #expect(result.count == 2) #expect(result.map(\.name) == ["accounts", "transactions"]) } #expect(mockDriver.fetchTablesCallCount == 1) } + + @Test("force: true bypasses schema provider cache and hits driver") + func forceBypassesCache() async throws { + let initialTables = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + + let mockDriver = MockDatabaseDriver() + mockDriver.tablesToReturn = initialTables + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: mockDriver) + + let freshTables = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders"), + TestFixtures.makeTableInfo(name: "new_table") + ] + mockDriver.tablesToReturn = freshTables + + let callCountBefore = mockDriver.fetchTablesCallCount + + let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) + let result = try await fetcher.fetchTables(force: true) + + #expect(result.count == 3) + #expect(result.map(\.name) == ["users", "orders", "new_table"]) + #expect(mockDriver.fetchTablesCallCount == callCountBefore + 1) + } + + @Test("force: true writes fresh tables back into schema provider") + func forcedFetchUpdatesSchemaProvider() async throws { + let initialTables = [TestFixtures.makeTableInfo(name: "old_table")] + + let mockDriver = MockDatabaseDriver() + mockDriver.tablesToReturn = initialTables + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: mockDriver) + + await provider.invalidateTables() + let freshTables = [ + TestFixtures.makeTableInfo(name: "alpha"), + TestFixtures.makeTableInfo(name: "beta") + ] + mockDriver.tablesToReturn = freshTables + + let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) + _ = try await fetcher.fetchTables(force: true) + + let cached = await provider.getTables() + #expect(cached.map(\.name).sorted() == ["alpha", "beta"]) + } } diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index d357710ee..167f5cd0d 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -16,7 +16,7 @@ private struct MockTableFetcher: TableFetcher { var tables: [TableInfo] var error: Error? - func fetchTables() async throws -> [TableInfo] { + func fetchTables(force: Bool) async throws -> [TableInfo] { if let error { throw error } return tables } diff --git a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift index fdb7a33c6..a495ce564 100644 --- a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift +++ b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift @@ -18,13 +18,16 @@ import Testing private final class FetchTrackingTableFetcher: TableFetcher, @unchecked Sendable { private let lock = NSLock() private var _fetchCount = 0 + private var _forceCount = 0 - var fetchCount: Int { - lock.withLock { _fetchCount } - } + var fetchCount: Int { lock.withLock { _fetchCount } } + var forceCount: Int { lock.withLock { _forceCount } } - func fetchTables() async throws -> [TableInfo] { - lock.withLock { _fetchCount += 1 } + func fetchTables(force: Bool) async throws -> [TableInfo] { + lock.withLock { + _fetchCount += 1 + if force { _forceCount += 1 } + } return [] } } @@ -77,6 +80,7 @@ struct CoordinatorReloadSidebarTests { try? await Task.sleep(nanoseconds: 100_000_000) #expect(mockFetcher.fetchCount > 0) + #expect(mockFetcher.forceCount > 0) } @Test("reloadSidebar is safe when sidebarViewModel is nil") diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index fa11e28a6..cbc623808 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -18,7 +18,7 @@ import Testing private struct MockTableFetcher: TableFetcher { var tables: [TableInfo] - func fetchTables() async throws -> [TableInfo] { + func fetchTables(force: Bool) async throws -> [TableInfo] { tables } } From 64f7a8f976789387562016a571c8d51a4f7b8403 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 13:24:27 +0700 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20disam?= =?UTF-8?q?biguate=20nil=20driver=20from=20empty=20DB,=20explicit=20@MainA?= =?UTF-8?q?ctor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/Core/Autocomplete/SQLSchemaProvider.swift | 4 ++-- TablePro/ViewModels/SidebarViewModel.swift | 3 +-- TablePro/Views/Main/MainContentCoordinator.swift | 2 +- TableProTests/ViewModels/LiveTableFetcherTests.swift | 1 - 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 100caeba4..aad57abe0 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -109,8 +109,8 @@ actor SQLSchemaProvider { tables = newTables } - func fetchFreshTables() async throws -> [TableInfo] { - guard let driver = cachedDriver else { return [] } + func fetchFreshTables() async throws -> [TableInfo]? { + guard let driver = cachedDriver else { return nil } let fresh = try await driver.fetchTables() tables = fresh return fresh diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 318b727a0..ead4c00a0 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -29,8 +29,7 @@ struct LiveTableFetcher: TableFetcher { func fetchTables(force: Bool) async throws -> [TableInfo] { if let provider = schemaProvider { if force { - let fresh = try await provider.fetchFreshTables() - if !fresh.isEmpty { return fresh } + if let fresh = try await provider.fetchFreshTables() { return fresh } } else { let cached = await provider.getTables() if !cached.isEmpty { return cached } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index b0851b9c5..cc80b3de7 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -313,7 +313,7 @@ final class MainContentCoordinator { } func reloadSidebar() { - Task { + Task { @MainActor in await schemaProvider.invalidateTables() sidebarViewModel?.forceLoadTables() } diff --git a/TableProTests/ViewModels/LiveTableFetcherTests.swift b/TableProTests/ViewModels/LiveTableFetcherTests.swift index 8025e6a9b..0305d226a 100644 --- a/TableProTests/ViewModels/LiveTableFetcherTests.swift +++ b/TableProTests/ViewModels/LiveTableFetcherTests.swift @@ -179,7 +179,6 @@ struct LiveTableFetcherTests { let provider = SQLSchemaProvider() await provider.loadSchema(using: mockDriver) - await provider.invalidateTables() let freshTables = [ TestFixtures.makeTableInfo(name: "alpha"), TestFixtures.makeTableInfo(name: "beta") From 0a4b01d95217080a9ab4d9e4cd4b9eacceaee448 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 13:32:29 +0700 Subject: [PATCH 3/6] fix: remove tab instead of closing window when dropping active table --- CHANGELOG.md | 1 + .../MainContentCoordinator+SaveChanges.swift | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d553dc343..baaa7b038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - DROP TABLE and TRUNCATE TABLE sidebar operations producing no SQL for plugin-based drivers - Foreign key navigation arrows not appearing after switching databases with Cmd+K on MySQL - Sidebar not refreshing after creating or dropping tables +- Dropping a table disconnecting the database when the dropped table's tab was active ## [0.19.1] - 2026-03-16 diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index aae7decfd..6c4d0ddb9 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -225,12 +225,21 @@ extension MainContentCoordinator { } if clearTableOps { - // Close tabs for deleted tables + // Remove tabs for deleted tables if !deletedTables.isEmpty { - if let currentTab = tabManager.selectedTab, - let tableName = currentTab.tableName, - deletedTables.contains(tableName) { - NSApp.keyWindow?.close() + let tabIdsToRemove = Set( + tabManager.tabs + .filter { $0.tabType == .table && deletedTables.contains($0.tableName ?? "") } + .map(\.id) + ) + + if !tabIdsToRemove.isEmpty { + tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } + if let firstRemaining = tabManager.tabs.first { + tabManager.selectedTabId = firstRemaining.id + } else { + tabManager.selectedTabId = nil + } } } From 7500e5e89c8967d8e518f8d0071e361f4252eb33 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 13:38:32 +0700 Subject: [PATCH 4/6] fix: select nearest neighbor tab after dropping a table --- .../Extensions/MainContentCoordinator+SaveChanges.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 6c4d0ddb9..3ce1d2178 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -234,9 +234,12 @@ extension MainContentCoordinator { ) if !tabIdsToRemove.isEmpty { + let firstRemovedIndex = tabManager.tabs + .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } - if let firstRemaining = tabManager.tabs.first { - tabManager.selectedTabId = firstRemaining.id + if !tabManager.tabs.isEmpty { + let neighborIndex = min(firstRemovedIndex, tabManager.tabs.count - 1) + tabManager.selectedTabId = tabManager.tabs[neighborIndex].id } else { tabManager.selectedTabId = nil } From 7d65198bf55019e3724dd289c1435e4ab7b17885 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 13:39:47 +0700 Subject: [PATCH 5/6] fix: remove hardcoded team ID from pbxproj to not break contributor builds --- TablePro.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 2016d9310..088944185 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -1783,7 +1783,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 35; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1856,7 +1856,7 @@ CURRENT_PROJECT_VERSION = 35; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; From df7bf14de6f0d3815640717c69bf95d78e2420cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 17 Mar 2026 13:46:53 +0700 Subject: [PATCH 6/6] fix: restore development team for main target, keep empty for plugins --- TablePro.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 088944185..2016d9310 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -1783,7 +1783,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 35; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1856,7 +1856,7 @@ CURRENT_PROJECT_VERSION = 35; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES;