diff --git a/CHANGELOG.md b/CHANGELOG.md index b13166d79..bf622ddd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Full-text search across all columns in iOS data browser -- iPad keyboard shortcuts (Cmd+N new connection, Cmd+Return execute query, Cmd+1/2 switch tabs) and trackpad hover effects on list rows -- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite) -- Handoff support for cross-device continuity between iOS and macOS -- State restoration across app lifecycle on iOS (selected connection, active tab, query text, database/schema selection) +- Server Dashboard: active sessions, metrics, slow queries (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite) +- Handoff support between iOS and macOS +- iOS: full-text search in data browser, state restoration, iPad keyboard shortcuts + +### Changed + +- Sidebar table loading refactored: single source of truth, explicit loading states, no race conditions on database switch ### Fixed -- Create Database dialog showing MySQL charset/collation options for all database types; now shows database-specific options (encoding/LC_COLLATE for PostgreSQL, hidden for Redis/etcd) -- SSH Tunnel not working with `~/.ssh/config` profiles (#672): added `Include` directive support, SSH token expansion (`%d`, `%h`, `%u`, `%r`), multi-word `Host` filtering, and detailed handshake error messages +- Create Database dialog now shows correct options per database type (encoding/LC_COLLATE for PostgreSQL, hidden for Redis/etcd) +- SSH tunnel with `~/.ssh/config` profiles (#672): `Include` directives, token expansion, multi-word `Host` filtering ## [0.30.1] - 2026-04-10 diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 39471b396..3e30109c5 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -184,7 +184,6 @@ struct ContentView: View { tableOperationOptions: sessionTableOperationOptionsBinding, databaseType: currentSession.connection.type, connectionId: currentSession.connection.id, - schemaProvider: SchemaProviderRegistry.shared.provider(for: currentSession.connection.id), coordinator: sessionState.coordinator ) } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index e59686723..55ce1ff3e 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -107,27 +107,17 @@ actor SQLSchemaProvider { isLoading } - /// Invalidate cache and reload - func invalidateCache() { - tables.removeAll() - columnCache.removeAll() - columnAccessOrder.removeAll() - 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 + func resetForDatabase(_ database: String?, tables newTables: [TableInfo], driver: DatabaseDriver) { + self.tables = newTables + self.columnCache.removeAll() + self.columnAccessOrder.removeAll() + self.cachedDriver = driver + self.isLoading = false + self.lastLoadError = nil } /// Find table name from alias diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index abd06c50f..42f7a1a94 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -3,63 +3,18 @@ // TablePro // // ViewModel for SidebarView. -// Handles table loading, search filtering, and batch operations. +// Handles search filtering and batch operations. // import Observation -import os import SwiftUI -// MARK: - TableFetcher Protocol - -/// Abstraction over table fetching for testability -protocol TableFetcher: Sendable { - func fetchTables(force: Bool) async throws -> [TableInfo] -} - -private let sidebarLogger = Logger(subsystem: "com.TablePro", category: "SidebarViewModel") - -/// Production implementation that uses DatabaseManager, with optional schema provider cache -struct LiveTableFetcher: TableFetcher { - let connectionId: UUID - let schemaProvider: SQLSchemaProvider? - - init(connectionId: UUID, schemaProvider: SQLSchemaProvider? = nil) { - self.connectionId = connectionId - self.schemaProvider = schemaProvider - } - - func fetchTables(force: Bool) async throws -> [TableInfo] { - if let provider = schemaProvider { - 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 { - sidebarLogger.warning("Driver is nil for connection \(connectionId)") - return [] - } - let fetched = try await driver.fetchTables() - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - sidebarLogger.debug("Fetched \(fetched.count) tables") - if let provider = schemaProvider { - await provider.updateTables(fetched) - } - return fetched - } -} - // MARK: - SidebarViewModel @MainActor @Observable final class SidebarViewModel { // MARK: - Published State - var isLoading = false - var errorMessage: String? var debouncedSearchText = "" var isTablesExpanded: Bool = { let key = "sidebar.isTablesExpanded" @@ -84,11 +39,6 @@ final class SidebarViewModel { var pendingOperationType: TableOperationType? var pendingOperationTables: [String] = [] - // MARK: - Internal State - - /// Prevents selection callback during programmatic updates (e.g., refresh) - var isRestoringSelection = false - // MARK: - Binding Storage private var tablesBinding: Binding<[TableInfo]> @@ -101,8 +51,6 @@ final class SidebarViewModel { // MARK: - Dependencies private let connectionId: UUID - private let tableFetcher: TableFetcher - private var loadTask: Task? // MARK: - Convenience Accessors @@ -140,9 +88,7 @@ final class SidebarViewModel { pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, databaseType: DatabaseType, - connectionId: UUID, - schemaProvider: SQLSchemaProvider? = nil, - tableFetcher: TableFetcher? = nil + connectionId: UUID ) { self.tablesBinding = tables self.selectedTablesBinding = selectedTables @@ -151,94 +97,6 @@ final class SidebarViewModel { self.tableOperationOptionsBinding = tableOperationOptions self.databaseType = databaseType self.connectionId = connectionId - self.tableFetcher = tableFetcher ?? LiveTableFetcher(connectionId: connectionId, schemaProvider: schemaProvider) - } - - // MARK: - Lifecycle - - func onAppear() { - guard tables.isEmpty else { - sidebarLogger.debug("onAppear: tables not empty (\(self.tables.count)), skipping") - return - } - if DatabaseManager.shared.driver(for: connectionId) != nil { - sidebarLogger.debug("onAppear: loading tables") - loadTables() - } else { - sidebarLogger.warning("onAppear: driver is nil for \(self.connectionId)") - } - } - - // MARK: - Table Loading - - func loadTables(force: Bool = false) { - loadTask?.cancel() - guard !isLoading else { return } - isLoading = true - errorMessage = nil - loadTask = Task { - await loadTablesAsync(force: force) - } - } - - func forceLoadTables() { - loadTask?.cancel() - loadTask = nil - isLoading = false - loadTables(force: true) - } - - private func loadTablesAsync(force: Bool = false) async { - let previousSelectedName: String? = tables.isEmpty ? nil : selectedTables.first?.name - - do { - let fetchedTables = try await tableFetcher.fetchTables(force: force) - tables = fetchedTables - - // Clean up stale entries for tables that no longer exist - let fetchedNames = Set(fetchedTables.map(\.name)) - - let staleSelections = selectedTables.filter { !fetchedNames.contains($0.name) } - if !staleSelections.isEmpty { - isRestoringSelection = true - selectedTables.subtract(staleSelections) - isRestoringSelection = false - } - - let stalePendingDeletes = pendingDeletes.subtracting(fetchedNames) - let stalePendingTruncates = pendingTruncates.subtracting(fetchedNames) - if !stalePendingDeletes.isEmpty { - pendingDeletes.subtract(stalePendingDeletes) - for name in stalePendingDeletes { - tableOperationOptions.removeValue(forKey: name) - } - } - if !stalePendingTruncates.isEmpty { - pendingTruncates.subtract(stalePendingTruncates) - for name in stalePendingTruncates { - tableOperationOptions.removeValue(forKey: name) - } - } - - // Only restore selection if it was cleared (prevent reopening tabs) - if let name = previousSelectedName { - let currentNames = Set(selectedTables.map { $0.name }) - if !currentNames.contains(name) { - // Selection was cleared, restore it without triggering callback - isRestoringSelection = true - if let restored = fetchedTables.first(where: { $0.name == name }) { - selectedTables = [restored] - } - isRestoringSelection = false - } - } - isLoading = false - } catch is CancellationError { - isLoading = false - } catch { - errorMessage = error.localizedDescription - isLoading = false - } } // MARK: - Batch Operations diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index f0a227784..c419f4490 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -80,6 +80,6 @@ extension MainContentCoordinator { tabManager.tabs[index].pendingChanges = TabPendingChanges() } - reloadSidebar() + Task { await refreshTables() } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index a8ee1028a..5c8e936be 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -49,7 +49,7 @@ extension MainContentCoordinator { // During database switch, update the existing tab in-place instead of // opening a new native window tab. - if isSwitchingDatabase { + if sidebarLoadingState == .loading { if tabManager.tabs.isEmpty { tabManager.addTableTab( tableName: tableName, @@ -369,23 +369,17 @@ extension MainContentCoordinator { /// Switch to a different database (called from database switcher) func switchDatabase(to database: String) async { - isSwitchingDatabase = true - defer { - isSwitchingDatabase = false - } + sidebarLoadingState = .loading - // Clear stale filter state from previous database/schema filterStateManager.clearAll() guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + sidebarLoadingState = .error(String(localized: "Not connected")) return } - // Snapshot current state for rollback on failure let previousDatabase = toolbarState.databaseName - // Immediately clear UI state so the sidebar shows a loading spinner - // instead of stale tables from the previous database/schema. toolbarState.databaseName = database closeSiblingNativeWindows() tabManager.tabs = [] @@ -393,13 +387,10 @@ extension MainContentCoordinator { DatabaseManager.shared.updateSession(connectionId) { session in session.tables = [] } - // Yield so SwiftUI renders the empty/loading state before async work begins - await Task.yield() do { let pm = PluginManager.shared if pm.requiresReconnectForDatabaseSwitch(for: connection.type) { - // PostgreSQL: full reconnection required for database switch DatabaseManager.shared.updateSession(connectionId) { session in session.connection.database = database session.currentDatabase = database @@ -408,36 +399,31 @@ extension MainContentCoordinator { AppSettingsStorage.shared.saveLastSchema(nil, for: connectionId) await DatabaseManager.shared.reconnectSession(connectionId) } else if pm.supportsSchemaSwitching(for: connection.type) { - // Redshift, Oracle: schema switching - guard let schemaDriver = driver as? SchemaSwitchable else { return } + guard let schemaDriver = driver as? SchemaSwitchable else { + sidebarLoadingState = .idle + return + } try await schemaDriver.switchSchema(to: database) DatabaseManager.shared.updateSession(connectionId) { session in session.currentSchema = database } } else { - // All others (MySQL, MariaDB, ClickHouse, MSSQL, MongoDB, Redis, etc.) if let adapter = driver as? PluginDriverAdapter { try await adapter.switchDatabase(to: database) } let grouping = pm.databaseGroupingStrategy(for: connection.type) DatabaseManager.shared.updateSession(connectionId) { session in session.currentDatabase = database - // Schema-grouped databases (e.g. MSSQL) need currentSchema - // reset to the plugin default (e.g. "dbo") on database switch. if grouping == .bySchema { session.currentSchema = pm.defaultSchemaName(for: connection.type) } } } AppSettingsStorage.shared.saveLastDatabase(database, for: connectionId) - await loadSchema() - await schemaProvider.invalidateTables() - sidebarViewModel?.forceLoadTables() + await refreshTables() } catch { - // Restore toolbar to previous database on failure toolbarState.databaseName = previousDatabase - // Reload previous tables so sidebar isn't left empty - reloadSidebar() + sidebarLoadingState = .error(error.localizedDescription) navigationLogger.error("Failed to switch database: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( @@ -453,13 +439,11 @@ extension MainContentCoordinator { guard PluginManager.shared.supportsSchemaSwitching(for: connection.type) else { return } guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - // Clear stale filter state from previous schema + sidebarLoadingState = .loading filterStateManager.clearAll() - // Snapshot current state for rollback on failure let previousSchema = toolbarState.databaseName - // Immediately clear UI state so sidebar shows loading state toolbarState.databaseName = schema closeSiblingNativeWindows() tabManager.tabs = [] @@ -467,10 +451,12 @@ extension MainContentCoordinator { DatabaseManager.shared.updateSession(connectionId) { session in session.tables = [] } - await Task.yield() do { - guard let schemaDriver = driver as? SchemaSwitchable else { return } + guard let schemaDriver = driver as? SchemaSwitchable else { + sidebarLoadingState = .idle + return + } try await schemaDriver.switchSchema(to: schema) DatabaseManager.shared.updateSession(connectionId) { session in @@ -478,13 +464,10 @@ extension MainContentCoordinator { } AppSettingsStorage.shared.saveLastSchema(schema, for: connectionId) - await loadSchema() - - reloadSidebar() + await refreshTables() } catch { - // Restore toolbar to previous schema on failure toolbarState.databaseName = previousSchema - reloadSidebar() + await refreshTables() navigationLogger.error("Failed to switch schema: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index c97d7ad4d..808cc0770 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -466,8 +466,7 @@ extension MainContentCoordinator { session.currentSchema = schema } toolbarState.databaseName = schema - await loadSchema() - reloadSidebar() + await refreshTables() } catch { Self.logger.warning("Failed to restore schema '\(schema, privacy: .public)': \(error.localizedDescription, privacy: .public)") return diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 405e815f6..753accf7f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -256,7 +256,7 @@ extension MainContentCoordinator { } } - reloadSidebar() + Task { await self.refreshTables() } } if tabManager.selectedTabIndex != nil && !tabManager.tabs.isEmpty { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 11cef1bb8..f0a2f2fbc 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -690,7 +690,9 @@ final class MainContentCommandActions { self?.pendingDeletes.wrappedValue.removeAll() } ) - coordinator?.reloadSidebar() + if let coordinator { + Task { await coordinator.refreshTables() } + } } // MARK: Tab Broadcasts @@ -711,10 +713,8 @@ final class MainContentCommandActions { if let driver = DatabaseManager.shared.driver(for: self.connection.id) { coordinator?.toolbarState.databaseVersion = driver.serverVersion } - // Skip sidebar reload during database switch — switchDatabase() handles it - // after schema invalidation to avoid flashing stale tables. - if coordinator?.isSwitchingDatabase != true { - coordinator?.reloadSidebar() + if coordinator?.sidebarLoadingState != .loading { + await coordinator?.refreshTables() } coordinator?.initRedisKeyTreeIfNeeded() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 64fd4ae1a..b37508711 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -29,6 +29,14 @@ struct QuerySortCacheEntry { let resultVersion: Int } +/// Sidebar table loading state — single source of truth for sidebar UI +enum SidebarLoadingState: Equatable { + case idle + case loading + case loaded + case error(String) +} + /// Represents which sheet is currently active in MainContentView. /// Uses a single `.sheet(item:)` modifier instead of multiple `.sheet(isPresented:)`. enum ActiveSheet: Identifiable { @@ -109,6 +117,7 @@ final class MainContentCoordinator { var activeSheet: ActiveSheet? var importFileURL: URL? var needsLazyLoad = false + var sidebarLoadingState: SidebarLoadingState = .idle /// Cache for async-sorted query tab rows (large datasets sorted on background thread) @ObservationIgnored private(set) var querySortCache: [UUID: QuerySortCacheEntry] = [:] @@ -146,10 +155,6 @@ final class MainContentCoordinator { /// Called during teardown to let the view layer release cached row providers and sort data. @ObservationIgnored var onTeardown: (() -> Void)? - /// True while a database switch is in progress. Guards against - /// side-effect window creation during the switch cascade. - @ObservationIgnored var isSwitchingDatabase = false - /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never /// adopts into @State are silently discarded — no teardown warning needed. @@ -348,10 +353,45 @@ final class MainContentCoordinator { _teardownScheduled.withLock { $0 = false } } - func reloadSidebar() { - Task { @MainActor in - await schemaProvider.invalidateTables() - sidebarViewModel?.forceLoadTables() + func refreshTables() async { + sidebarLoadingState = .loading + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + sidebarLoadingState = .error(String(localized: "Not connected")) + return + } + do { + let tables = try await driver.fetchTables() + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + DatabaseManager.shared.updateSession(connectionId) { $0.tables = tables } + let currentDb = DatabaseManager.shared.session(for: connectionId)?.activeDatabase + await schemaProvider.resetForDatabase(currentDb, tables: tables, driver: driver) + + // Clean up stale selections and pending operations for tables that no longer exist + if let vm = sidebarViewModel { + let validNames = Set(tables.map(\.name)) + let staleSelections = vm.selectedTables.filter { !validNames.contains($0.name) } + if !staleSelections.isEmpty { + vm.selectedTables.subtract(staleSelections) + } + let stalePendingDeletes = vm.pendingDeletes.subtracting(validNames) + if !stalePendingDeletes.isEmpty { + vm.pendingDeletes.subtract(stalePendingDeletes) + for name in stalePendingDeletes { + vm.tableOperationOptions.removeValue(forKey: name) + } + } + let stalePendingTruncates = vm.pendingTruncates.subtracting(validNames) + if !stalePendingTruncates.isEmpty { + vm.pendingTruncates.subtract(stalePendingTruncates) + for name in stalePendingTruncates { + vm.tableOperationOptions.removeValue(forKey: name) + } + } + } + + sidebarLoadingState = .loaded + } catch { + sidebarLoadingState = .error(error.localizedDescription) } } @@ -497,7 +537,6 @@ final class MainContentCoordinator { func loadSchema() async { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - await schemaProvider.invalidateCache() await schemaProvider.loadSchema(using: driver, connection: connection) } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index d3eecdab6..44682b23b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -45,7 +45,6 @@ struct SidebarView: View { tableOperationOptions: Binding<[String: TableOperationOptions]>, databaseType: DatabaseType, connectionId: UUID, - schemaProvider: SQLSchemaProvider? = nil, coordinator: MainContentCoordinator? = nil ) { _tables = tables @@ -64,8 +63,7 @@ struct SidebarView: View { pendingDeletes: pendingDeletes, tableOperationOptions: tableOperationOptions, databaseType: databaseType, - connectionId: connectionId, - schemaProvider: schemaProvider + connectionId: connectionId ) vm.debouncedSearchText = sidebarState.searchText if databaseType == .redis, let existingVM = sidebarState.redisKeyTreeViewModel { @@ -115,16 +113,7 @@ struct SidebarView: View { .onChange(of: sidebarState.searchText) { _, newValue in viewModel.debouncedSearchText = newValue } - .onChange(of: tables) { _, newTables in - let hasSession = DatabaseManager.shared.activeSessions[connectionId] != nil - if newTables.isEmpty && hasSession && !viewModel.isLoading - && coordinator?.isSwitchingDatabase != true - { - viewModel.loadTables() - } - } .onAppear { - viewModel.onAppear() coordinator?.sidebarViewModel = viewModel } .sheet(isPresented: $viewModel.showOperationDialog) { @@ -149,21 +138,20 @@ struct SidebarView: View { @ViewBuilder private var tablesContent: some View { - if let error = viewModel.errorMessage { - errorState(message: error) - } else if tables.isEmpty && hasActiveConnection { + switch coordinator?.sidebarLoadingState ?? .idle { + case .loading: loadingState - } else if tables.isEmpty { + case .error(let message): + errorState(message: message) + case .loaded where tables.isEmpty: emptyState - } else { + case .loaded: tableList + case .idle: + emptyState } } - private var hasActiveConnection: Bool { - viewModel.isLoading - } - private var loadingState: some View { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index 890108946..cf750cfd8 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -239,8 +239,8 @@ struct SQLSchemaProviderTests { #expect(driver.fetchColumnsCallCount == countBeforeBC + 2) } - @Test("invalidateCache clears tables, columns, and LRU tracking") - func invalidateCacheClearsEverything() async { + @Test("resetForDatabase clears columns, updates tables, and sets driver") + func resetForDatabaseClearsAndUpdates() async { let driver = MockDatabaseDriver() driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] driver.columnsToReturn = [ @@ -251,13 +251,18 @@ struct SQLSchemaProviderTests { await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) _ = await provider.getColumns(for: "users") - await provider.invalidateCache() + let newTables = [TestFixtures.makeTableInfo(name: "orders")] + let newDriver = MockDatabaseDriver() + await provider.resetForDatabase("new_db", tables: newTables, driver: newDriver) let tables = await provider.getTables() - #expect(tables.isEmpty) + #expect(tables.count == 1) + #expect(tables.first?.name == "orders") - let columns = await provider.getColumns(for: "users") - #expect(columns.isEmpty) + // Column cache should be cleared (requires re-fetch) + newDriver.columnsToReturn = ["orders": [TestFixtures.makeColumnInfo(name: "order_id")]] + let columns = await provider.getColumns(for: "orders") + #expect(columns.first?.name == "order_id") } @Test("getColumns returns empty when driver is not available") diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index bba26e7cd..6fd041573 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -228,8 +228,8 @@ struct CoordinatorConnectionIsolationTests { #expect(coordinator2.connectionId == id2) } - @Test("isSwitchingDatabase is per-coordinator and does not bleed across instances") - func isSwitchingDatabaseIsPerCoordinator() { + @Test("sidebarLoadingState is per-coordinator and does not bleed across instances") + func sidebarLoadingStateIsPerCoordinator() { let conn1 = TestFixtures.makeConnection(id: UUID(), name: "Conn1", database: "db_a", type: .mysql) let conn2 = TestFixtures.makeConnection(id: UUID(), name: "Conn2", database: "db_b", type: .mysql) @@ -253,10 +253,10 @@ struct CoordinatorConnectionIsolationTests { ) defer { coordinator2.teardown() } - coordinator1.isSwitchingDatabase = true + coordinator1.sidebarLoadingState = .loading - #expect(coordinator1.isSwitchingDatabase == true) - #expect(coordinator2.isSwitchingDatabase == false) + #expect(coordinator1.sidebarLoadingState == .loading) + #expect(coordinator2.sidebarLoadingState == .idle) } @Test("openTableTab uses coordinator's connection database for the added tab") diff --git a/TableProTests/ViewModels/LiveTableFetcherTests.swift b/TableProTests/ViewModels/LiveTableFetcherTests.swift deleted file mode 100644 index 0305d226a..000000000 --- a/TableProTests/ViewModels/LiveTableFetcherTests.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// LiveTableFetcherTests.swift -// TableProTests -// -// Tests for LiveTableFetcher schema provider cache integration. -// - -import Foundation -import Testing -@testable import TablePro - -// MARK: - Mock DatabaseDriver - -private class MockDatabaseDriver: DatabaseDriver { - let connection: DatabaseConnection - var status: ConnectionStatus = .connected - var serverVersion: String? = nil - - var tablesToReturn: [TableInfo] = [] - var fetchTablesCallCount = 0 - - init(connection: DatabaseConnection = TestFixtures.makeConnection()) { - self.connection = connection - } - - func connect() async throws {} - func disconnect() {} - func testConnection() async throws -> Bool { true } - func applyQueryTimeout(_ seconds: Int) async throws {} - - func execute(query: String) async throws -> QueryResult { .empty } - func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { .empty } - func fetchRowCount(query: String) async throws -> Int { 0 } - func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult { .empty } - - func fetchTables() async throws -> [TableInfo] { - fetchTablesCallCount += 1 - return tablesToReturn - } - - func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } - func fetchAllColumns() async throws -> [String: [ColumnInfo]] { [:] } - func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } - func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } - func fetchApproximateRowCount(table: String) async throws -> Int? { nil } - func fetchTableDDL(table: String) async throws -> String { "" } - func fetchViewDefinition(view: String) async throws -> String { "" } - func fetchTableMetadata(tableName: String) async throws -> TableMetadata { - TableMetadata( - tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, - avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, - collation: nil, createTime: nil, updateTime: nil - ) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchSchemas() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { - DatabaseMetadata( - id: database, name: database, tableCount: nil, sizeBytes: nil, - lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" - ) - } - func createDatabase(name: String, charset: String, collation: String?) async throws {} - func cancelQuery() throws {} - func beginTransaction() async throws {} - func commitTransaction() async throws {} - func rollbackTransaction() async throws {} -} - -// MARK: - Tests - -@Suite("LiveTableFetcher") -struct LiveTableFetcherTests { - - @Test("returns cached tables from schema provider when available") - func returnsCachedTablesFromSchemaProvider() async throws { - let expectedTables = [ - TestFixtures.makeTableInfo(name: "users"), - TestFixtures.makeTableInfo(name: "orders"), - TestFixtures.makeTableInfo(name: "products") - ] - - let mockDriver = MockDatabaseDriver() - mockDriver.tablesToReturn = expectedTables - - let provider = SQLSchemaProvider() - await provider.loadSchema(using: mockDriver) - - let initialCallCount = mockDriver.fetchTablesCallCount - #expect(initialCallCount == 1) - - let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) - let result = try await fetcher.fetchTables(force: false) - - #expect(result.count == 3) - #expect(result.map(\.name) == ["users", "orders", "products"]) - #expect(mockDriver.fetchTablesCallCount == initialCallCount) - } - - @Test("falls back to driver when schema provider has no cached tables") - func fallsBackWhenSchemaProviderEmpty() async throws { - let provider = SQLSchemaProvider() - - let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) - let result = try await fetcher.fetchTables(force: false) - - #expect(result.isEmpty) - } - - @Test("works without schema provider using direct driver fetch") - func worksWithoutSchemaProvider() async throws { - let fetcher = LiveTableFetcher(connectionId: UUID()) - let result = try await fetcher.fetchTables(force: false) - - #expect(result.isEmpty) - } - - @Test("schema provider with loaded tables returns them directly") - func schemaProviderReturnsLoadedTablesConsistently() async throws { - let expectedTables = [ - TestFixtures.makeTableInfo(name: "accounts"), - TestFixtures.makeTableInfo(name: "transactions") - ] - - let mockDriver = MockDatabaseDriver() - mockDriver.tablesToReturn = expectedTables - - let provider = SQLSchemaProvider() - await provider.loadSchema(using: mockDriver) - - let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider) - - for _ in 0..<3 { - 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 167f5cd0d..90b0e9e61 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -10,22 +10,6 @@ import SwiftUI import Testing @testable import TablePro -// MARK: - Mock TableFetcher - -private struct MockTableFetcher: TableFetcher { - var tables: [TableInfo] - var error: Error? - - func fetchTables(force: Bool) async throws -> [TableInfo] { - if let error { throw error } - return tables - } -} - -private enum TestError: Error { - case fetchFailed -} - // MARK: - Helper /// Creates a SidebarViewModel with controllable state bindings for testing @@ -36,9 +20,7 @@ private func makeSUT( pendingTruncates: Set = [], pendingDeletes: Set = [], tableOperationOptions: [String: TableOperationOptions] = [:], - databaseType: DatabaseType = .mysql, - fetcherTables: [TableInfo] = [], - fetcherError: Error? = nil + databaseType: DatabaseType = .mysql ) -> ( vm: SidebarViewModel, tables: Binding<[TableInfo]>, @@ -59,7 +41,6 @@ private func makeSUT( let deletesBinding = Binding(get: { deletesState }, set: { deletesState = $0 }) let optionsBinding = Binding(get: { optionsState }, set: { optionsState = $0 }) - let fetcher = MockTableFetcher(tables: fetcherTables, error: fetcherError) let vm = SidebarViewModel( tables: tablesBinding, selectedTables: selectedBinding, @@ -67,8 +48,7 @@ private func makeSUT( pendingDeletes: deletesBinding, tableOperationOptions: optionsBinding, databaseType: databaseType, - connectionId: UUID(), - tableFetcher: fetcher + connectionId: UUID() ) return (vm, tablesBinding, selectedBinding, truncatesBinding, deletesBinding, optionsBinding) @@ -79,125 +59,6 @@ private func makeSUT( @Suite("SidebarViewModel") struct SidebarViewModelTests { - // MARK: - Table Loading - - @Test("loadTables sets isLoading and populates tables") - @MainActor - func loadTablesPopulatesTables() async throws { - let fetchedTables = [ - TestFixtures.makeTableInfo(name: "users"), - TestFixtures.makeTableInfo(name: "orders") - ] - let (vm, tablesBinding, _, _, _, _) = makeSUT(fetcherTables: fetchedTables) - - vm.loadTables() - // Wait for async task to complete - try await Task.sleep(nanoseconds: 100_000_000) - - #expect(tablesBinding.wrappedValue.count == 2) - #expect(!vm.isLoading) - #expect(vm.errorMessage == nil) - } - - @Test("loadTables handles fetch error gracefully") - @MainActor - func loadTablesHandlesError() async throws { - let (vm, _, _, _, _, _) = makeSUT(fetcherError: TestError.fetchFailed) - - vm.loadTables() - try await Task.sleep(nanoseconds: 100_000_000) - - #expect(!vm.isLoading) - #expect(vm.errorMessage != nil) - } - - @Test("loadTables guards against concurrent loads") - @MainActor - func loadTablesGuardsConcurrent() { - let (vm, _, _, _, _, _) = makeSUT(fetcherTables: [TestFixtures.makeTableInfo(name: "t1")]) - - vm.loadTables() - // Second call while first is loading should be a no-op - #expect(vm.isLoading) - vm.loadTables() // Should not crash or double-load - } - - // MARK: - Stale Cleanup - - @Test("removes stale selections after refresh") - @MainActor - func removesStaleSelections() async throws { - let oldTable = TestFixtures.makeTableInfo(name: "old_table") - let newTable = TestFixtures.makeTableInfo(name: "new_table") - - let (vm, _, selectedBinding, _, _, _) = makeSUT( - tables: [oldTable], - selectedTables: [oldTable], - fetcherTables: [newTable] - ) - - vm.loadTables() - try await Task.sleep(nanoseconds: 100_000_000) - - // old_table was removed from fetched results, so selection should be cleared - let selectedNames = selectedBinding.wrappedValue.map(\.name) - #expect(!selectedNames.contains("old_table")) - } - - @Test("removes stale pending deletes") - @MainActor - func removesStaleDeletes() async throws { - let (vm, _, _, _, deletesBinding, optionsBinding) = makeSUT( - pendingDeletes: ["gone_table"], - tableOperationOptions: ["gone_table": TableOperationOptions()], - fetcherTables: [TestFixtures.makeTableInfo(name: "users")] - ) - - vm.loadTables() - try await Task.sleep(nanoseconds: 100_000_000) - - #expect(!deletesBinding.wrappedValue.contains("gone_table")) - #expect(optionsBinding.wrappedValue["gone_table"] == nil) - } - - @Test("removes stale pending truncates") - @MainActor - func removesStaletruncates() async throws { - let (vm, _, _, truncatesBinding, _, optionsBinding) = makeSUT( - pendingTruncates: ["gone_table"], - tableOperationOptions: ["gone_table": TableOperationOptions()], - fetcherTables: [TestFixtures.makeTableInfo(name: "users")] - ) - - vm.loadTables() - try await Task.sleep(nanoseconds: 100_000_000) - - #expect(!truncatesBinding.wrappedValue.contains("gone_table")) - #expect(optionsBinding.wrappedValue["gone_table"] == nil) - } - - // MARK: - Selection Restoration - - @Test("preserves selection when table still exists after refresh") - @MainActor - func preservesSelectionAfterRefresh() async throws { - let usersTable = TestFixtures.makeTableInfo(name: "users") - let fetchedUsers = TestFixtures.makeTableInfo(name: "users") - - let (vm, _, selectedBinding, _, _, _) = makeSUT( - tables: [usersTable], - selectedTables: [usersTable], - fetcherTables: [fetchedUsers] - ) - - vm.loadTables() - try await Task.sleep(nanoseconds: 100_000_000) - - // Selection should be restored to the "users" table from fetched results - let selectedNames = selectedBinding.wrappedValue.map(\.name) - #expect(selectedNames.contains("users")) - } - // MARK: - Batch Toggle Truncate @Test("batchToggleTruncate shows dialog for new tables") diff --git a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift index a495ce564..f92afe6e5 100644 --- a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift +++ b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift @@ -1,10 +1,9 @@ // -// CoordinatorReloadSidebarTests.swift +// CoordinatorRefreshTablesTests.swift // TableProTests // -// Tests for MainContentCoordinator.reloadSidebar() — -// verifies it delegates to sidebarViewModel.forceLoadTables() -// and is safe when the weak reference is nil. +// Tests for MainContentCoordinator.refreshTables() — +// verifies it updates sidebarLoadingState and populates session tables. // import SwiftUI @@ -12,31 +11,11 @@ import Testing @testable import TablePro -// MARK: - Mock TableFetcher - -/// Tracks fetch calls via a thread-safe counter for verifying forceLoadTables() delegation. -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 forceCount: Int { lock.withLock { _forceCount } } - - func fetchTables(force: Bool) async throws -> [TableInfo] { - lock.withLock { - _fetchCount += 1 - if force { _forceCount += 1 } - } - return [] - } -} - -@Suite("CoordinatorReloadSidebar") -struct CoordinatorReloadSidebarTests { - @Test("reloadSidebar calls forceLoadTables when sidebarViewModel is set") +@Suite("CoordinatorRefreshTables") +struct CoordinatorRefreshTablesTests { + @Test("refreshTables sets loading state to error when no driver") @MainActor - func callsForceLoadTablesWhenViewModelSet() async { + func setsErrorWhenNoDriver() async { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let changeManager = DataChangeManager() @@ -53,64 +32,16 @@ struct CoordinatorReloadSidebarTests { ) defer { coordinator.teardown() } - var tables: [TableInfo] = [] - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [String: TableOperationOptions] = [:] - - let mockFetcher = FetchTrackingTableFetcher() - - let sidebarVM = SidebarViewModel( - tables: Binding(get: { tables }, set: { tables = $0 }), - selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), - pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), - pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), - tableOperationOptions: Binding(get: { tableOperationOptions }, set: { tableOperationOptions = $0 }), - databaseType: .mysql, - connectionId: connection.id, - tableFetcher: mockFetcher - ) - - coordinator.sidebarViewModel = sidebarVM - - coordinator.reloadSidebar() - - // forceLoadTables triggers an async Task internally, give it time to execute - try? await Task.sleep(nanoseconds: 100_000_000) - - #expect(mockFetcher.fetchCount > 0) - #expect(mockFetcher.forceCount > 0) - } - - @Test("reloadSidebar is safe when sidebarViewModel is nil") - @MainActor - func safeWhenViewModelNil() { - let connection = TestFixtures.makeConnection(database: "db_a") - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } + #expect(coordinator.sidebarLoadingState == .idle) - #expect(coordinator.sidebarViewModel == nil) + await coordinator.refreshTables() - // Should not crash - coordinator.reloadSidebar() + #expect(coordinator.sidebarLoadingState == .error("Not connected")) } - @Test("reloadSidebar is safe after sidebarViewModel is deallocated") + @Test("sidebarLoadingState defaults to idle") @MainActor - func safeAfterViewModelDeallocated() { + func defaultsToIdle() { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() let changeManager = DataChangeManager() @@ -127,30 +58,6 @@ struct CoordinatorReloadSidebarTests { ) defer { coordinator.teardown() } - var tables: [TableInfo] = [] - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [String: TableOperationOptions] = [:] - - // Create and assign in a local scope so it gets deallocated - do { - let sidebarVM = SidebarViewModel( - tables: Binding(get: { tables }, set: { tables = $0 }), - selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), - pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), - pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), - tableOperationOptions: Binding(get: { tableOperationOptions }, set: { tableOperationOptions = $0 }), - databaseType: .mysql, - connectionId: connection.id - ) - coordinator.sidebarViewModel = sidebarVM - } - - // Weak reference should be nil after the local scope ends - #expect(coordinator.sidebarViewModel == nil) - - // Should not crash - coordinator.reloadSidebar() + #expect(coordinator.sidebarLoadingState == .idle) } } diff --git a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift index 83c56e9a1..a277355be 100644 --- a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift +++ b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift @@ -98,7 +98,7 @@ struct MultiConnectionNavigationTests { #expect(tab.databaseName == "primary_db") } - // Note: isSwitchingDatabase guard test lives in SwitchDatabaseTests.swift + // Note: sidebarLoadingState guard test lives in SwitchDatabaseTests.swift // MARK: - openTableTab: different database types create correct tab diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index 103625486..31e37aaad 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -5,7 +5,7 @@ // Tests for openTableTab logic — verifies skip/open behavior // based on current tab state and database context. // -// Note: isSwitchingDatabase guard and same-table fast path tests +// Note: sidebarLoadingState guard and same-table fast path tests // live in SwitchDatabaseTests.swift to avoid duplication. // diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index cbc623808..9e41c71fa 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -13,16 +13,6 @@ import Testing @testable import TablePro -// MARK: - Mock TableFetcher - -private struct MockTableFetcher: TableFetcher { - var tables: [TableInfo] - - func fetchTables(force: Bool) async throws -> [TableInfo] { - tables - } -} - // MARK: - Helpers /// Simulates the tab-clearing logic from switchDatabase(to:). @@ -37,33 +27,11 @@ private func simulateDatabaseSwitch( @Suite("SwitchDatabase") struct SwitchDatabaseTests { - // MARK: - isSwitchingDatabase flag - - @Test("isSwitchingDatabase defaults to false") - @MainActor - func flagDefaultsToFalse() { - let connection = TestFixtures.makeConnection() - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } - - #expect(coordinator.isSwitchingDatabase == false) - } + // MARK: - sidebarLoadingState - @Test("isSwitchingDatabase can be set to true") + @Test("sidebarLoadingState defaults to idle") @MainActor - func flagCanBeSetToTrue() { + func loadingStateDefaultsToIdle() { let connection = TestFixtures.makeConnection() let tabManager = QueryTabManager() let changeManager = DataChangeManager() @@ -80,13 +48,12 @@ struct SwitchDatabaseTests { ) defer { coordinator.teardown() } - coordinator.isSwitchingDatabase = true - #expect(coordinator.isSwitchingDatabase == true) + #expect(coordinator.sidebarLoadingState == .idle) } // MARK: - openTableTab behavior during database switch - @Test("openTableTab skips new window when switching database with existing tabs") + @Test("openTableTab skips new window when sidebar is loading with existing tabs") @MainActor func openTableTabSkipsNewWindowDuringSwitch() { let connection = TestFixtures.makeConnection(database: "db_a") @@ -105,23 +72,17 @@ struct SwitchDatabaseTests { ) defer { coordinator.teardown() } - // Set up: one existing tab tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") let tabCountBefore = tabManager.tabs.count - // Simulate database switch in progress - coordinator.isSwitchingDatabase = true + coordinator.sidebarLoadingState = .loading - // Opening a different table during switch should NOT add more tabs - // (because the guard returns early without calling WindowOpener) coordinator.openTableTab("orders") - // Tab count should remain unchanged — no new tab was added - // (isSwitchingDatabase guard returns early when tabs exist) #expect(tabManager.tabs.count == tabCountBefore) } - @Test("openTableTab adds tab in-place when switching database with empty tabs") + @Test("openTableTab adds tab in-place when sidebar is loading with empty tabs") @MainActor func openTableTabAddsInPlaceWhenSwitchingWithEmptyTabs() { let connection = TestFixtures.makeConnection(database: "db_a") @@ -140,13 +101,10 @@ struct SwitchDatabaseTests { ) defer { coordinator.teardown() } - // No existing tabs #expect(tabManager.tabs.isEmpty) - // Simulate database switch in progress - coordinator.isSwitchingDatabase = true + coordinator.sidebarLoadingState = .loading - // Opening a table during switch with empty tabs should add in-place coordinator.openTableTab("users") #expect(tabManager.tabs.count == 1) @@ -225,52 +183,33 @@ struct SwitchDatabaseTests { #expect(tabManager.selectedTabId == nil) } - // MARK: - SidebarViewModel selection during database switch + // MARK: - sidebarLoadingState during database switch - @Test("SidebarViewModel skips selection restore during database switch") + @Test("switchDatabase sets sidebarLoadingState to loading then error when no driver") @MainActor - func sidebarSkipsSelectionRestoreDuringSwitch() async throws { - let newTables = [ - TestFixtures.makeTableInfo(name: "orders"), - TestFixtures.makeTableInfo(name: "products") - ] - - // Start with empty tables and empty selection (simulates state after - // switchDatabase clears session.tables) - var tablesState: [TableInfo] = [] - var selectedState: Set = [] - var truncatesState: Set = [] - var deletesState: Set = [] - var optionsState: [String: TableOperationOptions] = [:] - - let tablesBinding = Binding(get: { tablesState }, set: { tablesState = $0 }) - let selectedBinding = Binding(get: { selectedState }, set: { selectedState = $0 }) - let truncatesBinding = Binding(get: { truncatesState }, set: { truncatesState = $0 }) - let deletesBinding = Binding(get: { deletesState }, set: { deletesState = $0 }) - let optionsBinding = Binding(get: { optionsState }, set: { optionsState = $0 }) - - let fetcher = MockTableFetcher(tables: newTables) - let vm = SidebarViewModel( - tables: tablesBinding, - selectedTables: selectedBinding, - pendingTruncates: truncatesBinding, - pendingDeletes: deletesBinding, - tableOperationOptions: optionsBinding, - databaseType: .mysql, - connectionId: UUID(), - tableFetcher: fetcher + func switchDatabaseSetsLoadingState() async { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let changeManager = DataChangeManager() + let filterStateManager = FilterStateManager() + let toolbarState = ConnectionToolbarState() + + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: changeManager, + filterStateManager: filterStateManager, + columnVisibilityManager: ColumnVisibilityManager(), + toolbarState: toolbarState ) + defer { coordinator.teardown() } - // When tables list is empty (cleared by switchDatabase), previousSelectedName - // should be nil so no stale table name is restored as a selection - vm.loadTables() - try await Task.sleep(nanoseconds: 100_000_000) + #expect(coordinator.sidebarLoadingState == .idle) - // Tables should be populated from fetcher - #expect(tablesBinding.wrappedValue.count == 2) + await coordinator.switchDatabase(to: "db_b") - // No selection should be restored because there was no previous selection - // to preserve (tables were empty when loadTablesAsync captured previousSelectedName) - #expect(selectedBinding.wrappedValue.isEmpty) + // Without a driver, switchDatabase sets loading then returns early + // refreshTables will set error state since there's no driver + #expect(coordinator.sidebarLoadingState == .error("Not connected")) } }