diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c1cf960..3d106907d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Raw SQL filter now suggests columns and keywords at every position in the expression, including after AND and OR, instead of only the first column. (#1346) - Plugins left incompatible after a TablePro update now update quietly in the background instead of showing a premature "could not be loaded" alert. You are only notified when no compatible version exists yet, and the message tells you what to do. (#1322) - A plugin you download and install by hand is no longer blocked by macOS Gatekeeper once its signature is verified. (#1322) +- Clicking a table now reliably replaces the active tab instead of opening a new tab once you have more than one tab open. A new tab still opens when the current tab has unsaved edits, an applied filter, or sorting. Double-click to open a table in its own tab. (#1348) ## [0.43.3] - 2026-05-22 diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 09e370f70..40ca11e9c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -331,18 +331,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi windowState: sessionState.coordinator.windowSidebarState, onDoubleClick: { [weak self] table in guard let coordinator = self?.sessionState?.coordinator else { return } - let connectionId = coordinator.connectionId - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId), - let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { - if previewCoordinator.tabManager.selectedTab?.tableContext.tableName == table.name { - previewCoordinator.promotePreviewTab() - } else { - previewCoordinator.promotePreviewTab() - coordinator.openTableTab(table) - } - } else { + let activeTab = coordinator.tabManager.selectedTab + if activeTab?.tabType == .table, activeTab?.tableContext.tableName == table.name { coordinator.promotePreviewTab() - coordinator.openTableTab(table) + } else { + coordinator.openTableTab(table, forceNonPreview: true) } }, pendingTruncates: sessionPendingTruncatesBinding, diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 6ebeb8af2..192675436 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -21,7 +21,6 @@ internal final class WindowLifecycleMonitor { let connectionId: UUID weak var window: NSWindow? var observer: NSObjectProtocol? - var isPreview: Bool = false } private var entries: [UUID: Entry] = [:] @@ -41,9 +40,9 @@ internal final class WindowLifecycleMonitor { // MARK: - Registration /// Register a window and start observing its willCloseNotification. - internal func register(window: NSWindow, connectionId: UUID, windowId: UUID, isPreview: Bool = false) { + internal func register(window: NSWindow, connectionId: UUID, windowId: UUID) { Self.lifecycleLogger.info( - "[open] WindowLifecycleMonitor.register windowId=\(windowId, privacy: .public) connId=\(connectionId, privacy: .public) isPreview=\(isPreview) registeredBefore=\(self.entries.count)" + "[open] WindowLifecycleMonitor.register windowId=\(windowId, privacy: .public) connId=\(connectionId, privacy: .public) registeredBefore=\(self.entries.count)" ) // Remove any existing entry for this windowId to avoid duplicate observers if let existing = entries[windowId] { @@ -69,8 +68,7 @@ internal final class WindowLifecycleMonitor { entries[windowId] = Entry( connectionId: connectionId, window: window, - observer: observer, - isPreview: isPreview + observer: observer ) } @@ -148,28 +146,12 @@ internal final class WindowLifecycleMonitor { return entries[windowId] != nil } - /// Find the first preview window for a connection. - internal func previewWindow(for connectionId: UUID) -> (windowId: UUID, window: NSWindow)? { - purgeStaleEntries() - for (windowId, entry) in entries { - guard entry.connectionId == connectionId, entry.isPreview else { continue } - guard let window = entry.window else { continue } - return (windowId, window) - } - return nil - } - /// Look up the NSWindow for a given windowId. internal func window(for windowId: UUID) -> NSWindow? { purgeStaleEntries() return entries[windowId]?.window } - /// Update the preview flag for a registered window. - internal func setPreview(_ isPreview: Bool, for windowId: UUID) { - entries[windowId]?.isPreview = isPreview - } - // MARK: - Source File Tracking internal func registerSourceFile(_ url: URL, windowId: UUID) { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e011dc70b..ebc10114e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -661,7 +661,7 @@ } } }, - "%@ — Preview" : { + "%@ - Preview" : { "localizations" : { "tr" : { "stringUnit" : { @@ -672,7 +672,7 @@ "vi" : { "stringUnit" : { "state" : "translated", - "value" : "%@ — Xem trước" + "value" : "%@ - Xem trước" } }, "zh-Hans" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 6b3e27471..813158eff 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -15,13 +15,19 @@ private let navigationLogger = Logger(subsystem: "com.TablePro", category: "Main extension MainContentCoordinator { // MARK: - Table Tab Opening - func openTableTab(_ table: TableInfo, showStructure: Bool = false, redirectToSibling: Bool = false) { + func openTableTab( + _ table: TableInfo, + showStructure: Bool = false, + redirectToSibling: Bool = false, + forceNonPreview: Bool = false + ) { openTableTab( table.name, schema: table.schema, showStructure: showStructure, isView: table.type == .view, - redirectToSibling: redirectToSibling + redirectToSibling: redirectToSibling, + forceNonPreview: forceNonPreview ) } @@ -30,7 +36,8 @@ extension MainContentCoordinator { schema: String? = nil, showStructure: Bool = false, isView: Bool = false, - redirectToSibling: Bool = false + redirectToSibling: Bool = false, + forceNonPreview: Bool = false ) { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId @@ -47,6 +54,7 @@ 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, @@ -100,52 +108,15 @@ extension MainContentCoordinator { } // If no tabs exist (empty state), add a table tab directly. - // In preview mode, mark it as preview so subsequent clicks replace it. if tabManager.tabs.isEmpty { - do { - if AppSettingsManager.shared.tabs.enablePreviewTabs { - try tabManager.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase, - schemaName: resolvedSchema - ) - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(true, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) - Preview" - } - } else { - try tabManager.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase, - schemaName: resolvedSchema - ) - } - } catch { - navigationLogger.error("openTableTab tab creation failed: \(error.localizedDescription, privacy: .public)") - return - } - if let (_, tabIndex) = tabManager.selectedTabAndIndex { - tabManager.mutate(at: tabIndex) { tab in - tab.tableContext.isView = isView - tab.tableContext.isEditable = !isView - tab.tableContext.schemaName = resolvedSchema - tab.pagination.reset() - } - toolbarState.isTableTab = true - } - // In-place navigation needs selectRedisDatabaseAndQuery to ensure the correct - // database is SELECTed and session state is updated before querying. - restoreLastHiddenColumnsForTable(tableName) - restoreFiltersForTable(tableName) - if navigationModel == .inPlace, let dbIndex = Int(currentDatabase) { - selectRedisDatabaseAndQuery(dbIndex) - } else if !selectedTabHiddenColumns.isEmpty { - requeryWithColumnScope() - } else { - runQuery() - } + addFirstTableTab( + tableName: tableName, + currentDatabase: currentDatabase, + resolvedSchema: resolvedSchema, + isView: isView, + createAsPreview: createAsPreview, + isInPlace: navigationModel == .inPlace + ) return } @@ -181,31 +152,19 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, active filters, or sorting, open in a new native tab - let hasActiveWork = changeManager.hasChanges - || selectedTabFilterState.hasAppliedFilters - || (tabManager.selectedTab?.hasUserActiveSort ?? false) - if hasActiveWork { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: currentDatabase, - schemaName: resolvedSchema, + if isActiveTabReusable { + reuseActiveTab( + for: tableName, + currentDatabase: currentDatabase, + resolvedSchema: resolvedSchema, isView: isView, - showStructure: showStructure + showStructure: showStructure, + createAsPreview: createAsPreview ) - WindowManager.shared.openTab(payload: payload) return } - // Preview tab mode: reuse or create a preview tab instead of a new native window - if AppSettingsManager.shared.tabs.enablePreviewTabs { - openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, schemaName: resolvedSchema, showStructure: showStructure) - return - } - - // Default: open table in a new native tab + promotePreviewTab() let payload = EditorTabPayload( connectionId: connection.id, tabType: .table, @@ -213,162 +172,129 @@ extension MainContentCoordinator { databaseName: currentDatabase, schemaName: resolvedSchema, isView: isView, - showStructure: showStructure + showStructure: showStructure, + isPreview: createAsPreview ) WindowManager.shared.openTab(payload: payload) } - // MARK: - Preview Tabs - - func openPreviewTab( - _ tableName: String, isView: Bool = false, - databaseName: String = "", schemaName: String? = nil, - showStructure: Bool = false + private func addFirstTableTab( + tableName: String, + currentDatabase: String, + resolvedSchema: String?, + isView: Bool, + createAsPreview: Bool, + isInPlace: Bool ) { - // Check if a preview window already exists for this connection - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId) { - if let previewCoordinator = Self.coordinator(for: preview.windowId) { - // Skip if preview tab already shows this table - if let current = previewCoordinator.tabManager.selectedTab, - current.tableContext.tableName == tableName, - current.tableContext.databaseName == databaseName { - preview.window.makeKeyAndOrderFront(nil) - return - } - if let oldTab = previewCoordinator.tabManager.selectedTab, - let oldTableName = oldTab.tableContext.tableName { - previewCoordinator.saveLastFilters(for: oldTableName) - } - do { - try previewCoordinator.tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true - ) - } catch { - navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") - return - } - previewCoordinator.clearFilterState() - if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { - let tabId = previewCoordinator.tabManager.tabs[tabIndex].id - previewCoordinator.setActiveTableRows(TableRows(), for: tabId) - previewCoordinator.tabManager.mutate(at: tabIndex) { tab in - tab.display.resultsViewMode = showStructure ? .structure : .data - tab.pagination.reset() - } - previewCoordinator.toolbarState.isTableTab = true - } - preview.window.makeKeyAndOrderFront(nil) - previewCoordinator.restoreLastHiddenColumnsForTable(tableName) - previewCoordinator.restoreFiltersForTable(tableName) - previewCoordinator.runQuery() - return - } - } - - // No preview window exists but current tab can be reused: replace in-place. - // This covers: preview tabs, non-preview table tabs with no active work, - // and empty/default query tabs (no user-entered content). - let isReusableTab: Bool = { - guard let tab = tabManager.selectedTab else { return false } - if tab.isPreview { return true } - // Table tab with no active work - if tab.tabType == .table && !changeManager.hasChanges - && !selectedTabFilterState.hasAppliedFilters && !tab.hasUserActiveSort { - return true - } - // Empty/default query tab (no user content, no results, never executed) - if tab.tabType == .query && tab.execution.lastExecutedAt == nil - && tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } - return false - }() - if let selectedTab = tabManager.selectedTab, isReusableTab { - // Skip if already showing this table - if selectedTab.tableContext.tableName == tableName, selectedTab.tableContext.databaseName == databaseName { - return - } - // If preview tab has active work, promote it and open new tab instead - let hasUnsavedQuery = tabManager.selectedTab.map { tab in - tab.tabType == .query && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } ?? false - let previewHasWork = changeManager.hasChanges - || selectedTabFilterState.hasAppliedFilters - || selectedTab.hasUserActiveSort - || hasUnsavedQuery - if previewHasWork { - promotePreviewTab() - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + do { + if createAsPreview { + try tabManager.addPreviewTableTab( tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - isView: isView, - showStructure: showStructure + databaseType: connection.type, + databaseName: currentDatabase, + schemaName: resolvedSchema ) - WindowManager.shared.openTab(payload: payload) - return - } - if let oldTableName = selectedTab.tableContext.tableName { - saveLastFilters(for: oldTableName) - } - do { - try tabManager.replaceTabContent( + } else { + try tabManager.addTableTab( tableName: tableName, databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true + databaseName: currentDatabase, + schemaName: resolvedSchema ) - } catch { - navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") - return } - clearFilterState() - if let (tab, tabIndex) = tabManager.selectedTabAndIndex { - setActiveTableRows(TableRows(), for: tab.id) - tabManager.mutate(at: tabIndex) { - $0.display.resultsViewMode = showStructure ? .structure : .data - $0.pagination.reset() - } - toolbarState.isTableTab = true + } catch { + navigationLogger.error("openTableTab tab creation failed: \(error.localizedDescription, privacy: .public)") + return + } + if let (_, tabIndex) = tabManager.selectedTabAndIndex { + tabManager.mutate(at: tabIndex) { tab in + tab.tableContext.isView = isView + tab.tableContext.isEditable = !isView + tab.tableContext.schemaName = resolvedSchema + tab.pagination.reset() } - restoreLastHiddenColumnsForTable(tableName) - restoreFiltersForTable(tableName) + toolbarState.isTableTab = true + } + updatePreviewSubtitle(isPreview: createAsPreview) + restoreLastHiddenColumnsForTable(tableName) + restoreFiltersForTable(tableName) + if isInPlace, let dbIndex = Int(currentDatabase) { + selectRedisDatabaseAndQuery(dbIndex) + } else if !selectedTabHiddenColumns.isEmpty { + requeryWithColumnScope() + } else { runQuery() + } + } + + private func reuseActiveTab( + for tableName: String, + currentDatabase: String, + resolvedSchema: String?, + isView: Bool, + showStructure: Bool, + createAsPreview: Bool + ) { + if let oldTableName = tabManager.selectedTab?.tableContext.tableName { + saveLastFilters(for: oldTableName) + } + do { + try tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: currentDatabase, + schemaName: resolvedSchema, + isPreview: createAsPreview + ) + } catch { + navigationLogger.error("openTableTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") return } + clearFilterState() + if let (tab, tabIndex) = tabManager.selectedTabAndIndex { + setActiveTableRows(TableRows(), for: tab.id) + tabManager.mutate(at: tabIndex) { + $0.display.resultsViewMode = showStructure ? .structure : .data + $0.pagination.reset() + } + toolbarState.isTableTab = true + } + updatePreviewSubtitle(isPreview: createAsPreview) + restoreLastHiddenColumnsForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() + } - // No preview tab anywhere: create a new native preview tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - isView: isView, - showStructure: showStructure, - isPreview: true - ) - WindowManager.shared.openTab(payload: payload) + // MARK: - Preview Tabs + + var isActiveTabReusable: Bool { + guard let tab = tabManager.selectedTab else { return false } + if changeManager.hasChanges + || selectedTabFilterState.hasAppliedFilters + || tab.hasUserActiveSort { + return false + } + if tab.isPreview { return true } + if tab.tabType == .query, + tab.execution.lastExecutedAt == nil, + tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + return false } func promotePreviewTab() { guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.isPreview else { return } tabManager.mutate(at: tabIndex) { $0.isPreview = false } + updatePreviewSubtitle(isPreview: false) + } - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(false, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = connection.name - } + private func updatePreviewSubtitle(isPreview: Bool) { + contentWindow?.subtitle = isPreview + ? String(format: String(localized: "%@ - Preview"), connection.name) + : connection.name } func showAllTablesMetadata() { diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index c2a8f01aa..454f2bf2a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -107,24 +107,20 @@ extension MainContentView { return } - let isPreviewMode = AppSettingsManager.shared.tabs.enablePreviewTabs - let hasPreview = WindowLifecycleMonitor.shared.previewWindow(for: connection.id) != nil - let result = SidebarNavigationResult.resolve( clickedTableName: table.name, currentTabTableName: tabManager.selectedTab?.tableContext.tableName, hasExistingTabs: !tabManager.tabs.isEmpty, - isPreviewTabMode: isPreviewMode, - hasPreviewTab: hasPreview + isActiveTabReusable: coordinator.isActiveTabReusable ) switch result { case .skip: return - case .openInPlace: + case .reuseActiveTab: coordinator.selectionState.indices = [] coordinator.openTableTab(table) - case .revertAndOpenNewWindow, .replacePreviewTab, .openNewPreviewTab: + case .openNewTab: coordinator.openTableTab(table) } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 25fc30a5d..de36cca30 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -209,7 +209,7 @@ extension MainContentView { ) let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false if isPreview { - window.subtitle = String(format: String(localized: "%@ — Preview"), connection.name) + window.subtitle = String(format: String(localized: "%@ - Preview"), connection.name) } else { window.subtitle = connection.name } @@ -222,8 +222,7 @@ extension MainContentView { WindowLifecycleMonitor.shared.register( window: window, connectionId: connection.id, - windowId: windowId, - isPreview: isPreview + windowId: windowId ) viewWindow = window coordinator.contentWindow = window diff --git a/TablePro/Views/Main/SidebarNavigationResult.swift b/TablePro/Views/Main/SidebarNavigationResult.swift index 7ea14d69c..bfaa2232d 100644 --- a/TablePro/Views/Main/SidebarNavigationResult.swift +++ b/TablePro/Views/Main/SidebarNavigationResult.swift @@ -12,49 +12,30 @@ import Foundation enum SidebarNavigationResult: Equatable { /// The selected table already matches the active tab — skip all navigation. case skip - /// No existing tabs: navigate in-place inside this window. - case openInPlace - /// Existing tabs present: revert sidebar to the current tab immediately, - /// then open the clicked table in a new native window tab. - /// Reverting synchronously prevents SwiftUI from rendering the [B] state - /// before coalescing back to [A] — eliminating the visible flash. - case revertAndOpenNewWindow - /// Preview mode: replace the contents of the existing preview tab. - case replacePreviewTab - /// Preview mode: no preview tab exists yet, so create a new one. - case openNewPreviewTab + /// Replace the focused window's active tab in place. + case reuseActiveTab + /// Open the clicked table in a new native window tab. + case openNewTab - /// Pure function — no side effects. Determines how a sidebar click should be handled. + /// Pure function with no side effects. Decides how a sidebar single-click is + /// handled, scoped to the focused window's active tab. /// /// - Parameters: /// - clickedTableName: The name of the table the user clicked in the sidebar. /// - currentTabTableName: The table name of this window's active tab - /// (`nil` when the active tab is a query or create-table tab). + /// (`nil` when the active tab is a query or non-table tab). /// - hasExistingTabs: `true` when this window already has at least one tab open. - /// - isPreviewTabMode: `true` when preview/temporary tab mode is enabled. - /// - hasPreviewTab: `true` when a preview tab already exists in this window. + /// - isActiveTabReusable: `true` when the active tab can be replaced in place + /// (a preview tab, or a blank never-executed query tab). static func resolve( clickedTableName: String, currentTabTableName: String?, hasExistingTabs: Bool, - isPreviewTabMode: Bool = false, - hasPreviewTab: Bool = false + isActiveTabReusable: Bool ) -> SidebarNavigationResult { - // Programmatic sync (e.g. didBecomeKeyNotification): the selection already - // reflects the active tab — nothing to do. if currentTabTableName == clickedTableName { return .skip } - // No existing tabs: open the table in-place within this window. - if !hasExistingTabs { return .openInPlace } - - // Preview tab logic: reuse or create a preview tab instead of opening a new window tab. - if isPreviewTabMode { - if hasPreviewTab { - return .replacePreviewTab - } - return .openNewPreviewTab - } - - // Default: revert sidebar synchronously (no flash), then open in a new native tab. - return .revertAndOpenNewWindow + if !hasExistingTabs { return .reuseActiveTab } + if isActiveTabReusable { return .reuseActiveTab } + return .openNewTab } } diff --git a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift index bb561ee0c..52c0e945e 100644 --- a/TableProTests/Views/Main/MultiConnectionNavigationTests.swift +++ b/TableProTests/Views/Main/MultiConnectionNavigationTests.swift @@ -10,11 +10,11 @@ import Foundation import TableProPluginKit import Testing + @testable import TablePro @Suite("Multi-Connection Navigation") struct MultiConnectionNavigationTests { - // MARK: - Helpers @MainActor @@ -138,7 +138,8 @@ struct MultiConnectionNavigationTests { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: manager.selectedTab?.tableContext.tableName, - hasExistingTabs: !manager.tabs.isEmpty + hasExistingTabs: !manager.tabs.isEmpty, + isActiveTabReusable: false ) #expect(result == .skip) } @@ -151,7 +152,8 @@ struct MultiConnectionNavigationTests { let result = SidebarNavigationResult.resolve( clickedTableName: "accounts", currentTabTableName: manager.selectedTab?.tableContext.tableName, - hasExistingTabs: !manager.tabs.isEmpty + hasExistingTabs: !manager.tabs.isEmpty, + isActiveTabReusable: false ) #expect(result == .skip) } @@ -164,41 +166,45 @@ struct MultiConnectionNavigationTests { let result = SidebarNavigationResult.resolve( clickedTableName: "items", currentTabTableName: manager.selectedTab?.tableContext.tableName, - hasExistingTabs: !manager.tabs.isEmpty + hasExistingTabs: !manager.tabs.isEmpty, + isActiveTabReusable: false ) #expect(result == .skip) } - // MARK: - SidebarNavigationResult: openInPlace for all database types with no tabs + // MARK: - SidebarNavigationResult: reuseActiveTab for all database types with no tabs - @Test("resolve returns openInPlace for mysql with no existing tabs") - func resolveOpenInPlaceForMysqlNoTabs() { + @Test("resolve returns reuseActiveTab for mysql with no existing tabs") + func resolveReuseActiveTabForMysqlNoTabs() { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: nil, - hasExistingTabs: false + hasExistingTabs: false, + isActiveTabReusable: false ) - #expect(result == .openInPlace) + #expect(result == .reuseActiveTab) } - @Test("resolve returns openInPlace for postgresql with no existing tabs") - func resolveOpenInPlaceForPostgresqlNoTabs() { + @Test("resolve returns reuseActiveTab for postgresql with no existing tabs") + func resolveReuseActiveTabForPostgresqlNoTabs() { let result = SidebarNavigationResult.resolve( clickedTableName: "accounts", currentTabTableName: nil, - hasExistingTabs: false + hasExistingTabs: false, + isActiveTabReusable: false ) - #expect(result == .openInPlace) + #expect(result == .reuseActiveTab) } - @Test("resolve returns openInPlace for sqlite with no existing tabs") - func resolveOpenInPlaceForSqliteNoTabs() { + @Test("resolve returns reuseActiveTab for sqlite with no existing tabs") + func resolveReuseActiveTabForSqliteNoTabs() { let result = SidebarNavigationResult.resolve( clickedTableName: "items", currentTabTableName: nil, - hasExistingTabs: false + hasExistingTabs: false, + isActiveTabReusable: false ) - #expect(result == .openInPlace) + #expect(result == .reuseActiveTab) } // MARK: - Coordinator connection scoping diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index 888a3fd61..d9f1ae56f 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -31,4 +31,174 @@ struct OpenTableTabTests { #expect(tabManager.tabs.count == 1) #expect(tabManager.tabs.first?.tableContext.tableName == "users") } + + // MARK: - Window-local reuse (issue #1348) + + @Test("Reuses the active preview tab in place instead of opening a new tab") + @MainActor + func reusesActivePreviewTabInPlace() 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.addPreviewTableTab(tableName: "users", databaseType: connection.type, databaseName: "db_a") + #expect(tabManager.tabs.count == 1) + + coordinator.openTableTab("orders") + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.tableContext.tableName == "orders") + } + + @Test("Reuses a blank query tab in place") + @MainActor + func reusesBlankQueryTabInPlace() { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + tabManager.addTab(databaseName: "db_a") + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.tabType == .query) + + coordinator.openTableTab("users") + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.tabType == .table) + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + } + + @Test("Clicking the active table again is a no-op") + @MainActor + func clickingActiveTableAgainIsNoOp() 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.addPreviewTableTab(tableName: "users", databaseType: connection.type, databaseName: "db_a") + let tabId = tabManager.selectedTab?.id + + coordinator.openTableTab("users") + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.id == tabId) + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + } + + // MARK: - isActiveTabReusable + + @Test("A preview table tab is reusable") + @MainActor + func previewTabIsReusable() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + try coordinator.tabManager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "db") + #expect(coordinator.isActiveTabReusable == true) + } + + @Test("A permanent table tab is protected and not reusable") + @MainActor + func permanentTableTabIsNotReusable() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + try coordinator.tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db") + #expect(coordinator.isActiveTabReusable == false) + } + + @Test("A blank query tab is reusable") + @MainActor + func blankQueryTabIsReusable() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addTab(databaseName: "db") + #expect(coordinator.isActiveTabReusable == true) + } + + @Test("A query tab with content is protected and not reusable") + @MainActor + func queryTabWithContentIsNotReusable() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db") + #expect(coordinator.isActiveTabReusable == false) + } + + // MARK: - Promotion (double-click / interaction) + + @Test("promotePreviewTab clears the preview flag and protects the tab") + @MainActor + func promoteClearsPreviewFlag() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + try coordinator.tabManager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "db") + #expect(coordinator.tabManager.selectedTab?.isPreview == true) + + coordinator.promotePreviewTab() + + #expect(coordinator.tabManager.selectedTab?.isPreview == false) + #expect(coordinator.isActiveTabReusable == false) + } + + @Test("promotePreviewTab is a no-op for a non-preview tab") + @MainActor + func promoteNonPreviewIsNoOp() throws { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + try coordinator.tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db") + coordinator.promotePreviewTab() + #expect(coordinator.tabManager.selectedTab?.isPreview == false) + } + + @Test("Double-click (forceNonPreview) replaces the preview tab with a permanent tab") + @MainActor + func forceNonPreviewReplacesWithPermanentTab() 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.addPreviewTableTab(tableName: "users", databaseType: connection.type, databaseName: "db_a") + + coordinator.openTableTab( + TableInfo(name: "orders", type: .table, rowCount: nil), + forceNonPreview: true + ) + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.tableContext.tableName == "orders") + #expect(tabManager.selectedTab?.isPreview == false) + } + + @MainActor + private static func makeCoordinator() -> MainContentCoordinator { + MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "db"), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } } diff --git a/TableProTests/Views/Main/SharedSidebarSyncTests.swift b/TableProTests/Views/Main/SharedSidebarSyncTests.swift index afdeff294..2da59e267 100644 --- a/TableProTests/Views/Main/SharedSidebarSyncTests.swift +++ b/TableProTests/Views/Main/SharedSidebarSyncTests.swift @@ -10,11 +10,11 @@ import Foundation import TableProPluginKit import Testing + @testable import TablePro @Suite("Shared Sidebar Sync Invariants") struct SharedSidebarSyncTests { - // MARK: - Helpers private func makeTable(_ name: String, type: TableInfo.TableType = .table) -> TableInfo { @@ -41,7 +41,8 @@ struct SharedSidebarSyncTests { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: "users", // <-- current tab IS "users" - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: false ) #expect(result == .skip, "syncSidebarToCurrentTab must not trigger navigation") } @@ -111,7 +112,8 @@ struct SharedSidebarSyncTests { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: "users", - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: false ) #expect(result == .skip, "Even with stale previous, skip when table matches current tab") } @@ -140,9 +142,10 @@ struct SharedSidebarSyncTests { let result = SidebarNavigationResult.resolve( clickedTableName: "orders", currentTabTableName: "users", - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: false ) - #expect(result == .revertAndOpenNewWindow) + #expect(result == .openNewTab) } @Test("Click table with no existing tabs — opens in place") @@ -156,9 +159,10 @@ struct SharedSidebarSyncTests { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: nil, - hasExistingTabs: false + hasExistingTabs: false, + isActiveTabReusable: false ) - #expect(result == .openInPlace) + #expect(result == .reuseActiveTab) } @Test("Click same table as current tab — skip") @@ -173,7 +177,8 @@ struct SharedSidebarSyncTests { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: "users", - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: false ) #expect(result == .skip) } diff --git a/TableProTests/Views/SidebarNavigationResultTests.swift b/TableProTests/Views/SidebarNavigationResultTests.swift index 334f0a59d..0364cfedf 100644 --- a/TableProTests/Views/SidebarNavigationResultTests.swift +++ b/TableProTests/Views/SidebarNavigationResultTests.swift @@ -3,23 +3,22 @@ // TableProTests // // Tests for SidebarNavigationResult — the pure decision logic that controls -// whether a sidebar click navigates in-place, opens a new native tab, or is -// a no-op programmatic sync. +// whether a sidebar single-click replaces the focused window's active tab in +// place or opens a new native tab. // -// These tests encode the "no-flash contract": when a table is clicked that is -// NOT the active tab and the window already has tabs, the result must be -// .revertAndOpenNewWindow — the sidebar reverts synchronously so SwiftUI never -// renders the [B] selection state. +// The rule is window-local: a click reuses the active tab when it is reusable +// (a preview tab or a blank query tab), opens the first tab in an empty window, +// and otherwise opens a new tab. Tab count is never the deciding factor. // import Foundation import TableProPluginKit import Testing + @testable import TablePro @Suite("SidebarNavigationResult") struct SidebarNavigationResultTests { - // MARK: - .skip (programmatic sync, no navigation) @Test("Skip when clicked table matches active tab and tabs exist") @@ -27,7 +26,8 @@ struct SidebarNavigationResultTests { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: "users", - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: false ) #expect(result == .skip) } @@ -37,135 +37,134 @@ struct SidebarNavigationResultTests { let result = SidebarNavigationResult.resolve( clickedTableName: "orders", currentTabTableName: "orders", - hasExistingTabs: false + hasExistingTabs: false, + isActiveTabReusable: false ) #expect(result == .skip) } @Test("Skip is case-sensitive — different case is NOT a match") func skipIsCaseSensitive() { - // Table names are case-sensitive; "Users" ≠ "users" let result = SidebarNavigationResult.resolve( clickedTableName: "Users", currentTabTableName: "users", - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: false ) #expect(result != .skip) } - // MARK: - .openInPlace (empty window, navigate in-place) + // MARK: - .reuseActiveTab (empty window opens first tab in place) - @Test("Open in-place when tabs are empty and no current tab") - func openInPlaceWhenTabsEmpty() { + @Test("Reuse active tab when window is empty and no current tab") + func reuseActiveTabWhenTabsEmpty() { let result = SidebarNavigationResult.resolve( clickedTableName: "products", currentTabTableName: nil, - hasExistingTabs: false + hasExistingTabs: false, + isActiveTabReusable: false ) - #expect(result == .openInPlace) + #expect(result == .reuseActiveTab) } - @Test("Open in-place when tabs are empty even if current tab name matches different value") - func openInPlaceWhenTabsEmptyWithCurrentTabName() { - // hasExistingTabs is the authoritative flag; if false, always openInPlace + @Test("Reuse active tab when window is empty even if a stale tab name is supplied") + func reuseActiveTabWhenTabsEmptyWithCurrentTabName() { let result = SidebarNavigationResult.resolve( clickedTableName: "products", currentTabTableName: "users", - hasExistingTabs: false - ) - #expect(result == .openInPlace) - } - - @Test("Open in-place when tabs are empty with an empty string table name") - func openInPlaceWithEmptyStringTableName() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "", - currentTabTableName: nil, - hasExistingTabs: false + hasExistingTabs: false, + isActiveTabReusable: false ) - #expect(result == .openInPlace) + #expect(result == .reuseActiveTab) } - // MARK: - .revertAndOpenNewWindow (no-flash contract) + // MARK: - .reuseActiveTab (active tab is reusable) - @Test("Revert and open new window when tabs exist and different table is clicked") - func revertAndOpenNewWindowWhenTabsExistDifferentTable() { + @Test("Reuse active tab when tabs exist and the active tab is reusable") + func reuseActiveTabWhenReusable() { let result = SidebarNavigationResult.resolve( - clickedTableName: "products", + clickedTableName: "orders", currentTabTableName: "users", - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: true ) - #expect(result == .revertAndOpenNewWindow) + #expect(result == .reuseActiveTab) } - @Test("Revert and open new window when tabs exist and current tab is a query tab (nil name)") - func revertAndOpenNewWindowWhenCurrentTabIsQueryTab() { - // A query tab has no tableName (nil); clicking any table should open new window + @Test("Reuse active tab when current tab is a reusable blank query tab (nil name)") + func reuseActiveTabWhenReusableQueryTab() { let result = SidebarNavigationResult.resolve( clickedTableName: "orders", currentTabTableName: nil, - hasExistingTabs: true + hasExistingTabs: true, + isActiveTabReusable: true ) - #expect(result == .revertAndOpenNewWindow) + #expect(result == .reuseActiveTab) } - @Test("Revert and open new window with empty current tab name") - func revertAndOpenNewWindowWithEmptyCurrentTabName() { + // MARK: - .openNewTab (active tab is protected) + + @Test("Open new tab when tabs exist and the active tab is not reusable") + func openNewTabWhenNotReusable() { let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: "", - hasExistingTabs: true + clickedTableName: "products", + currentTabTableName: "users", + hasExistingTabs: true, + isActiveTabReusable: false ) - #expect(result == .revertAndOpenNewWindow) + #expect(result == .openNewTab) } - // MARK: - No-flash contract (critical invariants) - - @Test("Never skips when different table is clicked and tabs exist") - func noFlashContract_differentTableWithTabsMustNotSkip() { + @Test("Open new tab when current tab is a non-reusable query tab") + func openNewTabWhenQueryTabNotReusable() { let result = SidebarNavigationResult.resolve( clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true + currentTabTableName: nil, + hasExistingTabs: true, + isActiveTabReusable: false ) - #expect(result != .skip) - #expect(result == .revertAndOpenNewWindow) + #expect(result == .openNewTab) } - @Test("Never opens in-place when tabs already exist") - func noFlashContract_tabsExistMustNotOpenInPlace() { + // MARK: - Invariants + + @Test("Never opens a new tab when the window is empty; always reuse in place") + func emptyWindowNeverOpensNewTab() { let result = SidebarNavigationResult.resolve( clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true + currentTabTableName: nil, + hasExistingTabs: false, + isActiveTabReusable: false ) - #expect(result != .openInPlace) + #expect(result != .openNewTab) + #expect(result == .reuseActiveTab) } - @Test("Never opens new window when tables are empty — always in-place") - func noFlashContract_emptyTabsMustNotOpenNewWindow() { + @Test("A protected active tab is never silently replaced") + func protectedTabNeverReused() { let result = SidebarNavigationResult.resolve( clickedTableName: "orders", - currentTabTableName: nil, - hasExistingTabs: false + currentTabTableName: "users", + hasExistingTabs: true, + isActiveTabReusable: false ) - #expect(result != .revertAndOpenNewWindow) - #expect(result == .openInPlace) + #expect(result != .reuseActiveTab) + #expect(result == .openNewTab) } // MARK: - QueryTabManager integration - @Test("Resolves to openInPlace for fresh QueryTabManager with no tabs") + @Test("Resolves to reuseActiveTab for a fresh QueryTabManager with no tabs") @MainActor func resolveWithFreshTabManager() { let manager = QueryTabManager() - // Fresh manager has no tabs let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: manager.selectedTab?.tableContext.tableName, - hasExistingTabs: !manager.tabs.isEmpty + hasExistingTabs: !manager.tabs.isEmpty, + isActiveTabReusable: false ) - #expect(result == .openInPlace) + #expect(result == .reuseActiveTab) } @Test("Resolves to skip when clicking the active table in QueryTabManager") @@ -176,35 +175,38 @@ struct SidebarNavigationResultTests { let result = SidebarNavigationResult.resolve( clickedTableName: "users", currentTabTableName: manager.selectedTab?.tableContext.tableName, - hasExistingTabs: !manager.tabs.isEmpty + hasExistingTabs: !manager.tabs.isEmpty, + isActiveTabReusable: false ) #expect(result == .skip) } - @Test("Resolves to revertAndOpenNewWindow when clicking a different table in non-empty window") + @Test("Resolves to openNewTab when clicking a different table while the active tab is protected") @MainActor - func resolveNewWindowWhenClickingDifferentTable() throws { + func resolveNewTabWhenActiveTabProtected() throws { let manager = QueryTabManager() try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let result = SidebarNavigationResult.resolve( clickedTableName: "orders", currentTabTableName: manager.selectedTab?.tableContext.tableName, - hasExistingTabs: !manager.tabs.isEmpty + hasExistingTabs: !manager.tabs.isEmpty, + isActiveTabReusable: false ) - #expect(result == .revertAndOpenNewWindow) + #expect(result == .openNewTab) } - @Test("Resolves to revertAndOpenNewWindow when current tab is a query tab but window has tabs") + @Test("Resolves to reuseActiveTab when clicking a different table while the active tab is a preview") @MainActor - func resolveNewWindowWhenCurrentTabIsQueryTabButWindowHasTabs() { + func resolveReuseWhenActiveTabIsPreview() throws { let manager = QueryTabManager() - manager.addTab(databaseName: "mydb") // query tab — no tableName + try manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let result = SidebarNavigationResult.resolve( - clickedTableName: "products", - currentTabTableName: manager.selectedTab?.tableContext.tableName, // nil for query tab - hasExistingTabs: !manager.tabs.isEmpty + clickedTableName: "orders", + currentTabTableName: manager.selectedTab?.tableContext.tableName, + hasExistingTabs: !manager.tabs.isEmpty, + isActiveTabReusable: true ) - #expect(result == .revertAndOpenNewWindow) + #expect(result == .reuseActiveTab) } // MARK: - syncSidebarToCurrentTab logic @@ -234,16 +236,6 @@ struct SidebarNavigationResultTests { #expect(match == nil) } - @Test("Sync should clear selection when tab has no table name") - @MainActor - func syncClearsSelectionForQueryTab() { - let manager = QueryTabManager() - manager.addTab(databaseName: "mydb") // query tab: tableName == nil - let currentTableName = manager.selectedTab?.tableContext.tableName - // When tableName is nil, syncSidebarToCurrentTab sets selectedTables = [] - #expect(currentTableName == nil) - } - @Test("Sync should set selection to active table name") @MainActor func syncSetsSelectionForTableTab() throws { @@ -251,90 +243,5 @@ struct SidebarNavigationResultTests { try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let currentTableName = manager.selectedTab?.tableContext.tableName #expect(currentTableName == "users") - // syncSidebarToCurrentTab will find "users" in tables and set selectedTables = [users] - } - - // MARK: - Database switch scenarios - - @Test("Skip when table matches current tab during database switch") - func skipWhenTableMatchesDuringDatabaseSwitch() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "users", - currentTabTableName: "users", - hasExistingTabs: true - ) - #expect(result == .skip) - } - - @Test("Open in-place when no existing tabs during database switch") - func openInPlaceWhenNoTabsDuringSwitch() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: nil, - hasExistingTabs: false - ) - #expect(result == .openInPlace) - } - - // MARK: - Preview tab mode - - @Test("Preview mode disabled returns existing behavior") - func previewModeDisabledReturnsExistingBehavior() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: false, - hasPreviewTab: false - ) - #expect(result == .revertAndOpenNewWindow) - } - - @Test("Preview mode enabled with existing preview tab returns replacePreviewTab") - func previewModeWithExistingPreviewTab() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: true, - hasPreviewTab: true - ) - #expect(result == .replacePreviewTab) - } - - @Test("Preview mode enabled without preview tab returns openNewPreviewTab") - func previewModeWithoutPreviewTab() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: true, - hasPreviewTab: false - ) - #expect(result == .openNewPreviewTab) - } - - @Test("Preview mode skip still works when table matches") - func previewModeSkipWhenTableMatches() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "users", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: true, - hasPreviewTab: true - ) - #expect(result == .skip) - } - - @Test("Preview mode with no existing tabs still opens in-place") - func previewModeNoExistingTabsOpensInPlace() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: nil, - hasExistingTabs: false, - isPreviewTabMode: true, - hasPreviewTab: false - ) - #expect(result == .openInPlace) } } diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index dcab85bff..00ea435c2 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -34,9 +34,7 @@ Change tracking is available in table tabs by default. Query tabs support change ### Smart Tab Reuse -Clicking the same table switches to its existing tab. Clicking a different table opens a new tab. - -Enable **Reuse clean table tab** in **Settings** > **Tabs** for TablePlus-style behavior: clicking a different table replaces the current table tab if it has no unsaved changes, no user interaction, and is not pinned. +Clicking a table you already have open just switches to it. With preview tabs on (the default), clicking a different table reuses the active tab in place. A tab with unsaved edits, an applied filter, or sorting is never replaced: the click opens a new tab and leaves your work alone. Double-click a table to open it in its own permanent tab. ### Preview Tabs @@ -75,7 +73,7 @@ Disable preview tabs in **Settings** > **Tabs** if you prefer every click to ope |--------|-----| | New query tab | `Cmd+T` or click **+** in the tab bar | | New query tab (toolbar) | Click the **SQL** button | -| New table tab | Click a table name in the sidebar | +| New table tab | Double-click a table in the sidebar (single-click reuses the preview tab) | ### Closing Tabs