diff --git a/CHANGELOG.md b/CHANGELOG.md index e710a4152..749783be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The autocomplete popup now filters in place as you type instead of closing and reopening on every keystroke. (#1608) - Syntax highlighting no longer disappears after formatting a query. (#1612) - The GitHub Copilot provider no longer shows a Max output tokens field it ignores, and picking a Copilot model no longer leaves a stray model ID field behind. +- Clicking a table that's already open switches to its existing tab instead of opening a duplicate. (#1613) - MongoDB now connects over an SSH or Cloudflare tunnel instead of bypassing it and failing with a connection refused error. (#1621) ## [0.49.1] - 2026-06-06 diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 751302e7f..278811dde 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -152,11 +152,7 @@ internal final class TabRouter { } ?? true return databaseMatches && schemaMatches }) else { continue } - coordinator.tabManager.selectedTabId = match.id - if let windowId = coordinator.windowId, - let window = WindowLifecycleMonitor.shared.window(for: windowId) { - window.makeKeyAndOrderFront(nil) - } + coordinator.selectTabAndFocusWindow(match.id) return true } return false diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index fbf1a012c..81880558d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -18,7 +18,6 @@ extension MainContentCoordinator { func openTableTab( _ table: TableInfo, showStructure: Bool = false, - redirectToSibling: Bool = false, forceNonPreview: Bool = false, activateGridFocus: Bool = false ) { @@ -27,7 +26,6 @@ extension MainContentCoordinator { schema: table.schema, showStructure: showStructure, isView: table.type == .view, - redirectToSibling: redirectToSibling, forceNonPreview: forceNonPreview, activateGridFocus: activateGridFocus ) @@ -38,7 +36,6 @@ extension MainContentCoordinator { schema: String? = nil, showStructure: Bool = false, isView: Bool = false, - redirectToSibling: Bool = false, forceNonPreview: Bool = false, activateGridFocus: Bool = false ) { @@ -59,18 +56,14 @@ extension MainContentCoordinator { let resolvedSchema = schema let createAsPreview = !forceNonPreview && AppSettingsManager.shared.tabs.enablePreviewTabs - // Fast path: if this table is already the active tab in the same database, skip all work - if let current = tabManager.selectedTab, - current.tabType == .table, - current.tableContext.tableName == tableName, - current.tableContext.databaseName == currentDatabase, - current.tableContext.schemaName == resolvedSchema { - if showStructure, let (_, tabIndex) = tabManager.selectedTabAndIndex { - tabManager.mutate(at: tabIndex) { $0.display.resultsViewMode = .structure } - } - if activateGridFocus { - focusActiveGrid() - } + if activateIfAlreadyOpen( + tableName: tableName, + databaseName: currentDatabase, + schemaName: resolvedSchema, + showStructure: showStructure, + activateGridFocus: activateGridFocus, + includeSiblings: navigationModel != .inPlace + ) { return } @@ -98,28 +91,6 @@ extension MainContentCoordinator { return } - // 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 } - pendingGridFocusOnOpen = false - window.makeKeyAndOrderFront(nil) - return - } - } - // If no tabs exist (empty state), add a table tab directly. if tabManager.tabs.isEmpty { addFirstTableTab( @@ -191,6 +162,50 @@ extension MainContentCoordinator { WindowManager.shared.openTab(payload: payload) } + func activateIfAlreadyOpen( + tableName: String, + databaseName: String, + schemaName: String?, + showStructure: Bool, + activateGridFocus: Bool, + includeSiblings: Bool + ) -> Bool { + func matches(_ tab: QueryTab) -> Bool { + tab.tabType == .table + && tab.tableContext.tableName == tableName + && tab.tableContext.databaseName == databaseName + && tab.tableContext.schemaName == schemaName + } + + if let match = tabManager.tabs.first(where: matches) { + if tabManager.selectedTabId != match.id { + tabManager.selectedTabId = match.id + } + applyStructureMode(showStructure, toTab: match.id, in: tabManager) + if activateGridFocus { + requestGridFocus() + } + return true + } + + guard includeSiblings else { return false } + + for sibling in MainContentCoordinator.allActiveCoordinators() + where sibling !== self && sibling.connectionId == connectionId { + guard let match = sibling.tabManager.tabs.first(where: matches) else { continue } + sibling.pendingGridFocusOnOpen = activateGridFocus + applyStructureMode(showStructure, toTab: match.id, in: sibling.tabManager) + sibling.selectTabAndFocusWindow(match.id) + return true + } + return false + } + + private func applyStructureMode(_ showStructure: Bool, toTab tabId: UUID, in tabManager: QueryTabManager) { + guard showStructure, let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + tabManager.mutate(at: index) { $0.display.resultsViewMode = .structure } + } + private func addFirstTableTab( tableName: String, currentDatabase: String, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 998fa874b..ed6a6678a 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, redirectToSibling: true, activateGridFocus: true) + openTableTab(item.name, activateGridFocus: true) case .view: - openTableTab(item.name, isView: true, redirectToSibling: true, activateGridFocus: true) + openTableTab(item.name, isView: true, activateGridFocus: true) case .database: Task { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index f023a59b8..2f8ff62c1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -97,6 +97,13 @@ extension MainContentCoordinator { ) } + func selectTabAndFocusWindow(_ tabId: UUID) { + tabManager.selectedTabId = tabId + guard let windowId, + let window = WindowLifecycleMonitor.shared.window(for: windowId) else { return } + window.makeKeyAndOrderFront(nil) + } + // MARK: - Sidebar Sync /// Update the window-scoped sidebar selection so the active table tab diff --git a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift index 52c0e945e..0973f4ce2 100644 --- a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift +++ b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift @@ -248,4 +248,58 @@ struct MultiConnectionNavigationTests { #expect(tabManagerB.tabs.count == tabCountBefore) #expect(tabManagerB.tabs.first?.tableContext.tableName == "orders") } + + // MARK: - Cross-window deduplication (issue #1613) + + @Test("openTableTab activates a sibling window's tab instead of duplicating when the table is already open") + @MainActor + func openTableTabActivatesSiblingInsteadOfDuplicating() throws { + let connectionId = UUID() + let (coordinatorA, tabManagerA) = makeCoordinator(id: connectionId, name: "Conn", database: "db_a") + let (coordinatorB, tabManagerB) = makeCoordinator(id: connectionId, name: "Conn", database: "db_a") + coordinatorA.registerEagerly() + coordinatorB.registerEagerly() + defer { + coordinatorA.teardown() + coordinatorB.teardown() + } + + try tabManagerA.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + try tabManagerA.addTableTab(tableName: "accounts", databaseType: .mysql, databaseName: "db_a") + #expect(tabManagerA.selectedTab?.tableContext.tableName == "accounts") + try tabManagerB.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_a") + + coordinatorB.openTableTab("users") + + #expect(tabManagerB.tabs.count == 1) + #expect(tabManagerB.tabs.first?.tableContext.tableName == "orders") + #expect(tabManagerA.selectedTab?.tableContext.tableName == "users") + } + + @Test("openTableTab does not dedupe against a sibling on a different connection") + @MainActor + func openTableTabIgnoresSiblingOnDifferentConnection() throws { + let (coordinatorA, tabManagerA) = makeCoordinator(name: "ConnA", database: "db_a") + let (coordinatorB, tabManagerB) = makeCoordinator(name: "ConnB", database: "db_b") + coordinatorA.registerEagerly() + coordinatorB.registerEagerly() + defer { + coordinatorA.teardown() + coordinatorB.teardown() + } + + try tabManagerA.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + + let activated = coordinatorB.activateIfAlreadyOpen( + tableName: "users", + databaseName: "db_b", + schemaName: nil, + showStructure: false, + activateGridFocus: false, + includeSiblings: true + ) + + #expect(activated == false) + #expect(tabManagerB.tabs.isEmpty) + } } diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index 6b82fed88..343bccd49 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -194,6 +194,90 @@ struct OpenTableTabTests { #expect(tabManager.selectedTab?.isPreview == false) } + // MARK: - Activate already-open tab (issue #1613) + + @Test("Clicking a table open in a non-selected tab selects it instead of duplicating") + @MainActor + func clickingTableInNonSelectedTabSelectsIt() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try tabManager.addTableTab(tableName: "users", databaseType: connection.type, databaseName: "db_a") + try tabManager.addTableTab(tableName: "orders", databaseType: connection.type, databaseName: "db_a") + #expect(tabManager.tabs.count == 2) + #expect(tabManager.selectedTab?.tableContext.tableName == "orders") + + coordinator.openTableTab("users") + + #expect(tabManager.tabs.count == 2) + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + } + + @Test("activateIfAlreadyOpen returns false when no open tab matches") + @MainActor + func activateIfAlreadyOpenReturnsFalseWhenNoMatch() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try tabManager.addTableTab(tableName: "orders", databaseType: connection.type, databaseName: "db_a") + + let activated = coordinator.activateIfAlreadyOpen( + tableName: "users", + databaseName: "db_a", + schemaName: nil, + showStructure: false, + activateGridFocus: false, + includeSiblings: true + ) + + #expect(activated == false) + #expect(tabManager.selectedTab?.tableContext.tableName == "orders") + } + + @Test("activateIfAlreadyOpen selects an existing in-window tab and applies structure mode") + @MainActor + func activateIfAlreadyOpenSelectsExistingTabWithStructure() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try tabManager.addTableTab(tableName: "users", databaseType: connection.type, databaseName: "db_a") + try tabManager.addTableTab(tableName: "orders", databaseType: connection.type, databaseName: "db_a") + + let activated = coordinator.activateIfAlreadyOpen( + tableName: "users", + databaseName: "db_a", + schemaName: nil, + showStructure: true, + activateGridFocus: false, + includeSiblings: true + ) + + #expect(activated == true) + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + #expect(tabManager.selectedTab?.display.resultsViewMode == .structure) + } + @MainActor private static func makeCoordinator() -> MainContentCoordinator { MainContentCoordinator(