diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f5a734..baaa7b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ 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 +- Dropping a table disconnecting the database when the dropped table's tab was active ## [0.19.1] - 2026-03-16 diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 30e922e2..aad57abe 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 nil } + 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 0e1b448c..ead4c00a 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,23 @@ 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 { + if let fresh = try await provider.fetchFreshTables() { 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 +151,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 +164,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/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index aae7decf..3ce1d217 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -225,12 +225,24 @@ 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 { + let firstRemovedIndex = tabManager.tabs + .firstIndex { tabIdsToRemove.contains($0.id) } ?? 0 + tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) } + if !tabManager.tabs.isEmpty { + let neighborIndex = min(firstRemovedIndex, tabManager.tabs.count - 1) + tabManager.selectedTabId = tabManager.tabs[neighborIndex].id + } else { + tabManager.selectedTabId = nil + } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index c6dcb451..cc80b3de 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 { @MainActor in + 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 c0fc487d..0305d226 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,64 @@ 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) + + 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 d357710e..167f5cd0 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 fdb7a33c..a495ce56 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 fa11e28a..cbc62380 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 } }