diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afcb236b..b56af253a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- New query tab (Cmd+T) no longer jumps focus back to the previous table tab on SQLite and other file-based databases (#1313) +- File-based databases (SQLite, DuckDB) no longer flash the sidebar table list every time a window becomes key; external file changes are picked up reactively via the file watcher instead of polling on focus - PostgreSQL connections to AWS RDS, Cloud SQL, Azure, and other hosted Postgres now succeed out of the box instead of failing with "no pg_hba.conf entry for host" (#1298) - Oracle: SSL/TCPS settings from the SSL pane are now respected; previously every Oracle connection was plain TCP regardless of SSL mode - Cassandra: SSL settings from the SSL pane are now respected; previously every Cassandra connection was plain TCP because the plugin read from a non-existent "sslMode" field diff --git a/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift b/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift index f003d17de..2cfbe624a 100644 --- a/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift +++ b/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift @@ -62,7 +62,7 @@ final class DatabaseFileWatcher { let fd = open(path, O_EVTONLY) guard fd >= 0 else { - Self.logger.warning("Cannot open database file for watching: \(path, privacy: .public)") + Self.logger.error("Cannot open database file for watching: \(path, privacy: .public) errno=\(errno)") return } @@ -85,9 +85,11 @@ final class DatabaseFileWatcher { activeSources[connectionId] = source source.resume() + Self.logger.info("watching connId=\(connectionId, privacy: .public) path=\(path, privacy: .public)") } private func handleEvent(connectionId: UUID) { + Self.logger.info("file event connId=\(connectionId, privacy: .public)") // Re-create the watcher to get a fresh file descriptor. // SQLite journaling (rename + recreate) can invalidate the old fd. startSource(connectionId: connectionId) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 93b3b7c60..1ab93cc8d 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -330,6 +330,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) -> some View { SidebarView( sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), + windowState: sessionState.coordinator.windowSidebarState, onDoubleClick: { [weak self] table in guard let coordinator = self?.sessionState?.coordinator else { return } let connectionId = coordinator.connectionId diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 390066c4c..9b43e6fee 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -76,8 +76,8 @@ internal final class SidebarContainerViewController: NSViewController { generation: Int ) { withObservationTracking { - _ = state.searchText _ = state.selectedSidebarTab + _ = windowState.searchText _ = windowState.favoritesSearchText } onChange: { [weak self] in Task { @MainActor [weak self] in @@ -96,7 +96,7 @@ internal final class SidebarContainerViewController: NSViewController { let placeholder: String switch state.selectedSidebarTab { case .tables: - activeText = state.searchText + activeText = windowState.searchText placeholder = String(localized: "Filter") case .favorites: activeText = windowState.favoritesSearchText @@ -121,12 +121,12 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { } private func writeSearchText(_ text: String) { - guard let sidebarState else { return } + guard let sidebarState, let windowState else { return } switch sidebarState.selectedSidebarTab { case .tables: - sidebarState.searchText = text + windowState.searchText = text case .favorites: - windowState?.favoritesSearchText = text + windowState.favoritesSearchText = text } } } diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 2131fd3d2..7badc73dc 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -17,7 +17,6 @@ final class SchemaService { private(set) var functions: [UUID: [RoutineInfo]] = [:] private(set) var schemasInOrder: [UUID: [String]] = [:] - @ObservationIgnored private var lastLoadDates: [UUID: Date] = [:] @ObservationIgnored private let loadDedup = OnceTask() @ObservationIgnored private let procedureDedup = OnceTask() @ObservationIgnored private let functionDedup = OnceTask() @@ -74,20 +73,6 @@ final class SchemaService { await runLoad(connectionId: connectionId, driver: driver, connection: connection) } - func reloadIfStale( - connectionId: UUID, - driver: DatabaseDriver, - connection: DatabaseConnection, - staleness: TimeInterval - ) async { - guard let lastLoad = lastLoadDates[connectionId] else { - await reload(connectionId: connectionId, driver: driver, connection: connection) - return - } - guard Date().timeIntervalSince(lastLoad) > staleness else { return } - await reload(connectionId: connectionId, driver: driver, connection: connection) - } - func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async { do { let routines = try await procedureDedup.execute(key: connectionId) { @@ -127,7 +112,6 @@ final class SchemaService { procedures.removeValue(forKey: connectionId) functions.removeValue(forKey: connectionId) schemasInOrder.removeValue(forKey: connectionId) - lastLoadDates.removeValue(forKey: connectionId) } func refresh(connectionId: UUID) async { @@ -176,7 +160,6 @@ final class SchemaService { states[connectionId] = .loaded(tables) procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions - lastLoadDates[connectionId] = Date() } catch is CancellationError { return } catch { diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index e0ea4d157..4c4817143 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -2,8 +2,9 @@ // SharedSidebarState.swift // TablePro // -// Shared sidebar state (selection + search + tab) for cross-tab synchronization. -// One instance per connection, shared across all native macOS tabs. +// Connection-scoped sidebar state shared across all windows of the same +// connection. Window-scoped state (table selection) lives in +// `WindowSidebarState`. // import Foundation @@ -16,8 +17,6 @@ internal enum SidebarTab: String, CaseIterable { @MainActor @Observable final class SharedSidebarState { - var selectedTables: Set = [] - var searchText: String = "" var redisKeyTreeViewModel: RedisKeyTreeViewModel? var selectedSidebarTab: SidebarTab { diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index a22616560..e09da4b3f 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -5,9 +5,12 @@ import Foundation import Observation +import TableProPluginKit @MainActor @Observable internal final class WindowSidebarState { + var selectedTables: Set = [] + var searchText: String = "" var favoritesSearchText: String = "" } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 6fe48011e..97792f131 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -15,16 +15,23 @@ private let navigationLogger = Logger(subsystem: "com.TablePro", category: "Main extension MainContentCoordinator { // MARK: - Table Tab Opening - func openTableTab(_ table: TableInfo, showStructure: Bool = false) { + func openTableTab(_ table: TableInfo, showStructure: Bool = false, redirectToSibling: Bool = false) { openTableTab( table.name, schema: table.schema, showStructure: showStructure, - isView: table.type == .view + isView: table.type == .view, + redirectToSibling: redirectToSibling ) } - func openTableTab(_ tableName: String, schema: String? = nil, showStructure: Bool = false, isView: Bool = false) { + func openTableTab( + _ tableName: String, + schema: String? = nil, + showStructure: Bool = false, + isView: Bool = false, + redirectToSibling: Bool = false + ) { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard @@ -71,20 +78,25 @@ extension MainContentCoordinator { return } - // Check if another native window tab already has this table open — switch to it - for sibling in MainContentCoordinator.allActiveCoordinators() - where sibling !== self && sibling.connectionId == connectionId { - let hasMatch = sibling.tabManager.tabs.contains { tab in - tab.tabType == .table - && tab.tableContext.tableName == tableName - && tab.tableContext.databaseName == currentDatabase - && tab.tableContext.schemaName == resolvedSchema + // Opt-in cross-window navigation: if requested (e.g. quick switcher), + // and another window already shows this table, focus that window. + // Default-off so sidebar clicks and other window-local actions stay + // window-local instead of stealing focus to a sibling. + if redirectToSibling { + for sibling in MainContentCoordinator.allActiveCoordinators() + where sibling !== self && sibling.connectionId == connectionId { + let hasMatch = sibling.tabManager.tabs.contains { tab in + tab.tabType == .table + && tab.tableContext.tableName == tableName + && tab.tableContext.databaseName == currentDatabase + && tab.tableContext.schemaName == resolvedSchema + } + guard hasMatch, + let windowId = sibling.windowId, + let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue } + window.makeKeyAndOrderFront(nil) + return } - guard hasMatch, - let windowId = sibling.windowId, - let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue } - window.makeKeyAndOrderFront(nil) - return } // If no tabs exist (empty state), add a table tab directly. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 672b6af39..c3c7f1cca 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -15,10 +15,10 @@ extension MainContentCoordinator { func handleQuickSwitcherSelection(_ item: QuickSwitcherItem) { switch item.kind { case .table, .systemTable: - openTableTab(item.name) + openTableTab(item.name, redirectToSibling: true) case .view: - openTableTab(item.name, isView: true) + openTableTab(item.name, isView: true, redirectToSibling: true) case .database: Task { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index ab882efe1..a8ddd5076 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -29,12 +29,6 @@ extension MainContentCoordinator { evictionTask?.cancel() evictionTask = nil - let isConnected = - DatabaseManager.shared.activeSessions[connectionId]?.isConnected ?? false - if PluginManager.shared.connectionMode(for: connection.type) == .fileBased && isConnected { - Task { await self.refreshTablesIfStale() } - } - syncSidebarToSelectedTab() Self.lifecycleLogger.debug( @@ -90,11 +84,10 @@ extension MainContentCoordinator { // MARK: - Sidebar Sync - /// Update the connection-scoped sidebar selection so the active table tab + /// Update the window-scoped sidebar selection so the active table tab /// is highlighted. Reads tables fresh from the DatabaseManager because the /// schema load is async and may complete after focus changes. func syncSidebarToSelectedTab() { - let sidebarState = SharedSidebarState.forConnection(connectionId) let liveTables = DatabaseManager.shared .session(for: connectionId)?.tables ?? [] let target: Set @@ -104,9 +97,9 @@ extension MainContentCoordinator { } else { target = [] } - if sidebarState.selectedTables != target { + if windowSidebarState.selectedTables != target { if target.isEmpty && liveTables.isEmpty { return } - sidebarState.selectedTables = target + windowSidebarState.selectedTables = target } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 2f5801633..6daa63dec 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -133,8 +133,7 @@ extension MainContentView { /// Only writes when the value actually changes, preventing spurious onChange triggers. /// Navigation safety is guaranteed by `SidebarNavigationResult.resolve` returning `.skip` /// when the selected table matches the current tab. - /// Reads from DatabaseManager (authoritative source) instead of the `tables` binding, - /// and skips background windows to avoid overwriting shared sidebar state. + /// Reads from DatabaseManager (authoritative source) instead of the `tables` binding. func syncSidebarToCurrentTab() { guard coordinator.isKeyWindow else { return } let liveTables = DatabaseManager.shared.session(for: connection.id)?.tables ?? [] @@ -146,9 +145,9 @@ extension MainContentView { } else { target = [] } - if sidebarState.selectedTables != target { + if coordinator.windowSidebarState.selectedTables != target { if target.isEmpty && liveTables.isEmpty { return } - sidebarState.selectedTables = target + coordinator.windowSidebarState.selectedTables = target } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 450c00f36..33daba0b8 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -274,8 +274,8 @@ extension MainContentView { connection: connection, selectionState: coordinator.selectionState, selectedTables: Binding( - get: { sidebarState.selectedTables }, - set: { sidebarState.selectedTables = $0 } + get: { coordinator.windowSidebarState.selectedTables }, + set: { coordinator.windowSidebarState.selectedTables = $0 } ), pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 01455e286..8c5ca9554 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -480,19 +480,6 @@ final class MainContentCoordinator { fileWatcher = watcher } - /// Refresh schema only if not recently refreshed (avoids redundant work - /// when both the file watcher and window focus trigger close together). - func refreshTablesIfStale() async { - guard let driver = services.databaseManager.driver(for: connectionId) else { return } - await services.schemaService.reloadIfStale( - connectionId: connectionId, - driver: driver, - connection: connection, - staleness: 2 - ) - await reconcilePostSchemaLoad() - } - func showAIChatPanel() { inspectorProxy?.showInspector() rightPanelState?.activeTab = .aiChat diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 864542161..2e1e51883 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -187,7 +187,7 @@ struct MainContentView: View { mode: .tables( connection: exportConnection, preselectedTables: coordinator.exportPreselectedTableNames - ?? Set(sidebarState.selectedTables.map(\.name)) + ?? Set(coordinator.windowSidebarState.selectedTables.map(\.name)) ), sidebarTables: tables ) @@ -379,9 +379,9 @@ struct MainContentView: View { handleConnectionStatusChange() } - .onChange(of: sidebarState.selectedTables) { oldTables, newTables in + .onChange(of: coordinator.windowSidebarState.selectedTables) { oldTables, newTables in guard !coordinator.isTearingDown else { - Self.lifecycleLogger.debug("[switch] sidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") + Self.lifecycleLogger.debug("[switch] windowSidebarState.selectedTables SKIPPED (tearingDown) windowId=\(windowId, privacy: .public)") return } handleTableSelectionChange(from: oldTables, to: newTables) @@ -389,13 +389,13 @@ struct MainContentView: View { .onChange(of: tables) { _, newTables in let syncAction = SidebarSyncAction.resolveOnTablesLoad( newTables: newTables, - selectedTables: sidebarState.selectedTables, + selectedTables: coordinator.windowSidebarState.selectedTables, currentTabTableName: tabManager.selectedTab?.tableContext.tableName ) if case .select(let tableName) = syncAction, let match = newTables.first(where: { $0.name == tableName }) { - sidebarState.selectedTables = [match] + coordinator.windowSidebarState.selectedTables = [match] } } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index d4f31ef45..fb24fe9f2 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -13,6 +13,7 @@ struct SidebarView: View { @Bindable private var schemaService = SchemaService.shared var sidebarState: SharedSidebarState + var windowState: WindowSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set @@ -44,13 +45,14 @@ struct SidebarView: View { private var selectedTablesBinding: Binding> { Binding( - get: { sidebarState.selectedTables }, - set: { sidebarState.selectedTables = $0 } + get: { windowState.selectedTables }, + set: { windowState.selectedTables = $0 } ) } init( sidebarState: SharedSidebarState, + windowState: WindowSidebarState, onDoubleClick: ((TableInfo) -> Void)? = nil, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -60,12 +62,13 @@ struct SidebarView: View { coordinator: MainContentCoordinator? = nil ) { self.sidebarState = sidebarState + self.windowState = windowState self.onDoubleClick = onDoubleClick _pendingTruncates = pendingTruncates _pendingDeletes = pendingDeletes let selectedBinding = Binding( - get: { sidebarState.selectedTables }, - set: { sidebarState.selectedTables = $0 } + get: { windowState.selectedTables }, + set: { windowState.selectedTables = $0 } ) let vm = SidebarViewModel( selectedTables: selectedBinding, @@ -75,7 +78,7 @@ struct SidebarView: View { databaseType: databaseType, connectionId: connectionId ) - vm.searchText = sidebarState.searchText + vm.searchText = windowState.searchText if databaseType == .redis, let existingVM = sidebarState.redisKeyTreeViewModel { vm.redisKeyTreeViewModel = existingVM } @@ -109,7 +112,7 @@ struct SidebarView: View { } } } - .onChange(of: sidebarState.searchText) { _, newValue in + .onChange(of: windowState.searchText) { _, newValue in viewModel.searchText = newValue } .onAppear { @@ -228,7 +231,7 @@ struct SidebarView: View { onDoubleClick?(table) } .onExitCommand { - sidebarState.selectedTables.removeAll() + windowState.selectedTables.removeAll() } } @@ -277,7 +280,7 @@ struct SidebarView: View { .contextMenu { SidebarContextMenu( clickedTable: table, - selectedTables: sidebarState.selectedTables, + selectedTables: windowState.selectedTables, isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, @@ -343,6 +346,7 @@ struct SidebarView: View { #Preview { SidebarView( sidebarState: SharedSidebarState(), + windowState: WindowSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), tableOperationOptions: .constant([:]), diff --git a/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift b/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift index 0988f3a57..c93905857 100644 --- a/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerTabTitleTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @Suite("QueryTabManager.tabTitle") +@MainActor struct QueryTabManagerTabTitleTests { @Test("Returns plain name when schema is nil") func nilSchemaReturnsName() { diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index 793feddca..48226b15e 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -3,6 +3,8 @@ // TableProTests // // Tests for SharedSidebarState — per-connection shared sidebar state registry. +// Window-scoped state (selection, search) lives in WindowSidebarState; see +// WindowSidebarStateTests. // import Foundation @@ -54,100 +56,16 @@ struct SharedSidebarStateTests { SharedSidebarState.removeConnection(UUID()) } - // MARK: - Default State + // MARK: - Sidebar Tab Persistence - @Test("New instance has empty selectedTables") + @Test("selectedSidebarTab persists across registry lookups for same connection") @MainActor - func defaultSelectedTablesEmpty() { - let state = SharedSidebarState() - #expect(state.selectedTables.isEmpty) - } - - @Test("New instance has empty searchText") - @MainActor - func defaultSearchTextEmpty() { - let state = SharedSidebarState() - #expect(state.searchText.isEmpty) - } - - // MARK: - State Mutation - - @Test("Setting selectedTables persists") - @MainActor - func selectedTablesPersists() { - let state = SharedSidebarState() - let table = TestFixtures.makeTableInfo(name: "users") - state.selectedTables = [table] - #expect(state.selectedTables.count == 1) - #expect(state.selectedTables.first?.name == "users") - } - - @Test("Setting searchText persists") - @MainActor - func searchTextPersists() { - let state = SharedSidebarState() - state.searchText = "user" - #expect(state.searchText == "user") - } - - // MARK: - Shared Reference Semantics - - @Test("Changes via one reference are visible through another") - @MainActor - func sharedReferenceSemantics() { + func selectedSidebarTabPersists() { let id = UUID() let a = SharedSidebarState.forConnection(id) + a.selectedSidebarTab = .favorites let b = SharedSidebarState.forConnection(id) - let table = TestFixtures.makeTableInfo(name: "orders") - a.selectedTables = [table] - #expect(b.selectedTables.count == 1) - #expect(b.selectedTables.first?.name == "orders") - a.searchText = "ord" - #expect(b.searchText == "ord") + #expect(b.selectedSidebarTab == .favorites) SharedSidebarState.removeConnection(id) } - - @Test("Clearing selectedTables is visible through shared reference") - @MainActor - func clearingSelectionShared() { - let id = UUID() - let a = SharedSidebarState.forConnection(id) - let b = SharedSidebarState.forConnection(id) - a.selectedTables = [TestFixtures.makeTableInfo(name: "users")] - #expect(!b.selectedTables.isEmpty) - a.selectedTables = [] - #expect(b.selectedTables.isEmpty) - SharedSidebarState.removeConnection(id) - } - - // MARK: - Disconnect Cleanup - - @Test("removeConnection clears state for that connection") - @MainActor - func removeConnectionClearsState() { - let id = UUID() - let state = SharedSidebarState.forConnection(id) - state.selectedTables = [TestFixtures.makeTableInfo(name: "users")] - state.searchText = "us" - SharedSidebarState.removeConnection(id) - // New instance should have clean state - let fresh = SharedSidebarState.forConnection(id) - #expect(fresh.selectedTables.isEmpty) - #expect(fresh.searchText.isEmpty) - SharedSidebarState.removeConnection(id) - } - - @Test("removeConnection does not affect other connections") - @MainActor - func removeDoesNotAffectOthers() { - let id1 = UUID() - let id2 = UUID() - let state1 = SharedSidebarState.forConnection(id1) - let state2 = SharedSidebarState.forConnection(id2) - state1.selectedTables = [TestFixtures.makeTableInfo(name: "a")] - state2.selectedTables = [TestFixtures.makeTableInfo(name: "b")] - SharedSidebarState.removeConnection(id1) - #expect(state2.selectedTables.first?.name == "b") - SharedSidebarState.removeConnection(id2) - } } diff --git a/TableProTests/ViewModels/WindowSidebarStateTests.swift b/TableProTests/ViewModels/WindowSidebarStateTests.swift new file mode 100644 index 000000000..152d470bb --- /dev/null +++ b/TableProTests/ViewModels/WindowSidebarStateTests.swift @@ -0,0 +1,50 @@ +// +// WindowSidebarStateTests.swift +// TableProTests +// +// Pins per-window scoping of sidebar state. Regression guard for #1313 where +// selectedTables was shared across windows of the same connection, causing +// Cmd+T to jump focus back to a sibling window. +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@MainActor +struct WindowSidebarStateTests { + @Test + func twoInstancesHoldIndependentSelection() { + let windowA = WindowSidebarState() + let windowB = WindowSidebarState() + + let users = TestFixtures.makeTableInfo(name: "users") + windowA.selectedTables = [users] + + #expect(windowA.selectedTables == [users]) + #expect(windowB.selectedTables.isEmpty) + } + + @Test + func twoInstancesHoldIndependentSearchText() { + let windowA = WindowSidebarState() + let windowB = WindowSidebarState() + + windowA.searchText = "users" + + #expect(windowA.searchText == "users") + #expect(windowB.searchText.isEmpty) + } + + @Test + func twoInstancesHoldIndependentFavoritesSearch() { + let windowA = WindowSidebarState() + let windowB = WindowSidebarState() + + windowA.favoritesSearchText = "daily" + + #expect(windowA.favoritesSearchText == "daily") + #expect(windowB.favoritesSearchText.isEmpty) + } +}