From b58891b6177b7672c90f5ef54b2ab4b0606dd850 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 15 Oct 2026 22:42:07 +0700 Subject: [PATCH 1/3] feat(connections): give each database in the connections strip its own tabs --- CHANGELOG.md | 11 + .../Core/Database/DatabaseManager+Scope.swift | 25 ++ TablePro/Core/Events/AppEvents.swift | 3 + .../Tools/MCPTabSnapshotProvider.swift | 39 ++- .../MCP/Protocol/Tools/OpenTableTabTool.swift | 4 +- .../Core/Scripting/ScriptingSnapshot.swift | 23 +- .../Infrastructure/ConnectionWorkspace.swift | 21 ++ .../Infrastructure/EditorTabOpener.swift | 3 +- ...nSplitViewController+FileMenuActions.swift | 7 + ...plitViewController+TabStripAccessory.swift | 13 +- .../MainSplitViewController.swift | 2 +- .../Infrastructure/SessionStateFactory.swift | 7 + .../Services/Infrastructure/TabRouter.swift | 16 +- .../Infrastructure/WindowManager.swift | 33 +- .../Infrastructure/WorkspaceCloseAction.swift | 87 +++-- .../Infrastructure/WorkspaceRailStore.swift | 33 +- .../WorkspaceRailViewController.swift | 64 ++-- TablePro/Models/Query/QueryTabManager.swift | 234 ++++++++++++-- .../Models/UI/QuickSwitcherOpenTable.swift | 36 ++- .../Workspace/QueryContainerRebinding.swift | 28 ++ .../Models/Workspace/WorkspaceAnchoring.swift | 79 ++--- .../ViewModels/QuickSwitcherViewModel.swift | 8 +- .../Main/Child/MainEditorContentView.swift | 33 +- TablePro/Views/Main/EditorTabStrip.swift | 4 +- .../MainContentCoordinator+ERDiagram.swift | 2 +- .../MainContentCoordinator+Navigation.swift | 67 ++-- ...MainContentCoordinator+QuickSwitcher.swift | 5 +- ...MainContentCoordinator+SchemaLoading.swift | 11 + .../MainContentCoordinator+TabRestore.swift | 48 ++- .../MainContentCoordinator+TabSwitch.swift | 14 +- ...nContentCoordinator+VisibleContainer.swift | 95 ++++++ ...inContentCoordinator+WindowLifecycle.swift | 2 +- .../Extensions/MainContentView+Helpers.swift | 28 +- .../Extensions/MainContentView+Setup.swift | 2 +- .../MainContentCommandActions+BulkClose.swift | 35 +- .../Main/MainContentCommandActions.swift | 14 +- .../Views/Main/MainContentCoordinator.swift | 19 +- TablePro/Views/Main/MainContentView.swift | 2 +- .../Protocol/Tools/MCPTabIdentityTests.swift | 51 +++ .../ConnectionWorkspaceContainersTests.swift | 57 ++++ .../Services/WorkspaceRailStoreTests.swift | 34 +- .../Helpers/SwitchingDriverStub.swift | 12 + TableProTests/Models/DatabaseScopeTests.swift | 28 ++ .../Query/QueryTabManagerRecencyTests.swift | 19 -- ...QueryTabManagerVisibleContainerTests.swift | 305 ++++++++++++++++++ .../Models/QueryContainerRebindingTests.swift | 36 +++ .../Models/QuickSwitcherOpenTableTests.swift | 38 ++- .../Models/WorkspaceAnchoringTests.swift | 90 ++---- .../QuickSwitcherCrossSchemaTests.swift | 27 +- .../Main/CommandActionsBulkCloseTests.swift | 47 ++- ...ContentCoordinatorTableMetadataTests.swift | 41 +++ TableProTests/Views/SwitchDatabaseTests.swift | 38 ++- TableProTests/Views/SwitchSchemaTests.swift | 11 +- .../VisibleContainerFollowingTests.swift | 158 +++++++++ 54 files changed, 1737 insertions(+), 412 deletions(-) create mode 100644 TablePro/Models/Workspace/QueryContainerRebinding.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift create mode 100644 TableProTests/Helpers/SwitchingDriverStub.swift create mode 100644 TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift create mode 100644 TableProTests/Models/QueryContainerRebindingTests.swift create mode 100644 TableProTests/Views/VisibleContainerFollowingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c946c45c59..002aae0f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cloud Firestore and Firebase Realtime Database as a registry plugin over their REST APIs. (#2504) - Google Cloud Bigtable as a registry plugin over the REST API. - Warning banner and Test Connection notice for a server warning at login, such as an Oracle password about to expire. (#3401) +- Schema picker in the query editor on servers that switch schemas, such as Oracle, to point a query tab at another schema. ### Changed @@ -46,6 +47,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set Value > Empty and Set EMPTY offered only on columns that can hold an empty value, not on dates, numbers or booleans. - Clearing a date, number or boolean cell in a NOT NULL column keeps the stored value instead of saving an empty string. - Clearing a Redis key's TTL in the grid removes its expiry. +- Each database in the connections strip keeps its own tabs. The tab bar shows the tabs of the one you are on. (#3431) +- Query tabs stay on the schema they were opened in on servers that switch schemas, instead of following the sidebar. +- Close Tab with no tab left closes the database's entry in the connections strip, then the connection. ### Removed @@ -127,6 +131,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Scroll to latest message button in AI chat never appearing. - AI reasoning text not updating while it streams. - Clearing a date, number or boolean field on iPhone and iPad saving an empty string, stored as `0000-00-00 00:00:00` on MySQL, instead of NULL. +- Switching database from the toolbar, sidebar or Open Database left the previous database's tab on screen. +- Quick Switcher marking a table as open when a table of the same name was open in another database. +- Table Info in the inspector showing the numbers of a table of the same name in another database. +- Links, AppleScript and MCP `open_table_tab` focusing a table of the same name in another database. +- Dropped databases staying in the connections strip, where clicking them failed with an error. +- A query tab moved to another database in its database picker keeping the old database's schema. +- Connections strip missing a database whose tabs were in a torn-off window. ### Security diff --git a/TablePro/Core/Database/DatabaseManager+Scope.swift b/TablePro/Core/Database/DatabaseManager+Scope.swift index 47fd407e93..27352ffa6c 100644 --- a/TablePro/Core/Database/DatabaseManager+Scope.swift +++ b/TablePro/Core/Database/DatabaseManager+Scope.swift @@ -37,6 +37,31 @@ extension DatabaseManager { ) } + /// The table an open by name lands on. The open path binds a missing database to the browse + /// cursor and a missing schema through `resolvedSchemaName`, so a lookup for a tab already + /// showing it resolves the same way. Treating a missing part as "any" matched `tenant_a.orders` + /// for a request that meant the `orders` of the database being browsed. + func requestedTableIdentity( + table: String, + database: String?, + schema: String?, + for connectionId: UUID + ) -> TableTabIdentity { + let named = database.flatMap { $0.isEmpty ? nil : $0 } + let resolvedDatabase = named ?? activeSessions[connectionId]?.resolvedBrowseDatabase ?? "" + return TableTabIdentity( + table: table, + database: resolvedDatabase, + schema: resolvedSchemaName(schema, inDatabase: resolvedDatabase, for: connectionId) + ) + } + + /// The identity of a table tab as it stands, with an unbound database read as the browse + /// cursor, the rule `TabTableContext.resolvedDatabaseName(browsing:)` defines. + func tableIdentity(of tab: QueryTab, for connectionId: UUID) -> TableTabIdentity? { + tab.tableIdentity(browsing: activeSessions[connectionId]?.resolvedBrowseDatabase ?? "") + } + /// Authoritative schema for a table identity. An explicit schema passes through unchanged; a /// blank or missing one resolves to the live session's current schema and stays nil for /// schema-less engines. A blank name never reaches a query builder: engines that qualify diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index 8b7c3ef9f6..31cae3c4da 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -90,6 +90,9 @@ final class AppEvents { /// Payload is the connection's id. let browseContainerChanged = PassthroughSubject() + /// A window put another connections-strip entry in front, so the strip's highlight moves. + let visibleContainerChanged = PassthroughSubject() + // MARK: - Data Sources /// Query history changed (entry added, deleted, or cleared). diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index aced488739..ed492b7b03 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -66,20 +66,33 @@ enum MCPTabSnapshotProvider { return snapshots.filter { readable.contains($0.connectionId) } } + /// A database or schema the caller left out means the one being browsed, which is where the + /// open put the tab. Matching the name alone returned whichever same-named table sorted first, + /// so an agent asking for `tenant_b.orders` was handed the id of `tenant_a.orders`. static func awaitTab( connectionId: UUID, tableName: String?, + databaseName: String? = nil, + schemaName: String? = nil, clock: ContinuousClock = ContinuousClock() ) async -> MCPTabSnapshot? { let deadline = clock.now.advanced(by: openTimeout) while clock.now < deadline { - let snapshots = await MainActor.run { collectTabSnapshots() } - let candidates = snapshots.filter { $0.connectionId == connectionId } - if let tableName { - if let match = candidates.first(where: { $0.tableName == tableName }) { - return match + let match = await MainActor.run { () -> MCPTabSnapshot? in + let candidates = collectTabSnapshots().filter { $0.connectionId == connectionId } + guard let tableName else { + return candidates.first(where: \.isActive) ?? candidates.first } - } else if let match = candidates.first(where: \.isActive) ?? candidates.first { + let databaseManager = DatabaseManager.shared + return firstTable( + in: candidates, + matching: databaseManager.requestedTableIdentity( + table: tableName, database: databaseName, schema: schemaName, for: connectionId + ), + browseDatabase: databaseManager.activeSessions[connectionId]?.resolvedBrowseDatabase ?? "" + ) + } + if let match { return match } try? await Task.sleep(for: openPollInterval) @@ -87,6 +100,20 @@ enum MCPTabSnapshotProvider { return nil } + static func firstTable( + in snapshots: [MCPTabSnapshot], + matching wanted: TableTabIdentity, + browseDatabase: String + ) -> MCPTabSnapshot? { + snapshots.first { snapshot in + guard snapshot.tabType == TabType.table.snapshotName, + let table = snapshot.tableName + else { return false } + let database = snapshot.databaseName.flatMap { $0.isEmpty ? nil : $0 } ?? browseDatabase + return TableTabIdentity(table: table, database: database, schema: snapshot.schemaName) == wanted + } + } + static func encode(_ snapshot: MCPTabSnapshot) -> JsonValue { var fields: [String: JsonValue] = [ "connection_id": .string(snapshot.connectionId.uuidString), diff --git a/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift index 05509f71c8..bde443a1f4 100644 --- a/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift @@ -78,7 +78,9 @@ public struct OpenTableTabTool: MCPToolImplementation { guard let snapshot = await MCPTabSnapshotProvider.awaitTab( connectionId: connectionId, - tableName: tableName + tableName: tableName, + databaseName: databaseName, + schemaName: schemaName ) else { throw MCPToolExecutionError.timedOut( String(localized: "TablePro did not open that table in time.") diff --git a/TablePro/Core/Scripting/ScriptingSnapshot.swift b/TablePro/Core/Scripting/ScriptingSnapshot.swift index 2a3ed655a5..4adc66e8e4 100644 --- a/TablePro/Core/Scripting/ScriptingSnapshot.swift +++ b/TablePro/Core/Scripting/ScriptingSnapshot.swift @@ -105,19 +105,28 @@ internal enum ScriptingSnapshot { /// The table name alone is not an identity. Another database or schema on the same connection /// can have a table of the same name already open, and returning that one hands the script a - /// reference to rows it did not ask for. + /// reference to rows it did not ask for. A database or schema the script left out means the one + /// being browsed, which is where the open put the tab, not any of them. private static func matches( _ tab: ScriptTab, tableName: String, databaseName: String?, schemaName: String? ) -> Bool { - guard tab.kind == ScriptEnumerations.code(for: .table), tab.tableName == tableName else { - return false - } - if let databaseName, tab.databaseName != databaseName { return false } - if let schemaName, tab.schemaName != schemaName { return false } - return true + guard tab.kind == ScriptEnumerations.code(for: .table), + let tabTable = tab.tableName, + tabTable == tableName + else { return false } + let databaseManager = DatabaseManager.shared + let wanted = databaseManager.requestedTableIdentity( + table: tableName, database: databaseName, schema: schemaName, for: tab.connectionId + ) + let browseDatabase = databaseManager.activeSessions[tab.connectionId]?.resolvedBrowseDatabase ?? "" + return TableTabIdentity( + table: tabTable, + database: tab.databaseName ?? browseDatabase, + schema: tab.schemaName + ) == wanted } @discardableResult diff --git a/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift index 969c1dc830..2bede2a98d 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift @@ -102,6 +102,7 @@ internal final class ConnectionWorkspace { private var browseCancellable: AnyCancellable? private var statusCancellable: AnyCancellable? private var tabsCancellable: AnyCancellable? + private var catalogCancellable: AnyCancellable? /// Set before `teardown` clears anything. Releasing the workspace writes `session = nil`, whose /// observer would otherwise record the container all over again from the session the manager has @@ -159,6 +160,25 @@ internal final class ConnectionWorkspace { .sink { [weak self] _ in self?.recordTabContainers() } + catalogCancellable = AppCommands.shared.containerChanged + .filter { [connectionId] change in change.connectionId == connectionId } + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self, let type = self.connection?.type else { return } + self.adoptContainerChange(change, target: PluginManager.shared.containerSwitchTarget(for: type)) + } + } + + /// A dropped database is not open any more, and a renamed one is open under its new name. Only + /// the user closing an entry took one out of the set, so a dropped database stayed listed and + /// clicking it failed the switch with an error. + internal func adoptContainerChange(_ change: DatabaseContainerChange, target: ContainerSwitchTarget?) { + guard let name = WorkspaceAnchoring.entryName(of: change.container, target: target), + openedContainers.contains(name) + else { return } + closeContainer(name) + guard case .renamed(let newName) = change.kind else { return } + openContainer(newName) } private func recordBrowsedContainer() { @@ -376,6 +396,7 @@ internal final class ConnectionWorkspace { browseCancellable = nil statusCancellable = nil tabsCancellable = nil + catalogCancellable = nil openedContainers = [] panes.teardown() trailingPaneState?.teardown() diff --git a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index bfa46837db..7a991f761b 100644 --- a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -78,7 +78,8 @@ internal enum EditorTabOpener { case .erDiagram: tabManager.addERDiagramTab( schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? browseDatabaseName, - databaseName: payload.databaseName ?? browseDatabaseName + databaseName: payload.databaseName ?? browseDatabaseName, + schemaName: payload.schemaName ) case .serverDashboard: tabManager.addServerDashboardTab() diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift index b5e944ee1b..f0ed4d9466 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift @@ -38,6 +38,13 @@ extension MainSplitViewController { /// action the command does not take. var closeCommandTitle: String? { if commandActions?.hasOpenTab == true { return String(localized: "Close Tab") } + if let workspace = workspaces.selected, + let entry = WorkspaceCloseAction.closableEntry( + connectionId: workspace.connectionId, + container: workspace.sessionState?.tabManager.visibleContainer + ) { + return WorkspaceRailViewController.containerCloseTitle(for: entry) + } guard let name = workspaces.selected?.connection?.name else { return nil } return String(format: String(localized: "Close “%@”"), name) } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift index d274572ef7..c70ad0eb3d 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift @@ -38,7 +38,7 @@ internal extension MainSplitViewController { /// real `tabManager` to observe, and there is no single moment in the lifecycle that is /// reliably that call. func applyTabStripVisibility() { - let tabCount = workspaces.selected?.sessionState?.tabManager.tabs.count ?? 0 + let tabCount = workspaces.selected?.sessionState?.tabManager.visibleTabs.count ?? 0 tabStripAccessory.setBandVisible( ConnectionWindowPaneResolver.showsTabStrip( for: currentPane, @@ -71,12 +71,13 @@ internal extension MainSplitViewController { /// `onMainActorChange` already delivers a turn late, which is what the count needs: /// the publisher fires before the change lands. It also wakes for any tab mutation - /// rather than only a count change, so the count is compared here. + /// rather than only a count change, so the count is compared here. The count is the + /// strip's, which moving to another entry changes without a tab opening or closing. guard let manager = workspaces.selected?.sessionState?.tabManager else { return } - var lastCount = manager.tabs.count + var lastCount = manager.visibleTabs.count tabStripObservation = manager.onMainActorChange { [weak self] in guard let self, generation == self.tabStripObservationGeneration else { return } - let current = manager.tabs.count + let current = manager.visibleTabs.count guard current != lastCount else { return } lastCount = current self.tabStripObservationIsArmed = false @@ -180,7 +181,7 @@ internal extension MainSplitViewController { /// truncated or duplicated name is told apart. tooltip: { [weak manager] id in guard let manager, let tab = manager.tabs.first(where: { $0.id == id }) else { return "" } - let description = EditorTabLabelResolver.resolve(tabs: manager.tabs, target: target)[id]? + let description = EditorTabLabelResolver.resolve(tabs: manager.visibleTabs, target: target)[id]? .description ?? tab.title guard tab.isPreview else { return description } return String( @@ -193,7 +194,7 @@ internal extension MainSplitViewController { /// `EditorTabStripInteraction.adopt(tabIds:overflow:)`; the strip's own `onChange` carries /// every later change. interaction.adopt( - tabIds: manager.tabs.map(\.id), + tabIds: manager.visibleTabs.map(\.id), overflow: AppSettingsManager.shared.tabs.overflow ) } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index c17d0b3b0d..9457e45599 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -738,7 +738,7 @@ internal final class MainSplitViewController: NSSplitViewController { agentSessionTitle: agentSession?.title, connection: paneConnection, tab: sessionState?.tabManager.selectedTab, - hasTabs: !(sessionState?.tabManager.tabs.isEmpty ?? true), + hasTabs: !(sessionState?.tabManager.visibleTabs.isEmpty ?? true), queryLanguageName: paneConnection.map { PluginManager.shared.queryLanguageName(for: $0.type) } ?? nil ) windowTitle = resolved.title diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 860037356d..7d3cd42721 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -56,7 +56,14 @@ enum SessionStateFactory { ) -> SessionState { let connectionId = connection.id let tabSessionRegistry = TabSessionRegistry() + let containerTarget = PluginManager.shared.containerSwitchTarget(for: connection.type) + /// The entry the window opens on is where the connection is, or will be once it connects. + let openingContainer = DatabaseManager.shared.session(for: connectionId) + .flatMap { WorkspaceAnchoring.browsedContainer(of: $0, target: containerTarget) } + ?? WorkspaceAnchoring.defaultContainer(of: connection, target: containerTarget) let tabMgr = QueryTabManager( + containerTarget: containerTarget, + visibleContainer: openingContainer, globalTabsProvider: { MainActor.assumeIsolated { MainContentCoordinator.allTabs(for: connectionId) } }, diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 2ae63adb8b..1271f38848 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -282,16 +282,12 @@ internal final class TabRouter { private func focusExistingTableTab( connectionId: UUID, database: String?, schema: String?, table: String ) -> Bool { - HostedTabRouting.live.revealFirstTab(of: connectionId) { tab in - guard tab.tabType == .table, - tab.tableContext.tableName == table else { return false } - let databaseMatches = database.map { db in - tab.tableContext.databaseName == db - } ?? true - let schemaMatches = schema.map { sch in - tab.tableContext.schemaName.map { $0 == sch } ?? false - } ?? true - return databaseMatches && schemaMatches + let databaseManager = DatabaseManager.shared + let wanted = databaseManager.requestedTableIdentity( + table: table, database: database, schema: schema, for: connectionId + ) + return HostedTabRouting.live.revealFirstTab(of: connectionId) { tab in + databaseManager.tableIdentity(of: tab, for: connectionId) == wanted } } diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 34294f1b1c..34e6c0f9f3 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -95,8 +95,7 @@ internal final class WindowManager { intent: .openContent ) let state = SessionStateFactory.create(connection: connection, payload: nil) - state.tabManager.tabs = [tab] - state.tabManager.selectedTabId = tab.id + state.tabManager.seed(with: tab) /// Selected before anything observes the manager, so the switch that prepares an incoming /// tab has to be run by hand, as `openTabInNewWindow` does for the tab it moves. state.coordinator.handleTabChange(from: nil, to: tab.id, tabs: [tab]) @@ -246,8 +245,7 @@ internal final class WindowManager { /// value drops the user's position in the editor. let moved = sourceState.coordinator.enrichedForPersistence(tab) let state = SessionStateFactory.create(connection: connection, payload: nil) - state.tabManager.tabs = [moved] - state.tabManager.selectedTabId = moved.id + state.tabManager.seed(with: moved) /// The rows the tab already loaded live in its `TabSession`, which belongs to the /// coordinator's registry rather than to the tab. Without handing it over the new window @@ -520,6 +518,15 @@ internal final class WindowManager { .first } + /// The coordinator hosting this connection in `window`, or in whichever window hosts it when + /// that one does not. A connection a tab was torn off from has one coordinator per window, each + /// with its own tabs. + internal func coordinator(for connectionId: UUID, in window: NSWindow?) -> MainContentCoordinator? { + let host = window?.contentViewController as? MainSplitViewController + return host?.workspaces.workspace(for: connectionId)?.sessionState?.coordinator + ?? coordinator(for: connectionId) + } + private func hosts() -> [MainSplitViewController] { controllers.values.compactMap { $0.window?.contentViewController as? MainSplitViewController } } @@ -556,6 +563,24 @@ internal final class WindowManager { workspaces(for: connectionId).compactMap { $0.sessionState?.coordinator } } + /// The coordinator of the frontmost window hosting this connection, by window order rather than + /// key status: a cursor an MCP client moves while another app is active has no key window. + internal func frontmostCoordinator(for connectionId: UUID) -> MainContentCoordinator? { + let hosting = hosts().compactMap { host -> (window: NSWindow?, coordinator: MainContentCoordinator)? in + guard let coordinator = host.workspaces.workspace(for: connectionId)?.sessionState?.coordinator else { + return nil + } + return (host.view.window, coordinator) + } + guard hosting.count > 1 else { return hosting.first?.coordinator } + let order = NSApp.orderedWindows + return hosting.min { lhs, rhs in + let left = lhs.window.flatMap { order.firstIndex(of: $0) } ?? Int.max + let right = rhs.window.flatMap { order.firstIndex(of: $0) } ?? Int.max + return left < right + }?.coordinator + } + internal func hostedCoordinators() -> [MainContentCoordinator] { hostedWorkspaces().compactMap { $0.sessionState?.coordinator } } diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift b/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift index e8167f20e6..9fadc533c9 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift @@ -33,6 +33,15 @@ internal enum WorkspaceCloseAction { entryCount > 1 ? .container : .connection } + /// The entry Close takes in a window that shows an entry with no tab left: that container, or + /// nil when it is the connection's only entry, which closing would take the connection with. + internal static func closableEntry(connectionId: UUID, container: String?) -> WorkspaceRailEntry? { + guard let container, !container.isEmpty else { return nil } + let entries = WorkspaceRailStore.entries.filter { $0.workspace.connectionId == connectionId } + guard scope(entryCount: entries.count) == .container else { return nil } + return entries.first { $0.container == container } + } + /// The entry that takes the closed one's place, so closing lands on a neighbour the way closing /// a tab does, rather than on whatever the connection happened to browse last. internal static func neighbour(in containers: [String], closing container: String) -> String? { @@ -130,6 +139,9 @@ internal enum WorkspaceCloseAction { ) let left = await browseAway(from: workspace, among: containers, coordinator: coordinator) + if left { + leave(workspace, in: coordinators) + } for hostedWorkspace in hostedWorkspaces { hostedWorkspace.endClosing() } @@ -157,7 +169,6 @@ internal enum WorkspaceCloseAction { hostedWorkspace.closeContainer(workspace.container) } } - landOnRemainingTab(after: workspace, among: containers, coordinator: coordinator) /// The database keeps its own connection and its catalog while its entry is listed; closing the /// entry is what ends them, and the connection has already moved off it above. DatabaseManager.shared.sessionLanes.close(database: workspace.container, for: workspace.connectionId) @@ -189,13 +200,14 @@ internal enum WorkspaceCloseAction { guard !victims.isEmpty else { return everything } var closable: Set = [] - /// What each window was showing before it was brought forward to ask. A dirty split - /// connection reveals more than one of them, and the caller can only put one back, so an - /// abandoned close left the others switched to a connection the user never chose. - var revealed: [(host: MainSplitViewController, shown: UUID?)] = [] + /// What each window was showing before it was brought forward to ask, the entry and its tab + /// as well as the connection. A dirty split connection reveals more than one of them, and + /// the caller can only put one back, so an abandoned close left the others switched to a + /// connection the user never chose. + var revealed: [RevealedWindow] = [] func restoreRevealed() { - for entry in revealed { - entry.host.workspaces.select(entry.shown) + for window in revealed { + window.restore() } } @@ -212,9 +224,7 @@ internal enum WorkspaceCloseAction { closable.formUnion(owned.map(\.id)) continue } - if let host = coordinator.splitViewController { - revealed.append((host, host.workspaces.selectedConnectionId)) - } + revealed.append(RevealedWindow(coordinator: coordinator)) reveal(workspace, in: coordinator) switch await actions.resolveUnsavedWork(in: owned) { case .cancel: @@ -232,6 +242,33 @@ internal enum WorkspaceCloseAction { return closable } + /// A window as it was before it was brought forward to ask: the connection it showed, and that + /// connection's entry and tab. + @MainActor + private struct RevealedWindow { + let coordinator: MainContentCoordinator + let host: MainSplitViewController? + let shownConnection: UUID? + let entry: String? + let tab: UUID? + + init(coordinator: MainContentCoordinator) { + self.coordinator = coordinator + self.host = coordinator.splitViewController + self.shownConnection = host?.workspaces.selectedConnectionId + self.entry = coordinator.tabManager.visibleContainer + self.tab = coordinator.tabManager.selectedTabId + } + + func restore() { + host?.workspaces.select(shownConnection) + let manager = coordinator.tabManager + manager.show(container: entry) + guard let tab, manager.tabs.contains(where: { $0.id == tab }) else { return } + manager.selectedTabId = tab + } + } + /// Asked before the unsaved-work prompt, whose Save writes at once, so a Cancel here leaves /// everything as it was. Nothing is revealed: the alert names the database, and the transaction /// is not in any one tab. Only a database with its own connection loses a transaction on close. @@ -274,16 +311,14 @@ internal enum WorkspaceCloseAction { return WorkspaceRailStore.browsedWorkspace(for: workspace.connectionId)?.container == next } - /// The tab the connection last used in the container it landed in, once the closed container's - /// own tabs are gone. - private static func landOnRemainingTab( - after workspace: WorkspaceID, - among containers: [String], - coordinator: MainContentCoordinator? - ) { - guard let coordinator, let next = neighbour(in: containers, closing: workspace.container) else { return } - guard WorkspaceRailStore.browsedWorkspace(for: workspace.connectionId)?.container == next else { return } - coordinator.selectTab(inContainer: next) + /// Every window still showing the closed entry goes to the one the connection browses. The + /// window whose cursor moved away is there already; a window a tab was torn off into, or one + /// that only showed the entry to ask about its unsaved work, is not, and would otherwise be left + /// on an entry the strip no longer lists. + private static func leave(_ workspace: WorkspaceID, in coordinators: [MainContentCoordinator]) { + for coordinator in coordinators where coordinator.tabManager.visibleContainer == workspace.container { + coordinator.showBrowsedContainer() + } } private static func listedContainers(of connectionId: UUID) -> [String] { @@ -309,14 +344,6 @@ internal enum WorkspaceCloseAction { reveal(workspace, window: coordinator.contentWindow, coordinator: coordinator) } - private static func reveal(_ workspace: WorkspaceID, coordinator: MainContentCoordinator?) { - reveal( - workspace, - window: WindowManager.shared.window(for: workspace.connectionId), - coordinator: coordinator - ) - } - private static func reveal( _ workspace: WorkspaceID, window: NSWindow?, @@ -330,6 +357,8 @@ internal enum WorkspaceCloseAction { (window.contentViewController as? MainSplitViewController)? .workspaces.select(workspace.connectionId) } - coordinator?.selectTab(inContainer: workspace.container) + /// Shown to be asked about, not gone to: the object browser stays where it is, and a + /// Cancel puts the entry back the way it was. + coordinator?.tabManager.show(container: workspace.container) } } diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift index 543071e800..5610939c9b 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift @@ -49,22 +49,25 @@ internal enum WorkspaceRailStore { let sessions = DatabaseManager.shared.activeSessions let sessionless = openIds.filter { sessions[$0] == nil } - let workspacesById = hosted.reduce(into: [UUID: ConnectionWorkspace]()) { result, workspace in - result[workspace.connectionId] = workspace - } + /// Every window hosting a connection, not one of them: a tab torn off into its own window + /// still holds its container, and keeping one workspace per connection dropped the other + /// window's entries depending on dictionary order. + let workspacesById = Dictionary(grouping: hosted, by: \.connectionId) + let coordinatorsById = workspacesById.mapValues { $0.compactMap { $0.sessionState?.coordinator } } return resolveEntries( openConnectionIds: openIds, sessions: sessions, - hostedConnections: workspacesById.compactMapValues(\.connection), + hostedConnections: workspacesById.compactMapValues { $0.lazy.compactMap(\.connection).first }, storedConnections: Self.storedConnections(for: Array(sessionless)), containerTarget: { PluginManager.shared.containerSwitchTarget(for: $0) }, - /// The hosted workspace's own coordinator, never the app-wide registry: that one also + /// The hosted workspaces' own coordinators, never the app-wide registry: that one also /// lists the throwaway coordinators SwiftUI discards mid-body, whose stale copy of a /// closed tab kept a container listed and made a closed entry come back. - tabs: { workspacesById[$0]?.sessionState?.coordinator.tabManager.tabs ?? [] }, - openedContainers: { workspacesById[$0]?.openedContainers ?? [] }, - closingContainers: { workspacesById[$0]?.closingContainer }, - openedAt: workspacesById.mapValues(\.openedAt), + tabs: { coordinatorsById[$0]?.flatMap(\.tabManager.tabs) ?? [] }, + openedContainers: { workspacesById[$0]?.reduce(into: Set()) { $0.formUnion($1.openedContainers) } ?? [] }, + closingContainers: { workspacesById[$0]?.lazy.compactMap(\.closingContainer).first }, + visibleContainers: { Set(coordinatorsById[$0]?.compactMap(\.tabManager.visibleContainer) ?? []) }, + openedAt: workspacesById.compactMapValues { $0.map(\.openedAt).min() }, storedOrder: WorkspaceRailOrderStore.shared.order ) } @@ -84,6 +87,7 @@ internal enum WorkspaceRailStore { tabs: (UUID) -> [QueryTab], openedContainers: (UUID) -> Set = { _ in [] }, closingContainers: (UUID) -> String? = { _ in nil }, + visibleContainers: (UUID) -> Set = { _ in [] }, openedAt: [UUID: Date] = [:], storedOrder: [WorkspaceID] ) -> [WorkspaceRailEntry] { @@ -106,11 +110,13 @@ internal enum WorkspaceRailStore { let target = containerTarget(resolved.connection.type) targets[connectionId] = target + let closing = closingContainers(connectionId) + let shown = visibleContainers(connectionId).filter { $0 != closing } let held = containers( for: resolved, tabs: tabs(connectionId), - opened: openedContainers(connectionId), - closing: closingContainers(connectionId), + opened: openedContainers(connectionId).union(shown), + closing: closing, target: target ) for container in held { @@ -166,6 +172,7 @@ internal enum WorkspaceRailStore { events.connectionWindowsChanged.eraseToAnyPublisher(), events.workspaceRailOrderChanged.eraseToAnyPublisher(), events.workspaceTabsChanged.eraseToAnyPublisher(), + events.visibleContainerChanged.eraseToAnyPublisher(), events.browseContainerChanged.map { _ in () }.eraseToAnyPublisher(), events.connectionStatusChanged.map { _ in () }.eraseToAnyPublisher(), events.connectionUpdated.map { _ in () }.eraseToAnyPublisher() @@ -208,8 +215,8 @@ internal enum WorkspaceRailStore { /// connection that has not reached one yet names nothing either. Both still get one /// row, keyed by the empty container, so every open window is reachable from the rail. /// - /// `opened` is what the user has open and has not closed. Tabs are still read alongside it, - /// because a restored window has tabs before anything has browsed anywhere. + /// `opened` is what the user has open and has not closed, plus the entry each window shows. + /// Every container a tab names is listed too, because a tab is only reachable through its entry. /// /// The live browse cursor always earns a row: it is where the next tab opens, and a container /// the connection is standing in cannot be closed without leaving it first. The saved default is diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index cdb4901b47..2a99991286 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -379,10 +379,18 @@ internal final class WorkspaceRailViewController: NSViewController { onLayoutChange?(resolved) } - /// Both halves move: the window switches which connection it shows, and that connection - /// switches which container it browses. Neither can be captured at init. + /// Both halves move: the window switches which connection it shows, and that connection's tabs + /// switch which entry is in front. Neither can be captured at init. + /// + /// The entry this window shows, not the one the connection browses: a window a tab was torn off + /// into shares its connection's cursor with the window it came from, and highlighting the cursor + /// named an entry whose tabs that window was not showing. private var activeWorkspace: WorkspaceID? { guard let connectionId = host?.selectedConnectionId else { return nil } + if let shown = WindowManager.shared.coordinator(for: connectionId, in: view.window)? + .tabManager.visibleContainer { + return WorkspaceID(connectionId: connectionId, container: shown) + } return WorkspaceRailStore.browsedWorkspace(for: connectionId) } @@ -459,7 +467,11 @@ internal final class WorkspaceRailViewController: NSViewController { private func commit(row: Int) { guard entries.indices.contains(row) else { return } let workspace = entries[row].workspace - guard workspace != appliedSelection else { + /// The highlighted entry is clicked again to retry a switch that failed: its tabs are on + /// screen, but the object browser stayed where it was. + guard workspace != appliedSelection + || WorkspaceRailStore.browsedWorkspace(for: workspace.connectionId) != workspace + else { Self.logger.debug( "commit rail=\(self.railName, privacy: .public) ignored: already at \(Self.describe(workspace), privacy: .public)" ) @@ -531,10 +543,9 @@ internal final class WorkspaceRailViewController: NSViewController { applySelection() } - /// Both halves of going to a workspace: the object tree moves to its container, and the window - /// lands on the work that container already holds. They run in one place, in that order, - /// because the switch is asynchronous and a second trigger would select a tab against a cursor - /// that had not moved yet. + /// Going to an entry of a connection: its tabs come forward, and the object tree follows to its + /// container once the switch lands. `goToEntry` holds both halves, so the order cannot be got + /// wrong here. private func moveBrowseCursor(of window: NSWindow, to workspace: WorkspaceID) { guard !workspace.container.isEmpty else { Self.logger.debug("moveBrowseCursor skipped: unnamed container") @@ -545,7 +556,7 @@ internal final class WorkspaceRailViewController: NSViewController { /// other connection moved the browse cursor of the one on screen instead: it switched the /// visible connection to a database named after a different one, or failed against a /// database that connection does not have. - guard let coordinator = WindowManager.shared.coordinator(for: workspace.connectionId) else { + guard let coordinator = WindowManager.shared.coordinator(for: workspace.connectionId, in: window) else { Self.logger.error( """ moveBrowseCursor has no coordinator target=\(Self.describe(workspace), privacy: .public) \ @@ -554,41 +565,8 @@ internal final class WorkspaceRailViewController: NSViewController { ) return } - let current = WorkspaceRailStore.browsedWorkspace(for: workspace.connectionId) - guard current != workspace else { - Self.logger.debug( - "moveBrowseCursor skipped: already at \(Self.describe(workspace), privacy: .public)" - ) - coordinator.selectTab(inContainer: workspace.container) - return - } - Self.logger.info( - """ - moveBrowseCursor from=\(current.map(Self.describe) ?? "none", privacy: .public) \ - to=\(Self.describe(workspace), privacy: .public) - """ - ) - coordinator.tabManager.defersActivationRecord = true - Task { @MainActor in - defer { coordinator.tabManager.defersActivationRecord = false } - await coordinator.switchContainer(to: workspace.container) - let landed = WorkspaceRailStore.browsedWorkspace(for: workspace.connectionId) - if landed == workspace { - Self.logger.info("moveBrowseCursor landed \(Self.describe(workspace), privacy: .public)") - /// Only once the cursor is really there. A failed switch has already told the user - /// why, and selecting that container's tab on top of it would leave the window - /// showing one database while the tree lists another. - coordinator.selectTab(inContainer: workspace.container) - } else { - Self.logger.error( - """ - moveBrowseCursor did not land wanted=\(Self.describe(workspace), privacy: .public) \ - got=\(landed.map(Self.describe) ?? "none", privacy: .public) - """ - ) - } - self.applySelection() - } + Self.logger.info("moveBrowseCursor to=\(Self.describe(workspace), privacy: .public)") + coordinator.goToEntry(workspace.container) } /// The menu command is a commit, not a highlight: it names the workspace it goes to, so it diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index a015889f9f..9ece25aa48 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -14,12 +14,14 @@ final class QueryTabManager: ObservableObject { @Published var tabs: [QueryTab] = [] { didSet { _tabIndexMapDirty = true + _visibleTabs = nil if oldValue.map(\.id) != tabs.map(\.id) { tabStructureVersion += 1 pruneActivations(keeping: tabs) } publishAnchorChange(oldTabs: oldValue, newTabs: tabs) syncTabSessionRegistry(oldTabs: oldValue, newTabs: tabs) + reconcileVisibleContainer() } } @@ -31,10 +33,31 @@ final class QueryTabManager: ObservableObject { @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } + recordSelectedTabContainer() + reconcileVisibleContainer() scheduleActivationRecord() } } + /// What a tab's entry is decided by: the database on an engine that switches databases, the + /// schema on one that only switches schemas. Nil where the engine switches neither, and every + /// tab is in the connection's one entry. + let containerTarget: ContainerSwitchTarget? + + /// The connections-strip entry this window shows: the strip lists only that container's tabs, + /// plus the ones that name no container. Nil shows every tab. + /// + /// A switch never closes a tab, it only changes which entry is in front; the tabs of the others + /// are kept and come back with their entry. + @Published private(set) var visibleContainer: String? + + /// Told when the selection carried the window into another entry, such as a tab picked from + /// the recent-tabs list or rebound to another database, so the connection can browse it too. + var onVisibleContainerReconciled: (() -> Void)? + + private(set) var containerTabHistory = ContainerTabHistory() + private var _visibleTabs: [QueryTab]? + /// Whether the window shows this connection's tabs, in the key window. The window sets it, for /// every connection it hosts at once. Only then is a selection a tab the user switched to: a /// restore that finishes for a connection in the background selects a tab nobody has seen, and @@ -58,16 +81,6 @@ final class QueryTabManager: ObservableObject { private static var lastActivationSequence: UInt64 = 0 private var activationRecordPending = false - /// Held while the window is on its way to a tab across an await, such as the rail switching a - /// connection's database before it selects the tab that database holds. The tab shown in the - /// meantime is a waypoint, and the one landed on is recorded when the hold lifts. - var defersActivationRecord = false { - didSet { - guard !defersActivationRecord, oldValue else { return } - scheduleActivationRecord() - } - } - private var _tabIndexMap: [UUID: Int] = [:] private var _tabIndexMapDirty = true @@ -75,48 +88,68 @@ final class QueryTabManager: ObservableObject { private weak var tabSessionRegistry: TabSessionRegistry? init( + containerTarget: ContainerSwitchTarget? = nil, + visibleContainer: String? = nil, globalTabsProvider: @escaping () -> [QueryTab] = { [] }, tabSessionRegistry: TabSessionRegistry? = nil ) { + self.containerTarget = containerTarget + self.visibleContainer = containerTarget == nil ? nil : visibleContainer.flatMap { $0.isEmpty ? nil : $0 } self.globalTabsProvider = globalTabsProvider self.tabSessionRegistry = tabSessionRegistry } /// Closing a tab used to mean closing its window, because a tab was a window. Selection - /// lands on the tab that took its place so the pane never blanks while others are open. + /// lands on the tab that took its place in the strip, so the pane never blanks while the entry + /// still has tabs. An entry left with none shows its empty state rather than another entry's tab. func closeTab(id: UUID) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } let wasSelected = selectedTab?.id == id + let stripIndex = visibleTabs.firstIndex { $0.id == id } tabs.remove(at: index) guard wasSelected else { return } - selectedTabId = tabs.indices.contains(index) ? tabs[index].id : tabs.last?.id + let remaining = visibleTabs + guard let stripIndex, remaining.indices.contains(stripIndex) else { + selectedTabId = remaining.last?.id + return + } + selectedTabId = remaining[stripIndex].id } /// Reorders the strip. A pure permutation: the same tabs, a different order, and the selection /// stays on whichever tab it was on rather than on the position that tab vacated. /// + /// `destination` is a position in the strip, which lists one entry's tabs, so the move lands + /// beside the strip neighbour it was dropped next to wherever the other entries' tabs sit. + /// /// The array is rebuilt and assigned once rather than mutated twice, because `tabs.didSet` /// bumps `tabStructureVersion` and republishes the workspace anchors on every write, and the /// state between a `remove` and an `insert` is a tab list the user never had. func moveTab(id: UUID, to destination: Int) { - guard let source = tabs.firstIndex(where: { $0.id == id }) else { return } - let clamped = min(max(destination, 0), tabs.count - 1) + let strip = visibleTabs + guard let source = strip.firstIndex(where: { $0.id == id }) else { return } + let clamped = min(max(destination, 0), strip.count - 1) guard clamped != source else { return } + let neighbourId = strip[clamped].id var reordered = tabs - reordered.insert(reordered.remove(at: source), at: clamped) + guard let flatSource = reordered.firstIndex(where: { $0.id == id }) else { return } + let moving = reordered.remove(at: flatSource) + guard let neighbour = reordered.firstIndex(where: { $0.id == neighbourId }) else { return } + reordered.insert(moving, at: clamped > source ? neighbour + 1 : neighbour) tabs = reordered } /// Whether `tab` can move one place in `offset`'s direction, so a menu item can dim instead of /// being offered and doing nothing. func canMoveTab(id: UUID, by offset: Int) -> Bool { - guard let index = tabs.firstIndex(where: { $0.id == id }) else { return false } - return tabs.indices.contains(index + offset) + let strip = visibleTabs + guard let index = strip.firstIndex(where: { $0.id == id }) else { return false } + return strip.indices.contains(index + offset) } func moveTab(id: UUID, by offset: Int) { - guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + guard let index = visibleTabs.firstIndex(where: { $0.id == id }) else { return } moveTab(id: id, to: index + offset) } @@ -141,15 +174,17 @@ final class QueryTabManager: ObservableObject { } func selectTab(at index: Int) { - guard tabs.indices.contains(index) else { return } - selectedTabId = tabs[index].id + let strip = visibleTabs + guard strip.indices.contains(index) else { return } + selectedTabId = strip[index].id } func selectTab(offsetBy offset: Int) { - guard !tabs.isEmpty else { return } - let current = selectedTab.flatMap { tab in tabs.firstIndex { $0.id == tab.id } } ?? 0 - let count = tabs.count - selectedTabId = tabs[((current + offset) % count + count) % count].id + let strip = visibleTabs + guard !strip.isEmpty else { return } + let current = selectedTab.flatMap { tab in strip.firstIndex { $0.id == tab.id } } ?? 0 + let count = strip.count + selectedTabId = strip[((current + offset) % count + count) % count].id } // MARK: - Recency @@ -159,7 +194,7 @@ final class QueryTabManager: ObservableObject { /// the tab it was showing is never drawn; recorded as it passed through, it would be what the /// next Control-Tab went back to. private func scheduleActivationRecord() { - guard isFrontmost, !defersActivationRecord, !activationRecordPending else { return } + guard isFrontmost, !activationRecordPending else { return } activationRecordPending = true DispatchQueue.main.async { [weak self] in MainActor.assumeIsolated { self?.recordSettledActivation() } @@ -168,7 +203,7 @@ final class QueryTabManager: ObservableObject { private func recordSettledActivation() { activationRecordPending = false - guard isFrontmost, !defersActivationRecord, let id = selectedTab?.id else { return } + guard isFrontmost, let id = selectedTab?.id else { return } recordActivation(of: id) } @@ -224,7 +259,7 @@ final class QueryTabManager: ObservableObject { var selectedTab: QueryTab? { if let index = selectedTabIndex { return tabs[index] } - return selectedTabId == nil ? tabs.first : nil + return selectedTabId == nil ? visibleTabs.first : nil } var selectedTabIndex: Int? { @@ -297,6 +332,7 @@ final class QueryTabManager: ObservableObject { } newTab.tableContext.databaseName = databaseName + bindToVisibleContainer(&newTab) newTab.content.sourceFileURL = sourceFileURL if sourceFileURL != nil { FileTabBaseline.adopt( @@ -335,6 +371,8 @@ final class QueryTabManager: ObservableObject { /// minting a fresh one. Selecting it drives the window title, toolbar, and persistence through /// the usual `selectedTabId` observation. func adoptTab(_ tab: QueryTab, claimFocus: Bool = false) { + var tab = tab + bindToVisibleContainer(&tab) tabs.append(tab) selectedTabId = tab.id if claimFocus { @@ -463,16 +501,21 @@ final class QueryTabManager: ObservableObject { let tabTitle = String(localized: "Create Table") var newTab = QueryTab(title: tabTitle, tabType: .createTable) newTab.tableContext.databaseName = databaseName + bindToVisibleContainer(&newTab) newTab.tableContext.isEditable = false newTab.hasUserInteraction = true tabs.append(newTab) selectedTabId = newTab.id } - func addERDiagramTab(schemaKey: String, databaseName: String = "") { + /// `schemaName` is the schema the diagram draws, which on an engine whose entries are schemas + /// is the entry the tab belongs to, whichever one is on screen when it opens. + func addERDiagramTab(schemaKey: String, databaseName: String = "", schemaName: String? = nil) { let tabTitle = String(localized: "ER Diagram") var newTab = QueryTab(title: tabTitle, tabType: .erDiagram) newTab.tableContext.databaseName = databaseName + newTab.tableContext.schemaName = schemaName + bindToVisibleContainer(&newTab) newTab.display.erDiagramSchemaKey = schemaKey newTab.tableContext.isEditable = false newTab.hasUserInteraction = true @@ -686,3 +729,138 @@ final class QueryTabManager: ObservableObject { #endif } } + +// MARK: - Visible Container + +extension QueryTabManager { + /// The tabs the strip lists: the visible entry's, plus the ones that name no container. + var visibleTabs: [QueryTab] { + if let cached = _visibleTabs { return cached } + let visible = tabs.filter(isVisible) + _visibleTabs = visible + return visible + } + + func containerName(of tab: QueryTab) -> String? { + WorkspaceAnchoring.containerName(of: tab, target: containerTarget) + } + + /// A tab that names no container belongs to no entry, so every entry shows it: a server + /// dashboard, or a query tab on an engine whose query tabs run against whatever is browsed. + func isVisible(_ tab: QueryTab) -> Bool { + guard let visibleContainer, let container = containerName(of: tab) else { return true } + return container == visibleContainer + } + + /// Puts an entry in front. The selection stays when it is already in that entry, lands on the + /// tab last used there otherwise, and on nothing when the entry holds no tab of its own, which + /// is the entry's empty state rather than a tab from somewhere else. + /// + /// It never moves the browse cursor. Whoever moved the cursor calls this, or calls it first and + /// moves the cursor after. + func show(container: String?) { + guard containerTarget != nil else { return } + let container = container.flatMap { $0.isEmpty ? nil : $0 } + if container != visibleContainer { + setVisibleContainer(container) + } + let landing = selectionLanding(in: container) + guard landing != selectedTabId else { return } + selectedTabId = landing + } + + /// A new window's only tab. Its entry is put in front before the tab is selected, so a window + /// built around a tab never reads as the user picking a tab in another entry, which would move + /// the connection's database on its own. + func seed(with tab: QueryTab) { + tabs = [tab] + show(container: containerName(of: tab) ?? visibleContainer) + selectedTabId = tab.id + } + + /// A tab is bound to the database, or the schema on an engine whose entries are schemas, it was + /// opened in. One a caller opened without naming it, such as a New Tab on a schema engine, + /// Favorites or Open in Editor, gets the visible entry's. Left unbound it ran against whatever + /// the connection browsed next, and picking a tab of another entry moves that: a statement + /// typed against one schema would run against the other. + func bindToVisibleContainer(_ tab: inout QueryTab) { + guard let visibleContainer, + Self.bindsToContainer(tab.tabType), + containerName(of: tab) == nil + else { return } + switch containerTarget { + case .database: + tab.tableContext.databaseName = visibleContainer + case .schema: + tab.tableContext.schemaName = visibleContainer + case nil: + break + } + } + + /// Restored tabs from before tabs were bound to their entry: they ran against the container the + /// connection was on, which is the one the restore lands in. + func bindUnboundTabs() { + guard visibleContainer != nil, + tabs.contains(where: { Self.bindsToContainer($0.tabType) && containerName(of: $0) == nil }) + else { return } + var bound = tabs + for index in bound.indices { + bindToVisibleContainer(&bound[index]) + } + tabs = bound + } + + /// Server-wide tabs answer for the whole connection, so they belong to every entry. + private static func bindsToContainer(_ type: TabType) -> Bool { + switch type { + case .query, .table, .createTable, .erDiagram, .objectSource: + return true + case .serverDashboard, .usersRoles, .insights, .versionHistory: + return false + } + } + + private func selectionLanding(in container: String?) -> UUID? { + let selected = selectedTabId.flatMap { id in tabs.first { $0.id == id } } + guard let container else { return selected?.id ?? visibleTabs.first?.id } + if let selected, containerName(of: selected) == container { + return selected.id + } + if let remembered = containerTabHistory.tabToSelect( + inContainer: container, among: tabs, target: containerTarget + ) { + return remembered + } + if let selected, isVisible(selected) { + return selected.id + } + return visibleTabs.last?.id + } + + /// Selecting a tab of another entry, or moving the selected tab into one, takes the window to + /// that entry: the selected tab is always one the strip lists. A selection that names a tab not + /// in the list yet is the moment inside a close or an adoption, and is left alone. + fileprivate func reconcileVisibleContainer() { + guard containerTarget != nil, + let id = selectedTabId, + let tab = tabs.first(where: { $0.id == id }), + let container = containerName(of: tab), + container != visibleContainer + else { return } + containerTabHistory.record(tabId: id, container: container) + setVisibleContainer(container) + onVisibleContainerReconciled?() + } + + fileprivate func recordSelectedTabContainer() { + guard let id = selectedTabId, let tab = tabs.first(where: { $0.id == id }) else { return } + containerTabHistory.record(tabId: id, container: containerName(of: tab)) + } + + private func setVisibleContainer(_ container: String?) { + visibleContainer = container + _visibleTabs = nil + AppEvents.shared.visibleContainerChanged.send() + } +} diff --git a/TablePro/Models/UI/QuickSwitcherOpenTable.swift b/TablePro/Models/UI/QuickSwitcherOpenTable.swift index fd0dfa9d06..a6996b82c4 100644 --- a/TablePro/Models/UI/QuickSwitcherOpenTable.swift +++ b/TablePro/Models/UI/QuickSwitcherOpenTable.swift @@ -7,28 +7,36 @@ import Foundation /// A table that already has a tab, identified the way the quick switcher has to match it. /// -/// Matching on the bare name is wrong the moment a connection has two schemas holding a table of -/// the same name: with `public.users` open, `analytics.users` was badged Open, boosted in the -/// ranking and offered "Switch to Tab", and committing it opened a second tab anyway. +/// Matching on the bare name is wrong the moment a connection has two databases or two schemas +/// holding a table of the same name: with `tenant_a.orders` open, `tenant_b.orders` was badged +/// Open, boosted in the ranking and offered "Switch to Tab", and committing it opened a second tab +/// anyway. /// -/// A tab or a catalog entry that names no schema follows the connection's browse cursor, which is -/// the rule `TabTableContext.resolvedDatabaseName(browsing:)` already applies to databases. Both -/// sides resolve through this initializer with the same cursor, so a driver that reports no schema -/// at all still matches on name, while two schemas that both hold `users` no longer collide. +/// A side that names no database or schema follows the connection's browse cursor, the rule +/// `TabTableContext.resolvedDatabaseName(browsing:)` applies. Both sides resolve through this +/// initializer with the same cursor, so a driver that reports neither still matches on name. internal struct QuickSwitcherOpenTable: Hashable, Sendable { + internal let database: String? internal let schema: String? internal let name: String - internal init(schema: String?, name: String, browsing browseSchema: String?) { - self.schema = Self.resolve(schema, browsing: browseSchema) + internal init( + database: String?, + schema: String?, + name: String, + browsingDatabase: String?, + browsingSchema: String? + ) { + self.database = Self.resolve(database, browsing: browsingDatabase) + self.schema = Self.resolve(schema, browsing: browsingSchema) self.name = name } - private static func resolve(_ schema: String?, browsing browseSchema: String?) -> String? { - if let schema, !schema.isEmpty { - return schema + private static func resolve(_ value: String?, browsing browsed: String?) -> String? { + if let value, !value.isEmpty { + return value } - guard let browseSchema, !browseSchema.isEmpty else { return nil } - return browseSchema + guard let browsed, !browsed.isEmpty else { return nil } + return browsed } } diff --git a/TablePro/Models/Workspace/QueryContainerRebinding.swift b/TablePro/Models/Workspace/QueryContainerRebinding.swift new file mode 100644 index 0000000000..9d57ffd950 --- /dev/null +++ b/TablePro/Models/Workspace/QueryContainerRebinding.swift @@ -0,0 +1,28 @@ +// +// QueryContainerRebinding.swift +// TablePro +// + +import Foundation + +/// Points a query tab at another entry from the editor's container picker. +internal enum QueryContainerRebinding { + /// On an engine whose entries are databases the schema goes with the old database: a schema + /// only means something inside the database that holds it, and a moved tab kept naming one the + /// new database may not have. On one whose entries are schemas the database stays. + internal static func rebind( + _ context: inout TabTableContext, + to container: String, + target: ContainerSwitchTarget? + ) { + switch target { + case .database: + context.databaseName = container + context.schemaName = nil + case .schema: + context.schemaName = container + case nil: + break + } + } +} diff --git a/TablePro/Models/Workspace/WorkspaceAnchoring.swift b/TablePro/Models/Workspace/WorkspaceAnchoring.swift index 8feaafc78b..234015b6cf 100644 --- a/TablePro/Models/Workspace/WorkspaceAnchoring.swift +++ b/TablePro/Models/Workspace/WorkspaceAnchoring.swift @@ -5,71 +5,37 @@ import Foundation -/// Which containers a connection's open tabs hold open. +/// Which container a tab belongs to, and so which connections-strip entry lists it. /// -/// This answers for tabs alone. What the connections strip lists is what the connection has open: -/// `ConnectionWorkspace.openedContainers`, which the user adds to by browsing to a container and -/// empties by closing its entry, plus whatever tabs hold. Anchoring used to be the whole of that -/// membership, under the rule "the rail lists work, not history", and deriving a switcher's rows -/// from what its rows were being used for is what let clicking one entry delete another. +/// Each entry owns its tabs: the tab strip shows the selected entry's tabs, and every container a +/// tab names is an entry, so no tab ever sits behind an entry the strip does not list. What else the +/// strip lists is `ConnectionWorkspace.openedContainers`, which the user adds to by browsing to a +/// container and empties by closing its entry. /// -/// A tab holds its container when it carries work that would be lost if the row went away. A brand -/// new query tab with nothing in it is not work, so a window restoring an empty scratch tab into a -/// container nobody has opened does not open a row for it. Everything else, including a query tab -/// the user has typed into, holds its container until its last tab closes. +/// A tab that names no container belongs to no entry and is shown in all of them: a server +/// dashboard, or a query tab on an engine whose query tabs run against whatever is being browsed. internal enum WorkspaceAnchoring { /// A tab's identity as far as anchoring is concerned. Comparing these across a tab /// mutation is what tells the rail whether it has to reload. internal struct Anchor: Hashable { internal let databaseName: String internal let schemaName: String? - internal let holdsContainer: Bool } - /// Whether a tab carries work that would be lost. A brand new query tab does not; everything - /// else does, including a query tab the user has typed into. - internal static func hasWork(_ tab: QueryTab) -> Bool { - guard tab.tabType == .query else { return true } - return tab.hasUserInteraction - || !tab.content.query.isEmpty - || tab.content.sourceFileURL != nil - } - - /// A tab holds a container only if it has work AND can name the container it belongs to. - /// The two have to be decided together: only `.table` tabs are given a schema, so on a - /// schema-switching engine a query tab full of unsaved SQL would otherwise be reported as - /// holding a container it cannot name, and its row would vanish from the rail on the next - /// switch. That is exactly the loss the anchoring rule exists to prevent. - internal static func holdsContainer(_ tab: QueryTab, target: ContainerSwitchTarget?) -> Bool { - guard containerName(of: tab, target: target) != nil else { return false } - return hasWork(tab) - } - - /// What a tab's container is called, whether or not the tab holds it open. Close commands - /// name every tab they are about to close; the rail only lists the ones worth a row. + /// What a tab's container is called, or nil when the tab belongs to every entry. internal static func containerName(of tab: QueryTab, target: ContainerSwitchTarget?) -> String? { named(database: tab.tableContext.databaseName, schema: tab.tableContext.schemaName, target: target) } - internal static func container(of tab: QueryTab, target: ContainerSwitchTarget?) -> String? { - guard holdsContainer(tab, target: target) else { return nil } - return containerName(of: tab, target: target) - } - internal static func containers(in tabs: [QueryTab], target: ContainerSwitchTarget?) -> Set { - Set(tabs.compactMap { container(of: $0, target: target) }) + Set(tabs.compactMap { containerName(of: $0, target: target) }) } /// Change detection only, so it stays free of the engine's container target: any change to a - /// tab's database, schema or work state can move a rail row, whichever of them this engine - /// keys on. + /// tab's database or schema can move a rail row, whichever of them this engine keys on. internal static func anchors(in tabs: [QueryTab]) -> [Anchor] { tabs.map { - Anchor( - databaseName: $0.tableContext.databaseName, - schemaName: $0.tableContext.schemaName, - holdsContainer: hasWork($0) - ) + Anchor(databaseName: $0.tableContext.databaseName, schemaName: $0.tableContext.schemaName) } } @@ -79,6 +45,15 @@ internal enum WorkspaceAnchoring { named(database: session.resolvedBrowseDatabase, schema: session.browseSchema, target: target) } + /// The entry a saved database and schema pair names, such as the one a connection was last on. + internal static func containerName( + database: String?, + schema: String?, + target: ContainerSwitchTarget? + ) -> String? { + named(database: database ?? "", schema: schema, target: target) + } + /// The container a connection names before it has a session to browse with. A schema /// switching engine names none, because a saved connection records no schema. internal static func defaultContainer( @@ -88,6 +63,20 @@ internal enum WorkspaceAnchoring { named(database: connection.database, schema: nil, target: target) } + /// The entry a dropped or renamed database or schema is listed under, or nil when that kind of + /// container is not what this engine's entries are: a schema dropped inside a PostgreSQL + /// database is not an entry of its own. + internal static func entryName(of container: DatabaseContainerRef, target: ContainerSwitchTarget?) -> String? { + switch (target, container.kind) { + case (.database, .database): + return container.database.flatMap { $0.isEmpty ? nil : $0 } + case (.schema, .schema): + return container.schema.flatMap { $0.isEmpty ? nil : $0 } + default: + return nil + } + } + /// An engine that switches neither database nor schema has one container, and naming it /// after a tab's database would split it into rows the rail has no way to activate. private static func named(database: String, schema: String?, target: ContainerSwitchTarget?) -> String? { diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 1be8a828c4..d0290a00a1 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -786,7 +786,13 @@ internal final class QuickSwitcherViewModel: ObservableObject { kind: presentation.kind, subtitle: subtitle, isOpenInTab: openTables.contains( - QuickSwitcherOpenTable(schema: table.schema, name: table.name, browsing: browseSchema) + QuickSwitcherOpenTable( + database: database, + schema: table.schema, + name: table.name, + browsingDatabase: database, + browsingSchema: browseSchema + ) ), isReadOnly: !table.type.allowsRowEditing, schemaName: table.schema, diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 47a95e2e64..883499d108 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -444,13 +444,21 @@ struct MainEditorContentView: View { PluginManager.shared.containerSwitchTarget(for: connection.type) } + /// The schemas on an engine whose entries are schemas: a query tab is bound to one, and this is + /// how it is pointed at another. private func containerDatabases(for tab: QueryTab) -> [DatabaseMetadata] { - guard containerSwitchTarget == .database else { return [] } - return DatabaseSwitchList.sections( - databases: treeService.databases(for: connectionId), - selected: SharedSidebarState.forConnection(connectionId).databaseFilterSelected, - activeDatabase: containerName(for: tab) - ).all + switch containerSwitchTarget { + case .database: + return DatabaseSwitchList.sections( + databases: treeService.databases(for: connectionId), + selected: SharedSidebarState.forConnection(connectionId).databaseFilterSelected, + activeDatabase: containerName(for: tab) + ).all + case .schema: + return schemaService.schemas(for: connectionId).map { DatabaseMetadata.minimal(name: $0) } + case nil: + return [] + } } private var isContainerSwitchReadOnly: Bool { @@ -465,6 +473,9 @@ struct MainEditorContentView: View { /// Read from the tab's own scope, the same value completion resolves against, so the control /// and the suggestions can never describe different databases. private func containerName(for tab: QueryTab) -> String { + if containerSwitchTarget == .schema { + return coordinator.scope(for: tab)?.schema ?? tab.tableContext.schemaName ?? "" + } if let scoped = coordinator.scope(for: tab)?.database, !scoped.isEmpty { return scoped } let bound = tab.tableContext.databaseName return bound.isEmpty ? coordinator.browseDatabaseName : bound @@ -477,12 +488,14 @@ struct MainEditorContentView: View { return coordinator.scope(for: tab)?.schema } - /// Rebinding the container is a tab-local edit. The tab owns the new database for the - /// rest of its life and the sidebar's browse cursor stays where the user left it. + /// The tab owns the new database, or schema, for the rest of its life, so it moves to that + /// entry and the window goes with it, the way selecting a tab of another entry does. private func changeContainer(for tab: QueryTab, to name: String) { let tabId = tab.id - guard tab.tableContext.databaseName != name, - tabManager.mutate(tabId: tabId, { $0.tableContext.databaseName = name }) else { return } + guard tabManager.containerName(of: tab) != name, + tabManager.mutate(tabId: tabId, { tab in + QueryContainerRebinding.rebind(&tab.tableContext, to: name, target: containerSwitchTarget) + }) else { return } tabManager.markTabRenamed(tabId) SchemaProviderRegistry.shared.reclaimUnheldProviders(for: connectionId) guard tabManager.selectedTabId == tabId, diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index c2be1fd6ae..f6c2842faf 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -75,7 +75,7 @@ internal struct EditorTabStrip: View { /// A closed tab leaves its id behind, and the tab that slides into its place would /// otherwise light up under a pointer that never moved onto it. The initial list is seeded /// where the strip is built, so these two carry changes only. - .onChange(of: tabManager.tabs.map(\.id)) { ids in + .onChange(of: tabManager.visibleTabs.map(\.id)) { ids in interaction.dropClosedTabs(keeping: ids) } .onChange(of: settings.tabs.overflow) { style in @@ -144,7 +144,7 @@ internal struct EditorTabStrip: View { } private var displayedTabs: [QueryTab] { - let byId = Dictionary(tabManager.tabs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let byId = Dictionary(tabManager.visibleTabs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) return interaction.displayedIds.compactMap { byId[$0] } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift index c72945ea94..4674a8fe67 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift @@ -30,7 +30,7 @@ extension MainContentCoordinator { } if tabManager.tabs.isEmpty { - tabManager.addERDiagramTab(schemaKey: schemaKey, databaseName: dbName) + tabManager.addERDiagramTab(schemaKey: schemaKey, databaseName: dbName, schemaName: schemaName) return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index b81629b8fa..020aa9d119 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -461,12 +461,17 @@ extension MainContentCoordinator { // MARK: - Database Switching /// Moves the browse cursor: what the sidebar lists and which database a new tab - /// opens in. It never retargets an open tab, and an open tab never calls it. + /// opens in. It never retargets an open tab. Once it lands, the window shows that database's + /// entry, unless a newer move from this window has been asked for since. /// `persist` records the database as the connection's saved default. @discardableResult func switchDatabase(to database: String, persist: Bool = true) async -> Bool { + let request = beginCursorRequest() do { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: persist) + if endCursorRequest(request) { + showBrowsedContainer() + } toolbarState.currentDatabase = database toolbarState.currentSchema = DatabaseManager.shared.session(for: connectionId)?.browseSchema @@ -476,6 +481,7 @@ extension MainContentCoordinator { syncSidebarObjectSelection() return true } catch { + _ = endCursorRequest(request) navigationLogger.error("Failed to switch database: \(error.publicLogShape, privacy: .public)") /// A user who dismissed the password prompt already knows why nothing happened, and /// telling them their own decision failed is noise, not news. @@ -496,51 +502,15 @@ extension MainContentCoordinator { /// engines like BigQuery), routing by the plugin's container switch target. /// `target` names the dimension the caller opened, because an engine can switch both and the /// engine's primary target cannot tell the two presentations apart. - func switchContainer(to container: String, target: ContainerSwitchTarget? = nil) async { + func switchContainer(to container: String, target: ContainerSwitchTarget? = nil, persist: Bool = true) async { switch target ?? PluginManager.shared.containerSwitchTarget(for: connection.type) { case .schema: await switchSchema(to: container) case .database, nil: - await switchDatabase(to: container) + await switchDatabase(to: container, persist: persist) } } - /// Records which container the tab on screen belongs to, so the connections strip can come - /// back to it. - /// - /// Called on every tab change and again when the window becomes key. A window restoring its - /// tabs picks the selected one before anything is watching the selection, so without the second - /// call the first thing ever recorded would be whatever the user switched to next, and coming - /// back to that database would land on the wrong tab. - func recordSelectedTabContainer() { - guard let tab = tabManager.selectedTab else { return } - containerTabHistory.record( - tabId: tab.id, - container: WorkspaceAnchoring.containerName( - of: tab, - target: PluginManager.shared.containerSwitchTarget(for: connection.type) - ) - ) - } - - /// Land on the work a container already holds. - /// - /// The connections strip returns to the tab you last used when it moves between two - /// connections. A row for a second database of one connection is the same promise, and without - /// it the strip moved the object tree while leaving a tab from another database on screen: the - /// row said one database, the window title said another (#2217). - /// - /// A container holding no tab selects nothing. That row is the browse cursor alone, and the - /// next thing opened lands there anyway. - func selectTab(inContainer container: String) { - guard let tabId = containerTabHistory.tabToSelect( - inContainer: container, - among: tabManager.tabs, - target: PluginManager.shared.containerSwitchTarget(for: connection.type) - ) else { return } - tabManager.selectedTabId = tabId - } - /// Applies both dimensions a caller named, in the order this engine can take them. /// /// The one entry point for anything that names a database and a schema together: a link, a @@ -566,19 +536,29 @@ extension MainContentCoordinator { switchable: PluginManager.shared.switchableContainers(for: connection.type) ) + var didSwitch = false for step in steps { let session = services.databaseManager.session(for: connectionId) + /// A switch of this window's own still on its way to somewhere else would land after this + /// one and take the window there, so being on the target already is not enough. + let isSettled = pendingCursorRequest == nil switch step { case .database(let name): - guard name != session?.resolvedBrowseDatabase else { continue } + guard name != session?.resolvedBrowseDatabase || !isSettled else { continue } + didSwitch = true /// A schema belongs to a database, so a failed database switch stops the plan /// rather than applying the schema against whatever is still open. guard await switchDatabase(to: name) else { return } case .schema(let name): - guard name != session?.browseSchema else { continue } + guard name != session?.browseSchema || !isSettled else { continue } + didSwitch = true await switchSchema(to: name) } } + /// Already there is still a request to be there: the window may be showing another entry. + if !didSwitch { + showBrowsedContainer() + } } private var schemaEntityName: String { @@ -607,10 +587,15 @@ extension MainContentCoordinator { let previousSchema = toolbarState.currentSchema toolbarState.currentSchema = schema + let request = beginCursorRequest() do { try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) + if endCursorRequest(request) { + showBrowsedContainer() + } syncSidebarObjectSelection() } catch { + _ = endCursorRequest(request) /// A switch that waited for the driver is dropped when the connection was closed and /// opened again before its turn. The toolbar now belongs to that new session, so it is /// read back from it rather than restored to what the old one showed, and nothing failed diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 2e2c3406cf..67ede9f42b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -23,15 +23,18 @@ extension MainContentCoordinator { FeatureTipSignals.quickSwitcherOpened() } let browseSchema = services.databaseManager.session(for: connectionId)?.browseSchema + let browseDatabase = services.databaseManager.browseScope(for: connectionId)?.database let openTables = Set( tabManager.tabs .filter { $0.tabType == .table } .compactMap { tab -> QuickSwitcherOpenTable? in guard let tableName = tab.tableContext.tableName else { return nil } return QuickSwitcherOpenTable( + database: tab.tableContext.databaseName, schema: tab.tableContext.schemaName, name: tableName, - browsing: browseSchema + browsingDatabase: browseDatabase, + browsingSchema: browseSchema ) } ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift index efc628e8a1..13546d51fe 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift @@ -173,6 +173,17 @@ extension MainContentCoordinator { return entry.tableName == tableName && entry.lastExecutedAt == tab.execution.lastExecutedAt } + /// The numbers loaded for this tab, or nil. Read per tab, never from the latest-wins slot, so a + /// table of the same name in another database cannot show them; a retarget drops the entry. + /// A refresh keeps showing them until new ones arrive. + func currentTableMetadata(for tab: QueryTab) -> TableMetadata? { + guard let tableName = tab.tableContext.tableName, + let entry = tableMetadataCache[tab.id], + entry.tableName == tableName + else { return nil } + return entry.metadata + } + func loadTableMetadata(tableName: String, for tab: QueryTab) async { if let entry = tableMetadataCache[tab.id], entry.tableName == tableName, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift index a72881b1d4..678fa2d9ad 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift @@ -69,8 +69,22 @@ extension MainContentCoordinator { heldElsewhere: heldElsewhere ) guard !merge.restoredTabIds.isEmpty else { return } + let entry = restoredEntry( + selecting: merge.selectedTabId, + among: merge.tabs, + activeDatabase: activeDatabase, + activeSchema: activeSchema + ) tabManager.tabs = merge.tabs - tabManager.selectedTabId = merge.selectedTabId + /// The entry goes in front before the selection is written, so restoring a tab is never read + /// as the user picking one in another entry, which would switch the database on its own. + tabManager.show(container: entry.container ?? tabManager.visibleContainer) + tabManager.bindUnboundTabs() + if let selection = merge.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == selection }), + tabManager.isVisible(tab) { + tabManager.selectedTabId = selection + } for renamedId in merge.renamedTabIds { tabManager.markTabRenamed(renamedId) } @@ -97,12 +111,40 @@ extension MainContentCoordinator { await restoreConnectionContext( for: selected, loadsSelectedTab: selectedIsRestored, - activeDatabase: activeDatabase, - activeSchema: activeSchema, + activeDatabase: entry.database, + activeSchema: entry.schema, loadTiming: loadTiming ) } + /// Where a restore lands: the entry of the tab it selects, so the tab the user was on, or the one + /// a link opened before the restore read the disk, is the one on screen. The database and schema + /// the connection was last on are kept when they are that entry, and named only when no selected + /// tab names one. + private func restoredEntry( + selecting selection: UUID?, + among tabs: [QueryTab], + activeDatabase: String?, + activeSchema: String? + ) -> (container: String?, database: String?, schema: String?) { + let target = tabManager.containerTarget + let lastActive = WorkspaceAnchoring.containerName(database: activeDatabase, schema: activeSchema, target: target) + guard let selected = tabs.first(where: { $0.id == selection }), + let container = tabManager.containerName(of: selected), + container != lastActive + else { + return (lastActive, activeDatabase, activeSchema) + } + switch target { + case .database: + return (container, container, nil) + case .schema: + return (container, activeDatabase, container) + case nil: + return (lastActive, activeDatabase, activeSchema) + } + } + internal func restoreSavedTabs() async { /// The split view controller owns the window and is wired up before the content view is built, /// unlike the content view's own window, which arrives from `configureWindow`. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 32e0fc3bb5..e33a36ff2c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -99,8 +99,6 @@ extension MainContentCoordinator { let newTab = tabManager.tabs[newIndex] let newRows = tabSessionRegistry.tableRows(for: newId) - recordSelectedTabContainer() - selectionState.indices = newTab.selectedDisplayRows toolbarState.isTableTab = newTab.tabType == .table toolbarState.isResultsCollapsed = newTab.display.isResultsCollapsed @@ -147,6 +145,18 @@ extension MainContentCoordinator { // count, and a real content change still arrives through `configureForTable`. (#2424) lazyLoadCurrentTabIfNeeded() } else { + /// An entry with no tab of its own. The outgoing tab's edits were saved onto it above, + /// and the live copy goes: the change manager is the selected tab's, and with none + /// selected a Save would write those rows against the database now on screen. + selectionState.indices = [] + changeManager.configureForTable( + tableName: "", + columns: [], + primaryKeyColumns: [], + databaseType: connection.type, + generatedColumns: [], + triggerReload: false + ) toolbarState.isTableTab = false toolbarState.isResultsCollapsed = false toolbarState.isQueryTab = false diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift new file mode 100644 index 0000000000..786e15fd52 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift @@ -0,0 +1,95 @@ +// +// MainContentCoordinator+VisibleContainer.swift +// TablePro +// + +import Foundation + +/// Keeps the entry a window shows and the database its connection browses together. +/// +/// The two move from different places. A switch from the toolbar, the sidebar or the strip moves +/// the browse cursor and the window shows that entry once it lands. Selecting a tab of another entry +/// moves the window first, because the tab is already on screen, and the cursor follows. +extension MainContentCoordinator { + /// The entry the connection is browsing, named the way this engine names entries. + var browsedContainer: String? { + guard let session = services.databaseManager.session(for: connectionId) else { return nil } + return WorkspaceAnchoring.browsedContainer(of: session, target: tabManager.containerTarget) + } + + /// Whether this window follows a cursor that something else moved. A connection a tab was torn + /// off from is hosted by two windows that share one cursor, and only the one in front follows, + /// so the window behind keeps the tabs it shows. Nil from the window manager means no window + /// hosts this coordinator yet, and it is the only one there is. + var followsBrowseCursor: Bool { + guard let frontmost = WindowManager.shared.frontmostCoordinator(for: connectionId) else { return true } + return frontmost === self + } + + func beginCursorRequest() -> Int { + cursorRequestCount &+= 1 + pendingCursorRequest = cursorRequestCount + return cursorRequestCount + } + + /// Whether the move that just finished is still the latest one this window asked for. + func endCursorRequest(_ request: Int) -> Bool { + guard pendingCursorRequest == request else { return false } + pendingCursorRequest = nil + return true + } + + func showBrowsedContainer() { + tabManager.show(container: browsedContainer) + } + + /// A cursor something outside this window moved: an MCP client, a script, or the other window + /// of a torn-off tab. A move of this window's own is applied where it lands instead, and an + /// event that arrives while one is under way belongs to an earlier switch. + func browseContainerDidChange() { + guard pendingCursorRequest == nil, followsBrowseCursor else { return } + showBrowsedContainer() + } + + /// The selection took the window to another entry, so the connection browses it too. + /// + /// The saved default database stays: picking a tab is not choosing a database. A switch that + /// fails leaves the entry on screen with the error, because its tabs are still the user's work + /// and a database that cannot be browsed is no reason to hide them. + /// + /// A switch still on its way counts even when the cursor has not moved yet: picking a tab of the + /// database being left, before the switch away lands, has to win over it. + /// + /// A window that is not the connection's frontmost does not move the cursor, so a tab revealed + /// in a window that has not come forward yet is followed once that window becomes key. + func followVisibleContainer() { + guard let container = tabManager.visibleContainer, + container != browsedContainer || pendingCursorRequest != nil, + services.databaseManager.session(for: connectionId)?.isConnected == true + else { return } + guard followsBrowseCursor else { + hasDeferredCursorFollow = true + return + } + hasDeferredCursorFollow = false + Task { @MainActor [weak self] in + await self?.switchContainer(to: container, persist: false) + } + } + + func followDeferredVisibleContainer() { + guard hasDeferredCursorFollow else { return } + hasDeferredCursorFollow = false + followVisibleContainer() + } + + /// Going to one of this connection's strip entries. Its tabs are this window's own, so they come + /// forward at once, and the object browser follows once the switch lands. + func goToEntry(_ container: String) { + tabManager.show(container: container) + guard container != browsedContainer || pendingCursorRequest != nil else { return } + Task { @MainActor [weak self] in + await self?.switchContainer(to: container) + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 1156b5a8ad..8ecbf4b878 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -36,7 +36,7 @@ extension MainContentCoordinator { consumeDeferredRestoreLoadIfNeeded() refreshSourceFileDiskChanges() - recordSelectedTabContainer() + followDeferredVisibleContainer() syncSidebarObjectSelection() announceActiveTabToVoiceOver() diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 6debd687cd..2e2af76545 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -12,11 +12,21 @@ import TableProPluginKit extension MainContentView { // MARK: - Helper Methods + /// Keyed on the tab and the table's whole identity. A name alone stayed equal across + /// `tenant_a.orders` and `tenant_b.orders`, so the second never loaded its own numbers. + struct TableInfoTaskKey: Hashable { + let tabId: UUID + let table: TableTabIdentity? + } + + var tableInfoTaskKey: TableInfoTaskKey? { + currentTab.map { TableInfoTaskKey(tabId: $0.id, table: $0.tableIdentity(browsing: "")) } + } + func loadTableMetadataIfNeeded() async { guard let tab = currentTab, let tableName = tab.tableContext.tableName, - !(coordinator.tableMetadata?.tableName == tableName - && coordinator.hasCurrentTableMetadata(for: tab, tableName: tableName)) + !coordinator.hasCurrentTableMetadata(for: tab, tableName: tableName) else { return } await coordinator.loadTableMetadata(tableName: tableName, for: tab) } @@ -117,17 +127,11 @@ extension MainContentView { /// Nil on a tab that has no table of its own. /// - /// `coordinator.tableMetadata` is a single latest-wins slot, written by `loadTableMetadata` and - /// cleared only by `teardown()`. Handing it over unconditionally meant a query tab, a - /// dashboard, an ER diagram or a Users & Roles tab showed the size, row count and engine of - /// whichever table had been opened last, labelled as if they described what was on screen, and - /// closing that table's tab did not clear it. + /// Read from the entry cached for this tab, never from `coordinator.tableMetadata`, the single + /// latest-wins slot. Gated on the name, the slot showed a query tab, a dashboard, or a table of + /// the same name in another database the numbers of whichever table had loaded last. private var tableMetadataForCurrentTab: TableMetadata? { - guard let tableName = currentTab?.tableContext.tableName, - let metadata = coordinator.tableMetadata, - metadata.tableName == tableName - else { return nil } - return metadata + currentTab.flatMap { coordinator.currentTableMetadata(for: $0) } } /// The scope a structure row's type picker looks types up in: the tab's own database and diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 44ef7c3674..711435ff7f 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -109,7 +109,7 @@ extension MainContentView { agentSessionTitle: nil, connection: connection, tab: selectedTab, - hasTabs: !tabManager.tabs.isEmpty, + hasTabs: !tabManager.visibleTabs.isEmpty, queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type) ) windowTitle = resolved.title diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index 9cea0d224e..7e8a549232 100644 --- a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -46,14 +46,10 @@ extension MainContentCommandActions { return !tabsToClose(kind: .otherDatabases).isEmpty } - /// The container the connection is browsing, named the way this engine names containers. - /// Comparing a schema-switching engine's tabs against a database name classified nothing. - var browsedContainerName: String { - let target = PluginManager.shared.containerSwitchTarget(for: currentDatabaseType) - guard let session = DatabaseManager.shared.session(for: connectionId) else { - return target == .schema ? "" : browseDatabaseName - } - return WorkspaceAnchoring.browsedContainer(of: session, target: target) ?? "" + /// The entry this window shows, named the way this engine names containers. Comparing a + /// schema-switching engine's tabs against a database name classified nothing. + var shownContainerName: String { + coordinator?.tabManager.visibleContainer ?? coordinator?.browsedContainer ?? "" } var closeTabsForOtherDatabasesTitle: String { @@ -194,25 +190,26 @@ extension MainContentCommandActions { return coordinator.hasSidebarEdits || coordinator.hasPendingDestructiveTableOps } + /// Close All and Close Other Tabs act on the strip, which lists one entry: a close never takes + /// tabs the user cannot see. Close Tabs for Other Databases is the one that reaches behind the + /// other entries, and it takes every tab there, an empty one too, since each would keep its + /// entry listed. private func tabsToClose(kind: BatchCloseKind) -> [QueryTab] { guard let coordinator else { return [] } - let tabs = coordinator.tabManager.tabs - let target = PluginManager.shared.containerSwitchTarget(for: currentDatabaseType) + let manager = coordinator.tabManager switch kind { case .all: - return tabs + return manager.visibleTabs case .others(let anchor): - return tabs.filter { $0.id != anchor } + return manager.visibleTabs.filter { $0.id != anchor } case .otherDatabases: - let current = browsedContainerName - /// A tab that cannot name its container is not in another one, it is in none, and + let current = shownContainerName + /// A tab that cannot name its container is not in another one, it is in every one, and /// `containerName` returning nil compared against a non-optional name made every such - /// tab foreign. Only `.table` tabs carry a schema, so on a schema-switching engine that - /// swept up every query tab the user had typed into. `container(of:)` is the same - /// composition the workspace rail already uses to avoid exactly this. - return tabs.filter { tab in - guard let name = WorkspaceAnchoring.container(of: tab, target: target) else { return false } + /// tab foreign. + return manager.tabs.filter { tab in + guard let name = manager.containerName(of: tab) else { return false } return name != current } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 471bb25d3f..17e2a35a79 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -321,7 +321,7 @@ final class MainContentCommandActions: ObservableObject { var browseDatabaseName: String { coordinator?.browseDatabaseName ?? "" } - var openTabCount: Int { coordinator?.tabManager.tabs.count ?? 0 } + var openTabCount: Int { coordinator?.tabManager.visibleTabs.count ?? 0 } var supportsContainerSwitching: Bool { PluginManager.shared.supportsContainerSwitching(for: connection.type) @@ -731,8 +731,9 @@ final class MainContentCommandActions: ObservableObject { coordinator.tabManager.selectedTabId = id } - /// Cmd+W closes the tab in front. Pressed again with no tabs left it closes the connection, - /// and the window itself only once that was the last connection open in it. + /// Cmd+W closes the tab in front. Pressed again with no tabs left it closes the entry, the way + /// the connections strip closes it, and the connection once that was its only entry. The window + /// itself goes only once that was the last connection open in it. func closeTab() { guard let coordinator else { Task { await closeWindowAwaiting() } @@ -742,6 +743,13 @@ final class MainContentCommandActions: ObservableObject { closeTab(id: selected.id) return } + if let entry = WorkspaceCloseAction.closableEntry( + connectionId: connectionId, + container: coordinator.tabManager.visibleContainer + ) { + Task { await WorkspaceCloseAction.close(entry.workspace) } + return + } Task { await ConnectionCloseAction.close(connectionId: connectionId) } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index db53a2c08a..e1c489244d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -172,9 +172,11 @@ final class MainContentCoordinator: ObservableObject { let queryExecutor: QueryExecutor internal var executionGate: any ExecutionGate = ExecutionGateProvider.shared let windowSidebarState: WindowSidebarState - /// Which tab each of this connection's containers was last on, so the connections strip lands - /// on that container's work instead of leaving a tab from another database on screen. - internal var containerTabHistory = ContainerTabHistory() + /// The browse-cursor move this window asked for last. Switches queue on the driver, so one that + /// lands after a newer request is stale and must not put back the entry the user already left. + internal var pendingCursorRequest: Int? + internal var cursorRequestCount = 0 + internal var hasDeferredCursorFollow = false // MARK: - Services @@ -395,6 +397,7 @@ final class MainContentCoordinator: ObservableObject { internal lazy var sourceFileDiskChangeMonitor = SourceFileDiskChangeMonitor(tabManager: tabManager) private var schemaSwitchCancellable: AnyCancellable? private var clearedEditsCancellable: AnyCancellable? + private var browseContainerCancellable: AnyCancellable? @Published var fileConflictRequest: FileConflictRequest? @@ -724,6 +727,15 @@ final class MainContentCoordinator: ObservableObject { ConnectionDataCache.shared(for: connection.id).ensureLoaded() changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager } changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) } + tabManager.onVisibleContainerReconciled = { [weak self] in + self?.followVisibleContainer() + } + browseContainerCancellable = services.appEvents.browseContainerChanged + .filter { [connectionId = connection.id] changed in changed == connectionId } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.browseContainerDidChange() + } tabManager.onTabRetargeted = { [weak self] tabId in self?.dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence() self?.supersedeExecution(for: tabId) @@ -966,6 +978,7 @@ final class MainContentCoordinator: ObservableObject { externalFileModCancellable = nil sourceFileDiskChangeMonitor.cancel() schemaSwitchCancellable = nil + browseContainerCancellable = nil fileWatcher?.stopWatching(connectionId: connectionId) fileWatcher = nil cancelAllQueryTasks() diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 6877c78838..f0c91f1cd8 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -329,7 +329,7 @@ struct MainContentView: View { configureWindow(window) } } - .task(id: currentTab?.tableContext.tableName) { + .task(id: tableInfoTaskKey) { await loadTableInfo() } /// Keyed on the connection alone. The only driver that answers `fetchSessionContexts` diff --git a/TableProTests/Core/MCP/Protocol/Tools/MCPTabIdentityTests.swift b/TableProTests/Core/MCP/Protocol/Tools/MCPTabIdentityTests.swift index e56933bc5d..bf3ef535e9 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/MCPTabIdentityTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/MCPTabIdentityTests.swift @@ -34,6 +34,57 @@ struct MCPTabIdentityTests { ) } + private func tableSnapshot(_ tableName: String, database: String?, schema: String? = nil) -> MCPTabSnapshot { + MCPTabSnapshot( + tabId: UUID(), + connectionId: connectionId, + connectionName: "Primary", + tabType: "table", + tableName: tableName, + databaseName: database, + schemaName: schema, + displayTitle: tableName, + windowId: nil, + isActive: false, + window: nil + ) + } + + @Test("A same-named table in another database is not the one asked for") + func firstTableMatchesTheDatabase() { + let tenantA = tableSnapshot("orders", database: "tenant_a") + let tenantB = tableSnapshot("orders", database: "tenant_b") + let match = MCPTabSnapshotProvider.firstTable( + in: [tenantA, tenantB], + matching: TableTabIdentity(table: "orders", database: "tenant_b", schema: nil), + browseDatabase: "tenant_a" + ) + #expect(match?.tabId == tenantB.tabId) + } + + @Test("A tab bound to no database is in the one being browsed") + func firstTableResolvesAnUnboundTab() { + let unbound = tableSnapshot("orders", database: nil) + let match = MCPTabSnapshotProvider.firstTable( + in: [unbound], + matching: TableTabIdentity(table: "orders", database: "tenant_a", schema: nil), + browseDatabase: "tenant_a" + ) + #expect(match?.tabId == unbound.tabId) + } + + @Test("A schema has to match as well") + func firstTableMatchesTheSchema() { + let publicOrders = tableSnapshot("orders", database: "shop", schema: "public") + let tenantOrders = tableSnapshot("orders", database: "shop", schema: "tenant_b") + let match = MCPTabSnapshotProvider.firstTable( + in: [publicOrders, tenantOrders], + matching: TableTabIdentity(table: "orders", database: "shop", schema: "tenant_b"), + browseDatabase: "shop" + ) + #expect(match?.tabId == tenantOrders.tabId) + } + @Test("The encoded tab carries the same ids the snapshot holds") func encodedTabCarriesTheSnapshotIds() throws { let encoded = MCPTabSnapshotProvider.encode(snapshot(windowId: windowId)) diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceContainersTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceContainersTests.swift index d6d6b7cb4f..bbb5235fb6 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceContainersTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceContainersTests.swift @@ -42,6 +42,63 @@ struct ConnectionWorkspaceContainersTests { #expect(workspace.openedContainers.isEmpty) } + private func change( + _ kind: DatabaseContainerChange.Kind, + database: String, + connectionId: UUID + ) -> DatabaseContainerChange { + DatabaseContainerChange( + connectionId: connectionId, + container: DatabaseContainerRef(kind: .database, database: database, schema: nil, isSystem: false), + kind: kind + ) + } + + @Test("A dropped database leaves the strip") + func droppedDatabaseIsClosed() { + let workspace = makeWorkspace() + workspace.openContainer("tenant_a") + workspace.openContainer("tenant_b") + + workspace.adoptContainerChange( + change(.dropped, database: "tenant_a", connectionId: workspace.connectionId), + target: .database + ) + + #expect(workspace.openedContainers == ["tenant_b"]) + } + + @Test("A renamed database stays open under its new name") + func renamedDatabaseIsReopened() { + let workspace = makeWorkspace() + workspace.openContainer("tenant_a") + + workspace.adoptContainerChange( + change(.renamed(to: "tenant_x"), database: "tenant_a", connectionId: workspace.connectionId), + target: .database + ) + + #expect(workspace.openedContainers == ["tenant_x"]) + } + + /// On an engine whose entries are databases, a schema is not an entry of its own. + @Test("A dropped schema does not close a database entry") + func droppedSchemaOnDatabaseEngineIsIgnored() { + let workspace = makeWorkspace() + workspace.openContainer("public") + + workspace.adoptContainerChange( + DatabaseContainerChange( + connectionId: workspace.connectionId, + container: DatabaseContainerRef(kind: .schema, database: "app", schema: "public", isSystem: false), + kind: .dropped + ), + target: .database + ) + + #expect(workspace.openedContainers == ["public"]) + } + @Test("Closing a container leaves the others open") func closingLeavesTheRest() { let workspace = makeWorkspace() diff --git a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift index d50cb78f30..06797e213b 100644 --- a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift +++ b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift @@ -41,6 +41,7 @@ struct WorkspaceRailStoreTests { tabs: [UUID: [QueryTab]] = [:], opened: [UUID: Set] = [:], closing: [UUID: String] = [:], + visible: [UUID: Set] = [:], openedAt: [UUID: Date] = [:], storedOrder: [WorkspaceID] = [] ) -> [WorkspaceRailEntry] { @@ -53,6 +54,7 @@ struct WorkspaceRailStoreTests { tabs: { tabs[$0] ?? [] }, openedContainers: { opened[$0] ?? [] }, closingContainers: { closing[$0] }, + visibleContainers: { visible[$0] ?? [] }, openedAt: openedAt, storedOrder: storedOrder ) @@ -134,16 +136,40 @@ struct WorkspaceRailStoreTests { #expect(Set(entries.map(\.container)) == ["app", "logs"]) } - /// A restored window has tabs before anything has browsed anywhere, so a container nobody has - /// opened is listed only when a tab there carries work. An empty scratch tab does not. - @Test("An empty query tab in a container nobody opened earns no row") - func scratchTabDoesNotHoldItsContainer() { + /// Each entry owns its tabs, so a tab is reachable only through its entry: an empty scratch tab + /// in a container nobody opened still lists it, or the tab would sit behind no entry at all. + @Test("An empty query tab in a container nobody opened still earns a row") + func scratchTabHoldsItsContainer() { let connection = TestFixtures.makeConnection(database: "app") let entries = resolve( openConnectionIds: [connection.id], sessions: [connection.id: makeSession(connection, browseDatabase: "logs")], tabs: [connection.id: [scratchTab(database: "app")]] ) + #expect(Set(entries.map(\.container)) == ["app", "logs"]) + } + + @Test("The entry a window shows is listed even when nothing else holds it") + func shownEntryIsListed() { + let connection = TestFixtures.makeConnection(database: "app") + let entries = resolve( + openConnectionIds: [connection.id], + sessions: [connection.id: makeSession(connection, browseDatabase: "logs")], + visible: [connection.id: ["audit"]] + ) + #expect(Set(entries.map(\.container)) == ["audit", "logs"]) + } + + @Test("An entry being closed is not listed because a window still shows it") + func closingEntryIsNotListedForAWindowShowingIt() { + let connection = TestFixtures.makeConnection(database: "app") + let entries = resolve( + openConnectionIds: [connection.id], + sessions: [connection.id: makeSession(connection, browseDatabase: "logs")], + opened: [connection.id: ["logs"]], + closing: [connection.id: "audit"], + visible: [connection.id: ["audit"]] + ) #expect(entries.map(\.container) == ["logs"]) } diff --git a/TableProTests/Helpers/SwitchingDriverStub.swift b/TableProTests/Helpers/SwitchingDriverStub.swift new file mode 100644 index 0000000000..7512b646bd --- /dev/null +++ b/TableProTests/Helpers/SwitchingDriverStub.swift @@ -0,0 +1,12 @@ +import Foundation +import TableProPluginKit + +/// A driver whose database switch lands, so a case sees the session browse the database it asked +/// for. `MockDatabaseDriver` is not a plugin adapter, and its switch never moves the session. +final class SwitchingDriverStub: PluginDriverStub, @unchecked Sendable { + private(set) var currentDatabase: String? + + func switchDatabase(to database: String) async throws { + currentDatabase = database + } +} diff --git a/TableProTests/Models/DatabaseScopeTests.swift b/TableProTests/Models/DatabaseScopeTests.swift index b63f4a7d51..4e57cfa01d 100644 --- a/TableProTests/Models/DatabaseScopeTests.swift +++ b/TableProTests/Models/DatabaseScopeTests.swift @@ -188,6 +188,34 @@ struct DatabaseManagerScopeResolutionTests { #expect(manager.resolvedSchemaName("", inDatabase: "analytics", for: connection.id) == nil) } + @Test("A table asked for by name alone is the one in the browsed database") + func requestedTableResolvesAgainstTheBrowseCursor() { + let connection = Self.makeSession(browseDatabase: "tenant_b", browseSchema: "public") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let manager = DatabaseManager.shared + + #expect( + manager.requestedTableIdentity(table: "orders", database: nil, schema: nil, for: connection.id) + == TableTabIdentity(table: "orders", database: "tenant_b", schema: "public") + ) + #expect( + manager.requestedTableIdentity(table: "orders", database: "tenant_a", schema: nil, for: connection.id) + == TableTabIdentity(table: "orders", database: "tenant_a", schema: nil) + ) + } + + @Test("A table tab bound to no database is in the browsed one") + func unboundTableTabResolvesAgainstTheBrowseCursor() { + let connection = Self.makeSession(browseDatabase: "tenant_b") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let tab = QueryTab(title: "orders", query: "", tabType: .table, tableName: "orders") + + #expect( + DatabaseManager.shared.tableIdentity(of: tab, for: connection.id) + == TableTabIdentity(table: "orders", database: "tenant_b", schema: nil) + ) + } + @Test("A resolved scope takes the schema the schema resolver gives") func resolvedScopeAgreesWithSchemaResolver() { let connection = Self.makeSession(browseDatabase: "app", browseSchema: "sales") diff --git a/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift b/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift index 4b77dbd950..0bd42b454b 100644 --- a/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerRecencyTests.swift @@ -159,23 +159,4 @@ struct QueryTabManagerRecencyTests { #expect(sequence(of: firstTab, in: one) > (two.activationSequence.values.max() ?? 0)) } - - /// The rail switches a connection's database before it selects the tab that database holds, and - /// the tab shown while the switch runs is only a waypoint. - @Test("A held record skips the tab passed through and records the one landed on") - func deferredRecordSkipsTheWaypoint() async throws { - let manager = await makeManager(tabCount: 3) - let waypoint = manager.tabs[0] - let landing = manager.tabs[1] - manager.defersActivationRecord = true - - manager.selectedTabId = waypoint.id - await settle() - manager.selectedTabId = landing.id - manager.defersActivationRecord = false - await settle() - - #expect(manager.activationSequence[waypoint.id] == nil) - #expect(sequence(of: landing, in: manager) > 0) - } } diff --git a/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift b/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift new file mode 100644 index 0000000000..2038106548 --- /dev/null +++ b/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift @@ -0,0 +1,305 @@ +// +// QueryTabManagerVisibleContainerTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct QueryTabManagerVisibleContainerTests { + private func makeManager( + target: ContainerSwitchTarget? = .database, + visible: String? = "tenant_a" + ) -> QueryTabManager { + QueryTabManager(containerTarget: target, visibleContainer: visible) + } + + private func queryTab(database: String, schema: String? = nil, title: String = "Query") -> QueryTab { + var tab = QueryTab(title: title, query: "SELECT 1") + tab.tableContext.databaseName = database + tab.tableContext.schemaName = schema + return tab + } + + private func dashboardTab() -> QueryTab { + QueryTab(title: "Server Dashboard", tabType: .serverDashboard) + } + + // MARK: - Projection + + @Test("The strip lists the visible entry's tabs and the ones that belong to every entry") + func visibleTabsAreTheEntrysAndTheConnectionWideOnes() { + let manager = makeManager() + let tenantA = queryTab(database: "tenant_a") + let tenantB = queryTab(database: "tenant_b") + let dashboard = dashboardTab() + manager.tabs = [tenantA, tenantB, dashboard] + + #expect(manager.visibleTabs.map(\.id) == [tenantA.id, dashboard.id]) + } + + @Test("An engine that switches no container shows every tab and has nothing to show") + func noContainerTargetShowsEverything() { + let manager = makeManager(target: nil, visible: "tenant_a") + let tenantA = queryTab(database: "tenant_a") + let tenantB = queryTab(database: "tenant_b") + manager.tabs = [tenantA, tenantB] + + manager.show(container: "tenant_b") + + #expect(manager.visibleContainer == nil) + #expect(manager.visibleTabs.count == 2) + } + + // MARK: - Binding + + @Test("A query tab opened without a database is bound to the visible entry's") + func unboundQueryTabTakesTheVisibleDatabase() { + let manager = makeManager() + + manager.addTab(initialQuery: "SELECT 1") + + #expect(manager.tabs.first?.tableContext.databaseName == "tenant_a") + } + + /// Left unbound it ran against whatever schema was browsed next, and picking a tab of another + /// schema moves that: a statement typed against HR would have run against SCOTT. + @Test("A query tab on a schema engine is bound to the visible schema") + func schemaEngineQueryTabIsBound() { + let manager = makeManager(target: .schema, visible: "HR") + + manager.addTab(initialQuery: "SELECT 1 FROM dual", databaseName: "ORCL") + manager.show(container: "SCOTT") + + #expect(manager.tabs.first?.tableContext.schemaName == "HR") + #expect(manager.visibleTabs.isEmpty) + } + + @Test("Server-wide tabs stay unbound and show in every entry") + func serverTabsAreNotBound() { + let manager = makeManager() + + manager.addServerDashboardTab() + manager.show(container: "tenant_b") + + #expect(manager.tabs.first?.tableContext.databaseName == "") + #expect(manager.visibleTabs.count == 1) + } + + /// Restored from before tabs were bound: they ran against the container the connection was on, + /// which is the one the restore lands in. + @Test("A restore binds tabs saved without a container to the entry it lands in") + func restoreBindsUnboundTabs() { + let manager = makeManager(visible: "tenant_b") + var legacy = QueryTab(title: "Query 1", query: "SELECT 1") + legacy.tableContext.databaseName = "" + manager.tabs = [legacy, dashboardTab()] + + manager.bindUnboundTabs() + + #expect(manager.tabs.map(\.tableContext.databaseName) == ["tenant_b", ""]) + } + + // MARK: - Selection carries the entry + + @Test("Selecting a tab of another entry puts that entry in front and says so once") + func selectingAnotherEntrysTabMovesTheEntry() { + let manager = makeManager() + let tenantA = queryTab(database: "tenant_a") + let tenantB = queryTab(database: "tenant_b") + manager.tabs = [tenantA, tenantB] + manager.selectedTabId = tenantA.id + var reports = 0 + manager.onVisibleContainerReconciled = { reports += 1 } + + manager.selectedTabId = tenantB.id + + #expect(manager.visibleContainer == "tenant_b") + #expect(manager.visibleTabs.map(\.id) == [tenantB.id]) + #expect(reports == 1) + } + + /// The per-tab database picker and a rename move a tab without anyone writing the selection. + @Test("Moving the selected tab to another database takes the window with it") + func rebindingTheSelectedTabMovesTheEntry() { + let manager = makeManager() + let tab = queryTab(database: "tenant_a") + manager.tabs = [tab] + manager.selectedTabId = tab.id + var reports = 0 + manager.onVisibleContainerReconciled = { reports += 1 } + + manager.mutate(tabId: tab.id) { $0.tableContext.databaseName = "tenant_b" } + + #expect(manager.visibleContainer == "tenant_b") + #expect(manager.selectedTab?.id == tab.id) + #expect(reports == 1) + } + + @Test("Selecting a tab that belongs to every entry leaves the entry where it is") + func selectingAConnectionWideTabKeepsTheEntry() { + let manager = makeManager() + let dashboard = dashboardTab() + manager.tabs = [queryTab(database: "tenant_a"), dashboard] + + manager.selectedTabId = dashboard.id + + #expect(manager.visibleContainer == "tenant_a") + } + + // MARK: - Showing an entry + + @Test("Showing an entry lands on the tab last used there") + func showingAnEntryLandsOnItsLastTab() { + let manager = makeManager() + let first = queryTab(database: "tenant_a") + let second = queryTab(database: "tenant_a") + let other = queryTab(database: "tenant_b") + manager.tabs = [first, second, other] + manager.selectedTabId = first.id + manager.selectedTabId = other.id + + manager.show(container: "tenant_a") + + #expect(manager.visibleContainer == "tenant_a") + #expect(manager.selectedTabId == first.id) + } + + @Test("Showing an entry with no tab of its own shows its empty state") + func showingAnEmptyEntrySelectsNothing() { + let manager = makeManager() + let tab = queryTab(database: "tenant_a") + manager.tabs = [tab] + manager.selectedTabId = tab.id + + manager.show(container: "tenant_c") + + #expect(manager.selectedTabId == nil) + #expect(manager.selectedTab == nil) + #expect(manager.tabs.count == 1) + } + + @Test("Showing the entry already in front keeps the selection") + func showingTheSameEntryKeepsTheSelection() { + let manager = makeManager() + let first = queryTab(database: "tenant_a") + let second = queryTab(database: "tenant_a") + manager.tabs = [first, second] + manager.selectedTabId = first.id + + manager.show(container: "tenant_a") + + #expect(manager.selectedTabId == first.id) + } + + @Test("Showing an entry never reports a move for the connection to follow") + func showingDoesNotReport() { + let manager = makeManager() + manager.tabs = [queryTab(database: "tenant_a"), queryTab(database: "tenant_b")] + var reports = 0 + manager.onVisibleContainerReconciled = { reports += 1 } + + manager.show(container: "tenant_b") + + #expect(reports == 0) + } + + @Test("A new window's tab puts its entry in front without reporting a move") + func seedingDoesNotReport() { + let manager = makeManager() + var reports = 0 + manager.onVisibleContainerReconciled = { reports += 1 } + let tab = queryTab(database: "tenant_b") + + manager.seed(with: tab) + + #expect(manager.visibleContainer == "tenant_b") + #expect(manager.selectedTabId == tab.id) + #expect(reports == 0) + } + + // MARK: - Strip positions + + @Test("Command-number and next-tab count the strip's tabs only") + func numberedSelectionCountsVisibleTabs() { + let manager = makeManager() + let a1 = queryTab(database: "tenant_a") + let b1 = queryTab(database: "tenant_b") + let a2 = queryTab(database: "tenant_a") + manager.tabs = [a1, b1, a2] + manager.selectedTabId = a1.id + + manager.selectTab(at: 1) + #expect(manager.selectedTabId == a2.id) + + manager.selectTab(offsetBy: 1) + #expect(manager.selectedTabId == a1.id) + #expect(manager.visibleContainer == "tenant_a") + } + + /// The strip reports a drop as a position among the tabs it draws, and the other entries' tabs + /// sitting between them in the connection's list must not shift where it lands. + @Test("A drag lands beside the strip neighbour it was dropped next to") + func reorderUsesStripPositions() { + let manager = makeManager() + let a1 = queryTab(database: "tenant_a", title: "a1") + let b1 = queryTab(database: "tenant_b", title: "b1") + let a2 = queryTab(database: "tenant_a", title: "a2") + let a3 = queryTab(database: "tenant_a", title: "a3") + manager.tabs = [a1, b1, a2, a3] + manager.selectedTabId = a1.id + + manager.moveTab(id: a1.id, to: 2) + #expect(manager.visibleTabs.map(\.title) == ["a2", "a3", "a1"]) + + manager.moveTab(id: a1.id, to: 0) + #expect(manager.visibleTabs.map(\.title) == ["a1", "a2", "a3"]) + #expect(manager.tabs.count == 4) + } + + @Test("A tab at the strip's edge cannot move past it, whatever sits behind it") + func moveByStopsAtTheStripEdge() { + let manager = makeManager() + let b1 = queryTab(database: "tenant_b") + let a1 = queryTab(database: "tenant_a") + manager.tabs = [b1, a1] + manager.selectedTabId = a1.id + + #expect(!manager.canMoveTab(id: a1.id, by: -1)) + #expect(!manager.canMoveTab(id: a1.id, by: 1)) + } + + // MARK: - Closing + + @Test("Closing a tab lands on its neighbour in the strip, never on another entry's tab") + func closingLandsOnAStripNeighbour() { + let manager = makeManager() + let a1 = queryTab(database: "tenant_a") + let b1 = queryTab(database: "tenant_b") + let a2 = queryTab(database: "tenant_a") + manager.tabs = [a1, b1, a2] + manager.selectedTabId = a1.id + + manager.closeTab(id: a1.id) + + #expect(manager.selectedTabId == a2.id) + #expect(manager.visibleContainer == "tenant_a") + } + + @Test("Closing an entry's last tab leaves it empty and the other entries' tabs alone") + func closingTheLastTabOfAnEntry() { + let manager = makeManager() + let a1 = queryTab(database: "tenant_a") + let b1 = queryTab(database: "tenant_b") + manager.tabs = [a1, b1] + manager.selectedTabId = a1.id + + manager.closeTab(id: a1.id) + + #expect(manager.selectedTabId == nil) + #expect(manager.visibleContainer == "tenant_a") + #expect(manager.tabs.map(\.id) == [b1.id]) + } +} diff --git a/TableProTests/Models/QueryContainerRebindingTests.swift b/TableProTests/Models/QueryContainerRebindingTests.swift new file mode 100644 index 0000000000..8d6c0d6669 --- /dev/null +++ b/TableProTests/Models/QueryContainerRebindingTests.swift @@ -0,0 +1,36 @@ +// +// QueryContainerRebindingTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +struct QueryContainerRebindingTests { + private func context(database: String, schema: String?) -> TabTableContext { + var context = TabTableContext() + context.databaseName = database + context.schemaName = schema + return context + } + + @Test("Another database drops the schema that belonged to the old one") + func databaseRebindDropsTheSchema() { + var bound = context(database: "db1", schema: "sales") + + QueryContainerRebinding.rebind(&bound, to: "db2", target: .database) + + #expect(bound.databaseName == "db2") + #expect(bound.schemaName == nil) + } + + @Test("Another schema keeps the database") + func schemaRebindKeepsTheDatabase() { + var bound = context(database: "ORCL", schema: "HR") + + QueryContainerRebinding.rebind(&bound, to: "SCOTT", target: .schema) + + #expect(bound.databaseName == "ORCL") + #expect(bound.schemaName == "SCOTT") + } +} diff --git a/TableProTests/Models/QuickSwitcherOpenTableTests.swift b/TableProTests/Models/QuickSwitcherOpenTableTests.swift index c07d5f64f9..92b6e77526 100644 --- a/TableProTests/Models/QuickSwitcherOpenTableTests.swift +++ b/TableProTests/Models/QuickSwitcherOpenTableTests.swift @@ -10,7 +10,43 @@ import Testing /// assertions are always about a pair: what a tab produces against what a table produces. struct QuickSwitcherOpenTableTests { private func tab(_ schema: String?, _ name: String, browsing: String?) -> QuickSwitcherOpenTable { - QuickSwitcherOpenTable(schema: schema, name: name, browsing: browsing) + QuickSwitcherOpenTable( + database: nil, + schema: schema, + name: name, + browsingDatabase: nil, + browsingSchema: browsing + ) + } + + private func tab( + database: String?, + _ name: String, + browsingDatabase: String? + ) -> QuickSwitcherOpenTable { + QuickSwitcherOpenTable( + database: database, + schema: nil, + name: name, + browsingDatabase: browsingDatabase, + browsingSchema: nil + ) + } + + @Test("Two databases holding the same table name do not match") + func differentDatabasesDoNotMatch() { + #expect( + tab(database: "tenant_a", "orders", browsingDatabase: "tenant_b") + != tab(database: "tenant_b", "orders", browsingDatabase: "tenant_b") + ) + } + + @Test("A tab that names no database resolves against the browsed one") + func unboundDatabaseResolvesAgainstBrowseDatabase() { + #expect( + tab(database: "", "orders", browsingDatabase: "tenant_b") + == tab(database: "tenant_b", "orders", browsingDatabase: "tenant_b") + ) } @Test("Two schemas holding the same table name do not match") diff --git a/TableProTests/Models/WorkspaceAnchoringTests.swift b/TableProTests/Models/WorkspaceAnchoringTests.swift index 34d79b34af..15eccec2f4 100644 --- a/TableProTests/Models/WorkspaceAnchoringTests.swift +++ b/TableProTests/Models/WorkspaceAnchoringTests.swift @@ -22,38 +22,9 @@ struct WorkspaceAnchoringTests { return tab } - @Test("A brand new query tab is not work, so it holds nothing") - func emptyQueryTabHoldsNothing() { - #expect(!WorkspaceAnchoring.hasWork(queryTab())) - } - - @Test("A query tab with text holds its database") - func typedQueryTabHoldsItsContainer() { - #expect(WorkspaceAnchoring.hasWork(queryTab("SELECT 1"))) - } - - @Test("A query tab the user has edited rows in holds its database even with no text") - func interactedQueryTabHoldsItsContainer() { - var tab = queryTab() - tab.hasUserInteraction = true - #expect(WorkspaceAnchoring.hasWork(tab)) - } - - @Test("A file-backed query tab holds its database even before anything is typed") - func fileBackedQueryTabHoldsItsContainer() { - var tab = queryTab() - tab.content.sourceFileURL = URL(fileURLWithPath: "/tmp/report.sql") - #expect(WorkspaceAnchoring.hasWork(tab)) - } - - @Test("Every tab that is not a query tab holds its database") - func nonQueryTabsAlwaysHold() { - #expect(WorkspaceAnchoring.hasWork(tableTab())) - } - @Test("A database engine keys the container on the database") func databaseEngineUsesDatabase() { - let container = WorkspaceAnchoring.container( + let container = WorkspaceAnchoring.containerName( of: tableTab(database: "app", schema: "public"), target: .database ) @@ -62,7 +33,7 @@ struct WorkspaceAnchoringTests { @Test("A schema engine keys the container on the schema") func schemaEngineUsesSchema() { - let container = WorkspaceAnchoring.container( + let container = WorkspaceAnchoring.containerName( of: tableTab(database: "app", schema: "reporting"), target: .schema ) @@ -71,24 +42,25 @@ struct WorkspaceAnchoringTests { @Test("A schema engine names no container for a tab with no schema") func schemaEngineWithoutSchemaNamesNothing() { - #expect(WorkspaceAnchoring.container(of: tableTab(database: "app"), target: .schema) == nil) + #expect(WorkspaceAnchoring.containerName(of: tableTab(database: "app"), target: .schema) == nil) } @Test("An engine that switches nothing names no container, so it gets a single workspace") func noSwitchTargetNamesNothing() { - #expect(WorkspaceAnchoring.container(of: tableTab(database: "app"), target: nil) == nil) + #expect(WorkspaceAnchoring.containerName(of: tableTab(database: "app"), target: nil) == nil) #expect(WorkspaceAnchoring.containers(in: [tableTab(database: "app")], target: nil).isEmpty) } - @Test("Containers collapse duplicates and skip tabs that hold nothing") - func containersDeduplicateAndSkipScratchTabs() { + /// A tab is reachable only through its entry, so an empty scratch tab names its container too. + @Test("Containers collapse duplicates and include every tab that names one") + func containersDeduplicateAndIncludeScratchTabs() { let tabs = [ tableTab(database: "app"), tableTab(database: "app"), tableTab(database: "logs"), queryTab(database: "archive"), ] - #expect(WorkspaceAnchoring.containers(in: tabs, target: .database) == ["app", "logs"]) + #expect(WorkspaceAnchoring.containers(in: tabs, target: .database) == ["app", "logs", "archive"]) } @Test("The browsed container falls back to the connection's database when none is set") @@ -105,33 +77,41 @@ struct WorkspaceAnchoringTests { #expect(WorkspaceAnchoring.defaultContainer(of: connection, target: .database) == "app") } - @Test("On a schema engine, a query tab with no schema holds nothing rather than a container it cannot name") - func schemaEngineQueryTabWithoutSchemaHoldsNothing() { - var tab = queryTab("SELECT 1", database: "warehouse") - tab.hasUserInteraction = true - #expect(WorkspaceAnchoring.hasWork(tab)) - #expect(!WorkspaceAnchoring.holdsContainer(tab, target: .schema)) - #expect(WorkspaceAnchoring.holdsContainer(tab, target: .database)) + /// On an engine whose query tabs run against whatever schema is browsed, such a tab belongs to + /// every entry rather than to one it cannot name. + @Test("On a schema engine, a query tab with no schema names no container") + func schemaEngineQueryTabWithoutSchemaNamesNothing() { + let tab = queryTab("SELECT 1", database: "warehouse") + #expect(WorkspaceAnchoring.containerName(of: tab, target: .schema) == nil) + #expect(WorkspaceAnchoring.containerName(of: tab, target: .database) == "warehouse") } - @Test("On a schema engine, a tab that names its schema still holds it") - func schemaEngineTabWithSchemaHolds() { + @Test("On a schema engine, a tab that names its schema is in that entry") + func schemaEngineTabWithSchemaNamesIt() { let tab = queryTab("SELECT 1", database: "warehouse", schema: "reporting") - #expect(WorkspaceAnchoring.holdsContainer(tab, target: .schema)) - #expect(WorkspaceAnchoring.container(of: tab, target: .schema) == "reporting") + #expect(WorkspaceAnchoring.containerName(of: tab, target: .schema) == "reporting") } - @Test("Typing into a query tab changes the anchors, so the rail is told to reload") - func typingChangesAnchors() { - let before = WorkspaceAnchoring.anchors(in: [queryTab()]) - let after = WorkspaceAnchoring.anchors(in: [queryTab("SELECT 1")]) + @Test("A tab moving to another database changes the anchors, so the rail is told to reload") + func rebindingChangesAnchors() { + let before = WorkspaceAnchoring.anchors(in: [queryTab(database: "app")]) + let after = WorkspaceAnchoring.anchors(in: [queryTab(database: "logs")]) #expect(before != after) } - @Test("Editing a query that already had text leaves the anchors alone") - func editingTypedQueryKeepsAnchors() { - let before = WorkspaceAnchoring.anchors(in: [queryTab("SELECT 1")]) - let after = WorkspaceAnchoring.anchors(in: [queryTab("SELECT 2")]) + @Test("Typing into a query tab leaves the anchors alone") + func typingKeepsAnchors() { + let before = WorkspaceAnchoring.anchors(in: [queryTab()]) + let after = WorkspaceAnchoring.anchors(in: [queryTab("SELECT 1")]) #expect(before == after) } + + @Test("A dropped database is listed under its own name only on an engine whose entries are databases") + func entryNameFollowsTheEngineTarget() { + let database = DatabaseContainerRef(kind: .database, database: "tenant_a", schema: nil, isSystem: false) + let schema = DatabaseContainerRef(kind: .schema, database: "app", schema: "tenant_b", isSystem: false) + #expect(WorkspaceAnchoring.entryName(of: database, target: .database) == "tenant_a") + #expect(WorkspaceAnchoring.entryName(of: schema, target: .database) == nil) + #expect(WorkspaceAnchoring.entryName(of: schema, target: .schema) == "tenant_b") + } } diff --git a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift index b980802ee1..2bff79dbe8 100644 --- a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift @@ -16,6 +16,21 @@ struct QuickSwitcherCrossSchemaTests { TableInfo(name: name, type: type, rowCount: nil, schema: schema) } + private func openTable( + _ database: String, + _ schema: String?, + _ name: String, + browsing browseSchema: String? + ) -> QuickSwitcherOpenTable { + QuickSwitcherOpenTable( + database: database, + schema: schema, + name: name, + browsingDatabase: "shop", + browsingSchema: browseSchema + ) + } + private func items( _ tables: [TableInfo], browsing browseSchema: String? = "public", @@ -79,7 +94,7 @@ struct QuickSwitcherCrossSchemaTests { @Test("An open table is badged, one without a tab is not") func openState() { - let open: Set = [QuickSwitcherOpenTable(schema: "public", name: "users", browsing: "public")] + let open: Set = [openTable("shop", "public", "users", browsing: "public")] let rows = items([table("users", "public"), table("orders", "public")], openTables: open) #expect(rows.first { $0.name == "users" }?.isOpenInTab == true) @@ -89,12 +104,20 @@ struct QuickSwitcherCrossSchemaTests { /// The defect #2191 fixed: a tab on `public.users` badged `analytics.users` as open. @Test("A table of the same name in another schema is not badged") func openStateRespectsSchema() { - let open: Set = [QuickSwitcherOpenTable(schema: "public", name: "users", browsing: "analytics")] + let open: Set = [openTable("shop", "public", "users", browsing: "analytics")] let rows = items([table("users", "analytics")], browsing: "analytics", openTables: open) #expect(rows.first?.isOpenInTab == false) } + @Test("A table of the same name in another database is not badged") + func openStateRespectsDatabase() { + let open: Set = [openTable("tenant_a", nil, "orders", browsing: nil)] + let rows = items([table("orders", nil)], browsing: nil, openTables: open) + + #expect(rows.first?.isOpenInTab == false) + } + // MARK: - Merging @Test("The schema service answers for its own schemas and the listing fills in the rest") diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift index 3a2be6832b..6fccec8f35 100644 --- a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -143,11 +143,15 @@ struct CommandActionsBulkCloseTests { try current.coordinator.tabManager.addTableTab( tableName: "EMPLOYEES", databaseType: .oracle, databaseName: "ORCL", schemaName: "HR" ) + try current.coordinator.tabManager.addTableTab( + tableName: "DEPT", databaseType: .oracle, databaseName: "ORCL", schemaName: "SCOTT" + ) + #expect(current.coordinator.tabManager.visibleContainer == "SCOTT") #expect(current.actions.canCloseTabsForOtherDatabases) } - /// The same rule on a database-switching engine: a tab that never got a database is in no + /// A tab opened without a database is bound to the entry it was opened in, so it is in this /// database rather than in another one. @Test("a tab with no database of its own is not foreign") func tabWithoutADatabaseIsNotForeign() { @@ -160,18 +164,37 @@ struct CommandActionsBulkCloseTests { #expect(!current.actions.canCloseTabsForOtherDatabases) } - /// A tab holds a container only when it has work as well as a name, which is the rule the - /// workspace rail applies. So an untouched scratch tab on another database is left alone even - /// though it can name where it is. - @Test("an empty scratch tab on another database is left alone") - func emptyScratchTabOnAnotherDatabaseSurvives() { + /// Every tab names its entry and keeps it listed, so an untouched scratch tab behind another + /// entry is one of the tabs the command closes. Leaving it kept the other entry in the strip. + @Test("an empty scratch tab on another database is closed too") + func emptyScratchTabOnAnotherDatabaseIsForeign() { let connection = TestFixtures.makeConnection(database: "db_a") let current = makeWindow(connection: connection) defer { current.coordinator.teardown() } current.coordinator.tabManager.addTab(databaseName: "db_b") + current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") - #expect(!current.actions.canCloseTabsForOtherDatabases) + #expect(current.actions.canCloseTabsForOtherDatabases) + } + + /// The strip lists one entry, and a close never takes tabs the user cannot see. + @Test("close all and close others act on the entry on screen") + func closeAllAndOthersStayInTheShownEntry() async { + let connection = TestFixtures.makeConnection(database: "db_a") + let current = makeWindow(connection: connection) + defer { current.coordinator.teardown() } + let manager = current.coordinator.tabManager + + manager.addTab(initialQuery: "SELECT 'b'", databaseName: "db_b") + manager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") + manager.addTab(initialQuery: "SELECT 2", databaseName: "db_a") + #expect(manager.visibleTabs.count == 2) + + current.actions.closeAllTabs() + _ = try? await waitUntil { manager.tabs.count == 1 } + + #expect(manager.tabs.map(\.tableContext.databaseName) == ["db_b"]) } /// The enablement flag and the close itself read the same victim list, so this drives the real @@ -186,13 +209,15 @@ struct CommandActionsBulkCloseTests { try current.coordinator.tabManager.addTableTab( tableName: "EMPLOYEES", databaseType: .oracle, databaseName: "ORCL", schemaName: "HR" ) - #expect(current.coordinator.tabManager.tabs.count == 2) + try current.coordinator.tabManager.addTableTab( + tableName: "DEPT", databaseType: .oracle, databaseName: "ORCL", schemaName: "SCOTT" + ) + #expect(current.coordinator.tabManager.tabs.count == 3) current.actions.closeTabsForOtherDatabases() - _ = try? await waitUntil { current.coordinator.tabManager.tabs.count <= 1 } + _ = try? await waitUntil { current.coordinator.tabManager.tabs.count <= 2 } - #expect(current.coordinator.tabManager.tabs.count == 1) - #expect(current.coordinator.tabManager.tabs.first?.tabType == .query) + #expect(current.coordinator.tabManager.tabs.map(\.tableContext.schemaName) == [nil, "SCOTT"]) } // MARK: - Container wording diff --git a/TableProTests/Views/Main/MainContentCoordinatorTableMetadataTests.swift b/TableProTests/Views/Main/MainContentCoordinatorTableMetadataTests.swift index f9129f0c62..11cb525d7f 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorTableMetadataTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorTableMetadataTests.swift @@ -76,6 +76,47 @@ struct MainContentCoordinatorTableMetadataTests { #expect(coordinator.hasCurrentTableMetadata(for: customers, tableName: "customers")) } + @Test("A table of the same name in another database never shows the other one's numbers") + func sameNameInAnotherDatabaseIsNotShown() { + let (coordinator, tabManager) = makeCoordinator() + var tenantA = addTableTab(to: tabManager, tableName: "orders") + tenantA.tableContext.databaseName = "tenant_a" + tabManager.updateTab(tenantA) + var tenantB = addTableTab(to: tabManager, tableName: "orders") + tenantB.tableContext.databaseName = "tenant_b" + tabManager.updateTab(tenantB) + + let tenantAMetadata = metadata("orders", rowCount: 10) + coordinator.tableMetadataCache[tenantA.id] = TableMetadataCacheEntry( + tableName: "orders", + lastExecutedAt: tenantA.execution.lastExecutedAt, + metadata: tenantAMetadata + ) + coordinator.tableMetadata = tenantAMetadata + + #expect(coordinator.currentTableMetadata(for: tenantA) == tenantAMetadata) + #expect(coordinator.currentTableMetadata(for: tenantB) == nil) + } + + /// A refresh never clears what it refreshes: the panel keeps the last numbers until new ones + /// arrive, while the cache still reports them stale so the next load refetches. + @Test("Re-running the tab's query keeps its numbers on screen") + func reExecutingKeepsTheShownNumbers() { + let (coordinator, tabManager) = makeCoordinator() + var orders = addTableTab(to: tabManager, tableName: "orders") + let shown = metadata("orders", rowCount: 10) + coordinator.tableMetadataCache[orders.id] = TableMetadataCacheEntry( + tableName: "orders", + lastExecutedAt: orders.execution.lastExecutedAt, + metadata: shown + ) + + orders.execution.lastExecutedAt = Date(timeIntervalSince1970: 1) + + #expect(coordinator.currentTableMetadata(for: orders) == shown) + #expect(!coordinator.hasCurrentTableMetadata(for: orders, tableName: "orders")) + } + @Test("Re-running the tab's query makes its cached numbers stale") func reExecutingInvalidatesTheEntry() { let (coordinator, tabManager) = makeCoordinator() diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index f142ffa895..893497c7f1 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -21,14 +21,13 @@ struct SwitchDatabaseTests { _ body: (MainContentCoordinator, QueryTabManager) async throws -> Void ) async throws { let connection = TestFixtures.makeConnection(database: "db_a", type: .mysql) - let driver = MockDatabaseDriver(connection: connection) - DatabaseManager.shared.injectSession( - ConnectionSession(connection: connection, driver: driver), - for: connection.id - ) + let driver = PluginDriverAdapter(connection: connection, pluginDriver: SwitchingDriverStub()) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } - let tabManager = QueryTabManager() + let tabManager = QueryTabManager(containerTarget: .database, visibleContainer: "db_a") let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, @@ -65,24 +64,47 @@ struct SwitchDatabaseTests { // MARK: - Tab state after database switch + /// A switch keeps every tab: the database left behind keeps its tabs in its own entry, and + /// the window shows the one it switched to, which has none yet. @Test("switchDatabase keeps table and query tabs and their contents") func switchDatabaseKeepsExistingTabs() async throws { try await withConnectedCoordinator { coordinator, tabManager in try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") tabManager.addTab(initialQuery: "SELECT NOW()", databaseName: "db_a") let idsBefore = tabManager.tabs.map(\.id) - let selectedBefore = tabManager.selectedTabId let switched = await coordinator.switchDatabase(to: "db_b", persist: false) #expect(switched) #expect(tabManager.tabs.map(\.id) == idsBefore) - #expect(tabManager.selectedTabId == selectedBefore) + #expect(tabManager.visibleContainer == "db_b") + #expect(tabManager.visibleTabs.isEmpty) + #expect(tabManager.selectedTabId == nil) #expect(tabManager.tabs.contains { $0.content.query == "SELECT NOW()" }) #expect(coordinator.toolbarState.currentDatabase == "db_b") } } + /// The toolbar, the sidebar and Open Database used to move only the object browser and leave a + /// tab from the database the user had just left on screen. + @Test("switching back to a database lands on the tab last used there") + func switchingBackLandsOnTheDatabasesTab() async throws { + try await withConnectedCoordinator { coordinator, tabManager in + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") + tabManager.addTab(initialQuery: "SELECT NOW()", databaseName: "db_a") + let users = try #require(tabManager.tabs.first) + tabManager.selectedTabId = users.id + + _ = await coordinator.switchDatabase(to: "db_b", persist: false) + tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_b") + _ = await coordinator.switchDatabase(to: "db_a", persist: false) + + #expect(tabManager.visibleContainer == "db_a") + #expect(tabManager.selectedTabId == users.id) + #expect(tabManager.visibleTabs.map(\.tableContext.databaseName) == ["db_a", "db_a"]) + } + } + @Test("switchDatabase leaves each tab bound to the database it was opened against") func switchDatabaseKeepsPerTabDatabase() async throws { try await withConnectedCoordinator { coordinator, tabManager in diff --git a/TableProTests/Views/SwitchSchemaTests.swift b/TableProTests/Views/SwitchSchemaTests.swift index 0811b35a26..e951c60034 100644 --- a/TableProTests/Views/SwitchSchemaTests.swift +++ b/TableProTests/Views/SwitchSchemaTests.swift @@ -78,10 +78,12 @@ struct SwitchSchemaTests { await body(connection, driver) } + /// PostgreSQL's entries are its databases, so a schema switch inside one keeps the entry on + /// screen, its tabs and the tab the user was on. @Test("switchSchema keeps query and table tabs and their contents") func switchSchemaPreservesTabs() async { await withSchemaSwitchingConnection { connection, driver in - let tabManager = QueryTabManager() + let tabManager = QueryTabManager(containerTarget: .database, visibleContainer: connection.database) let coordinator = MainContentCoordinator( connection: connection, tabManager: tabManager, @@ -90,8 +92,10 @@ struct SwitchSchemaTests { ) defer { coordinator.teardown() } - let queryTab = makeTab(title: "Query 1", query: "SELECT 42", tabType: .query) - let tableTab = makeTab(title: "users", query: "SELECT * FROM users", tabType: .table, tableName: "users") + var queryTab = makeTab(title: "Query 1", query: "SELECT 42", tabType: .query) + queryTab.tableContext.databaseName = connection.database + var tableTab = makeTab(title: "users", query: "SELECT * FROM users", tabType: .table, tableName: "users") + tableTab.tableContext.databaseName = connection.database tabManager.tabs = [queryTab, tableTab] tabManager.selectedTabId = queryTab.id let idsBefore = tabManager.tabs.map(\.id) @@ -100,6 +104,7 @@ struct SwitchSchemaTests { #expect(tabManager.tabs.map(\.id) == idsBefore) #expect(tabManager.selectedTabId == queryTab.id) + #expect(tabManager.visibleTabs.map(\.id) == idsBefore) #expect(tabManager.tabs.contains { $0.content.query == "SELECT 42" }) #expect(driver.currentSchema == "s2") #expect(coordinator.toolbarState.currentSchema == "s2") diff --git a/TableProTests/Views/VisibleContainerFollowingTests.swift b/TableProTests/Views/VisibleContainerFollowingTests.swift new file mode 100644 index 0000000000..d27d8bc040 --- /dev/null +++ b/TableProTests/Views/VisibleContainerFollowingTests.swift @@ -0,0 +1,158 @@ +// +// VisibleContainerFollowingTests.swift +// TableProTests +// +// The entry a window shows and the database its connection browses move together: a tab picked +// in another entry takes the connection there, and a switch made outside the window brings that +// entry forward. +// + +import Foundation +import TableProPluginKit +import TableProTestSupport +import Testing + +@testable import TablePro + +@MainActor +struct VisibleContainerFollowingTests { + private func withConnectedCoordinator( + _ body: (MainContentCoordinator, QueryTabManager, DatabaseConnection) async throws -> Void + ) async throws { + let connection = TestFixtures.makeConnection(database: "db_a", type: .mysql) + let driver = PluginDriverAdapter(connection: connection, pluginDriver: SwitchingDriverStub()) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let tabManager = QueryTabManager(containerTarget: .database, visibleContainer: "db_a") + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try await body(coordinator, tabManager, connection) + } + + private func queryTab(database: String) -> QueryTab { + var tab = QueryTab(title: "Query", query: "SELECT 1") + tab.tableContext.databaseName = database + return tab + } + + @Test("Picking a tab of another database takes the connection there") + func selectingAnotherEntrysTabMovesTheCursor() async throws { + try await withConnectedCoordinator { coordinator, tabManager, connection in + let tenantA = queryTab(database: "db_a") + let tenantB = queryTab(database: "db_b") + tabManager.tabs = [tenantA, tenantB] + tabManager.selectedTabId = tenantA.id + + tabManager.selectedTabId = tenantB.id + + try await waitUntil { coordinator.browsedContainer == "db_b" } + #expect(tabManager.visibleContainer == "db_b") + #expect(tabManager.selectedTabId == tenantB.id) + /// Picking a tab is not choosing a database to come back to. + #expect(AppSettingsStorage.shared.loadLastDatabase(for: connection.id) != "db_b") + } + } + + /// An MCP client or a script moves the cursor without going through the window. + @Test("A switch made outside the window brings that database's entry forward") + func outsideSwitchShowsTheEntry() async throws { + try await withConnectedCoordinator { coordinator, tabManager, connection in + let tenantA = queryTab(database: "db_a") + let tenantB = queryTab(database: "db_b") + tabManager.tabs = [tenantA, tenantB] + tabManager.selectedTabId = tenantA.id + + try await DatabaseManager.shared.switchDatabase(to: "db_b", for: connection.id, persist: false) + + try await waitUntil { tabManager.visibleContainer == "db_b" } + #expect(tabManager.selectedTabId == tenantB.id) + #expect(coordinator.pendingCursorRequest == nil) + } + } + + /// Switches queue on the driver, so an earlier one can land after a newer one was asked for. + @Test("Only the latest move this window asked for is applied when it lands") + func staleLandingIsNotApplied() async throws { + try await withConnectedCoordinator { coordinator, _, _ in + let first = coordinator.beginCursorRequest() + let second = coordinator.beginCursorRequest() + + #expect(!coordinator.endCursorRequest(first)) + #expect(coordinator.endCursorRequest(second)) + #expect(coordinator.pendingCursorRequest == nil) + } + } + + /// Picking a tab of the database being left, before the switch away lands, has to win: the + /// earlier switch would otherwise land and take the window back to the other entry. + @Test("A selection supersedes a switch still on its way") + func followSupersedesAPendingSwitch() async throws { + try await withConnectedCoordinator { coordinator, tabManager, _ in + tabManager.tabs = [queryTab(database: "db_a")] + let earlier = coordinator.beginCursorRequest() + + coordinator.followVisibleContainer() + + try await waitUntil { coordinator.pendingCursorRequest == nil } + #expect(!coordinator.endCursorRequest(earlier)) + #expect(coordinator.browsedContainer == "db_a") + } + } + + /// The edits are the outgoing tab's and go with it. Left in the change manager with no tab + /// selected, a Save would write them against the database on screen. + @Test("Landing on an entry with no tabs keeps the outgoing edits on their tab") + func emptyEntryTakesNoLiveEdits() async throws { + try await withConnectedCoordinator { coordinator, tabManager, _ in + var orders = QueryTab(title: "orders", query: "SELECT * FROM orders", tabType: .table, tableName: "orders") + orders.tableContext.databaseName = "db_a" + tabManager.tabs = [orders] + tabManager.selectedTabId = orders.id + coordinator.changeManager.configureForTable( + tableName: "orders", + columns: ["id", "status"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + coordinator.changeManager.recordCellChange( + rowID: .existing(0), + columnIndex: 1, + columnName: "status", + oldValue: "open", + newValue: "paid", + originalRow: ["1", "open"] + ) + + tabManager.show(container: "db_b") + coordinator.handleTabChange(from: orders.id, to: nil, tabs: tabManager.tabs) + + #expect(!coordinator.changeManager.hasChanges) + #expect(tabManager.tabs.first?.pendingChanges.hasChanges == true) + } + } + + @Test("A switch to the database already browsed still brings its entry forward") + func satisfiedSwitchShowsTheEntry() async throws { + try await withConnectedCoordinator { coordinator, tabManager, _ in + let tenantA = queryTab(database: "db_a") + tabManager.tabs = [tenantA] + tabManager.show(container: "db_b") + #expect(tabManager.selectedTabId == nil) + + await coordinator.switchContainers(database: "db_a", schema: nil) + + #expect(tabManager.visibleContainer == "db_a") + #expect(tabManager.selectedTabId == tenantA.id) + } + } +} From 73d5324e5b322228fd03a10709861c358242c91f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 15 Oct 2026 22:51:09 +0700 Subject: [PATCH 2/3] fix(connections): open new tabs in the entry on screen --- .../Infrastructure/EditorTabOpener.swift | 4 +- TablePro/Models/Query/QueryTabManager.swift | 8 ++++ ...nContentCoordinator+VisibleContainer.swift | 28 +++++++----- ...inContentCoordinator+WindowLifecycle.swift | 2 +- .../Main/MainContentCommandActions.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 6 +-- ...QueryTabManagerVisibleContainerTests.swift | 7 +++ .../VisibleContainerFollowingTests.swift | 45 +++++++++++++++++++ 8 files changed, 85 insertions(+), 17 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index 7a991f761b..ae4f176191 100644 --- a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -19,7 +19,9 @@ internal enum EditorTabOpener { connection: DatabaseConnection, toolbarState: ConnectionToolbarState? ) { - let browseDatabaseName = DatabaseManager.shared.browseDatabaseName(for: connection) + let browseDatabaseName = tabManager.openingDatabase( + browsing: DatabaseManager.shared.browseDatabaseName(for: connection) + ) switch payload.intent { case .openContent: diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 9ece25aa48..6364b64995 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -798,6 +798,14 @@ extension QueryTabManager { } } + /// Where a tab opened without naming a database goes: the entry on screen. The browse cursor + /// lags it while a switch to that entry is on its way, and a tab bound to the cursor then would + /// take the window straight back to the entry the user just left. + func openingDatabase(browsing browseDatabase: String) -> String { + guard containerTarget == .database, let visibleContainer else { return browseDatabase } + return visibleContainer + } + /// Restored tabs from before tabs were bound to their entry: they ran against the container the /// connection was on, which is the one the restore lands in. func bindUnboundTabs() { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift index 786e15fd52..a809601b12 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift @@ -17,6 +17,10 @@ extension MainContentCoordinator { return WorkspaceAnchoring.browsedContainer(of: session, target: tabManager.containerTarget) } + var newTabDatabaseName: String { + tabManager.openingDatabase(browsing: browseDatabaseName) + } + /// Whether this window follows a cursor that something else moved. A connection a tab was torn /// off from is hosted by two windows that share one cursor, and only the one in front follows, /// so the window behind keeps the tabs it shows. Nil from the window manager means no window @@ -60,26 +64,28 @@ extension MainContentCoordinator { /// A switch still on its way counts even when the cursor has not moved yet: picking a tab of the /// database being left, before the switch away lands, has to win over it. /// - /// A window that is not the connection's frontmost does not move the cursor, so a tab revealed - /// in a window that has not come forward yet is followed once that window becomes key. + /// A window that is not the connection's frontmost does not move the cursor; it does once it + /// becomes key. func followVisibleContainer() { guard let container = tabManager.visibleContainer, container != browsedContainer || pendingCursorRequest != nil, - services.databaseManager.session(for: connectionId)?.isConnected == true + services.databaseManager.session(for: connectionId)?.isConnected == true, + followsBrowseCursor else { return } - guard followsBrowseCursor else { - hasDeferredCursorFollow = true - return - } - hasDeferredCursorFollow = false + failedCursorFollow = nil Task { @MainActor [weak self] in await self?.switchContainer(to: container, persist: false) + guard let self, self.browsedContainer != container, self.pendingCursorRequest == nil else { return } + self.failedCursorFollow = container } } - func followDeferredVisibleContainer() { - guard hasDeferredCursorFollow else { return } - hasDeferredCursorFollow = false + /// Two windows of one connection share its cursor, so the one the user comes back to may show an + /// entry the other window has since moved the cursor away from. The window in front decides what + /// the connection browses. A container whose switch just failed is not asked for again on every + /// activation: dismissing the error makes the window key, and retrying there raised it again. + func followVisibleContainerOnActivation() { + guard tabManager.visibleContainer != failedCursorFollow else { return } followVisibleContainer() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 8ecbf4b878..35f7a36a5e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -36,7 +36,7 @@ extension MainContentCoordinator { consumeDeferredRestoreLoadIfNeeded() refreshSourceFileDiskChanges() - followDeferredVisibleContainer() + followVisibleContainerOnActivation() syncSidebarObjectSelection() announceActiveTabToVoiceOver() diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 17e2a35a79..61fab9e682 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -650,7 +650,7 @@ final class MainContentCommandActions: ObservableObject { guard let coordinator else { return } coordinator.tabManager.addTab( initialQuery: initialQuery, - databaseName: coordinator.browseDatabaseName, + databaseName: coordinator.newTabDatabaseName, claimFocus: true ) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index e1c489244d..742250ac8b 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -176,7 +176,7 @@ final class MainContentCoordinator: ObservableObject { /// lands after a newer request is stale and must not put back the entry the user already left. internal var pendingCursorRequest: Int? internal var cursorRequestCount = 0 - internal var hasDeferredCursorFollow = false + internal var failedCursorFollow: String? // MARK: - Services @@ -1272,7 +1272,7 @@ final class MainContentCoordinator: ObservableObject { databaseName: String? = nil, forceNewTab: Bool = false ) -> WindowTabOpenDisposition { - let targetDatabaseName = databaseName ?? browseDatabaseName + let targetDatabaseName = databaseName ?? newTabDatabaseName if !forceNewTab, let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.tabType == .query, @@ -1312,7 +1312,7 @@ final class MainContentCoordinator: ObservableObject { mutTab.hasUserInteraction = true } } else if tabManager.tabs.isEmpty { - tabManager.addTab(initialQuery: query, databaseName: browseDatabaseName) + tabManager.addTab(initialQuery: query, databaseName: newTabDatabaseName) } else { let payload = EditorTabPayload( connectionId: connection.id, diff --git a/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift b/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift index 2038106548..1f7854550f 100644 --- a/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerVisibleContainerTests.swift @@ -102,6 +102,13 @@ struct QueryTabManagerVisibleContainerTests { #expect(manager.tabs.map(\.tableContext.databaseName) == ["tenant_b", ""]) } + @Test("A tab opened without a database goes to the entry on screen, not the browse cursor") + func openingDatabaseIsTheShownEntry() { + #expect(makeManager().openingDatabase(browsing: "tenant_b") == "tenant_a") + #expect(makeManager(target: .schema, visible: "HR").openingDatabase(browsing: "ORCL") == "ORCL") + #expect(makeManager(target: nil, visible: nil).openingDatabase(browsing: "main") == "main") + } + // MARK: - Selection carries the entry @Test("Selecting a tab of another entry puts that entry in front and says so once") diff --git a/TableProTests/Views/VisibleContainerFollowingTests.swift b/TableProTests/Views/VisibleContainerFollowingTests.swift index d27d8bc040..2992b58fe4 100644 --- a/TableProTests/Views/VisibleContainerFollowingTests.swift +++ b/TableProTests/Views/VisibleContainerFollowingTests.swift @@ -141,6 +141,51 @@ struct VisibleContainerFollowingTests { } } + /// Going to an entry shows its tabs before the switch lands, and a tab opened in that moment + /// belongs to the entry on screen, not to the database the cursor is leaving. + @Test("A new tab opened while going to another entry lands in that entry") + func newTabDuringASwitchTakesTheShownEntry() async throws { + try await withConnectedCoordinator { coordinator, tabManager, _ in + tabManager.tabs = [queryTab(database: "db_a")] + + coordinator.goToEntry("db_b") + + #expect(coordinator.browsedContainer == "db_a") + #expect(coordinator.newTabDatabaseName == "db_b") + tabManager.addTab(initialQuery: "SELECT 1", databaseName: coordinator.newTabDatabaseName) + #expect(tabManager.visibleContainer == "db_b") + try await waitUntil { coordinator.browsedContainer == "db_b" } + } + } + + /// Two windows of one connection share its cursor; the window the user comes back to decides. + @Test("A window coming to the front takes the connection to the entry it shows") + func activationMovesTheCursorToTheShownEntry() async throws { + try await withConnectedCoordinator { coordinator, tabManager, _ in + tabManager.tabs = [queryTab(database: "db_b")] + tabManager.show(container: "db_b") + + coordinator.followVisibleContainerOnActivation() + + try await waitUntil { coordinator.browsedContainer == "db_b" } + } + } + + /// Dismissing the error makes the window key again, and asking again there raised it again. + @Test("Coming to the front does not retry a switch that just failed") + func activationSkipsAFailedSwitch() async throws { + try await withConnectedCoordinator { coordinator, tabManager, _ in + tabManager.tabs = [queryTab(database: "db_b")] + tabManager.show(container: "db_b") + coordinator.failedCursorFollow = "db_b" + + coordinator.followVisibleContainerOnActivation() + + #expect(coordinator.pendingCursorRequest == nil) + #expect(coordinator.browsedContainer == "db_a") + } + } + @Test("A switch to the database already browsed still brings its entry forward") func satisfiedSwitchShowsTheEntry() async throws { try await withConnectedCoordinator { coordinator, tabManager, _ in From dbd07ea823edc2bb26ab7b0ca6dbbb1e6e94f5a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 15 Oct 2026 23:08:51 +0700 Subject: [PATCH 3/3] fix(connections): restore the saved entry and claim cursor moves up front --- .../Infrastructure/EditorTabOpener.swift | 24 +++++---- TablePro/Models/Workspace/RestoredEntry.swift | 41 ++++++++++++++ .../MainContentCoordinator+TabRestore.swift | 32 ++++------- ...nContentCoordinator+VisibleContainer.swift | 17 ++++-- .../Infrastructure/EditorTabOpenerTests.swift | 24 +++++++++ TableProTests/Models/RestoredEntryTests.swift | 53 +++++++++++++++++++ .../VisibleContainerFollowingTests.swift | 17 ++++++ 7 files changed, 172 insertions(+), 36 deletions(-) create mode 100644 TablePro/Models/Workspace/RestoredEntry.swift create mode 100644 TableProTests/Models/RestoredEntryTests.swift diff --git a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index ae4f176191..6fd7144a35 100644 --- a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -19,9 +19,11 @@ internal enum EditorTabOpener { connection: DatabaseConnection, toolbarState: ConnectionToolbarState? ) { - let browseDatabaseName = tabManager.openingDatabase( - browsing: DatabaseManager.shared.browseDatabaseName(for: connection) - ) + let browseDatabaseName = DatabaseManager.shared.browseDatabaseName(for: connection) + /// A table named without a database is the one in the database being browsed, the same answer + /// the lookup for a tab already showing it gives. Anything else opened without one belongs to + /// the entry on screen, which the cursor lags while a switch to it is on its way. + let openingDatabaseName = tabManager.openingDatabase(browsing: browseDatabaseName) switch payload.intent { case .openContent: @@ -30,14 +32,15 @@ internal enum EditorTabOpener { to: tabManager, connection: connection, toolbarState: toolbarState, - browseDatabaseName: browseDatabaseName + browseDatabaseName: browseDatabaseName, + openingDatabaseName: openingDatabaseName ) case .newEmptyTab: let allTabs = MainContentCoordinator.allTabs(for: connection.id) tabManager.addTab( initialQuery: payload.initialQuery, title: QueryTabManager.nextQueryTitle(existingTabs: allTabs), - databaseName: payload.databaseName ?? browseDatabaseName, + databaseName: payload.databaseName ?? openingDatabaseName, claimFocus: true ) case .restoreOrDefault: @@ -50,7 +53,8 @@ internal enum EditorTabOpener { to tabManager: QueryTabManager, connection: DatabaseConnection, toolbarState: ConnectionToolbarState?, - browseDatabaseName: String + browseDatabaseName: String, + openingDatabaseName: String ) { switch payload.tabType { case .table: @@ -69,18 +73,18 @@ internal enum EditorTabOpener { tabManager.addTab( initialQuery: payload.initialQuery, title: payload.tabTitle, - databaseName: payload.databaseName ?? browseDatabaseName, + databaseName: payload.databaseName ?? openingDatabaseName, sourceFileURL: payload.sourceFileURL, sourceFileStamp: payload.sourceFileStamp, sourceFileEncoding: payload.sourceFileEncoding, claimFocus: true ) case .createTable: - tabManager.addCreateTableTab(databaseName: payload.databaseName ?? browseDatabaseName) + tabManager.addCreateTableTab(databaseName: payload.databaseName ?? openingDatabaseName) case .erDiagram: tabManager.addERDiagramTab( - schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? browseDatabaseName, - databaseName: payload.databaseName ?? browseDatabaseName, + schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? openingDatabaseName, + databaseName: payload.databaseName ?? openingDatabaseName, schemaName: payload.schemaName ) case .serverDashboard: diff --git a/TablePro/Models/Workspace/RestoredEntry.swift b/TablePro/Models/Workspace/RestoredEntry.swift new file mode 100644 index 0000000000..d0b143f17c --- /dev/null +++ b/TablePro/Models/Workspace/RestoredEntry.swift @@ -0,0 +1,41 @@ +// +// RestoredEntry.swift +// TablePro +// + +import Foundation + +/// The connections-strip entry a restore lands in, and the database and schema it switches to. +internal struct RestoredEntry: Equatable { + internal let container: String? + internal let database: String? + internal let schema: String? + + /// A tab a link opened before the restore read the disk is what the user asked for, so its entry + /// wins. Otherwise the database the connection was last on, which is the entry that was on + /// screen: the saved selection cannot say so, because an entry with no tab of its own saves the + /// connection's first tab as selected. The saved tab decides only when no database was saved. + internal static func resolve( + selected: QueryTab?, + isPresent: Bool, + target: ContainerSwitchTarget?, + activeDatabase: String?, + activeSchema: String? + ) -> RestoredEntry { + let lastActive = WorkspaceAnchoring.containerName(database: activeDatabase, schema: activeSchema, target: target) + let saved = RestoredEntry(container: lastActive, database: activeDatabase, schema: activeSchema) + guard let selected, + let container = WorkspaceAnchoring.containerName(of: selected, target: target), + container != lastActive, + isPresent || lastActive == nil + else { return saved } + switch target { + case .database: + return RestoredEntry(container: container, database: container, schema: nil) + case .schema: + return RestoredEntry(container: container, database: activeDatabase, schema: container) + case nil: + return saved + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift index 678fa2d9ad..bb1290d0c5 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift @@ -72,6 +72,7 @@ extension MainContentCoordinator { let entry = restoredEntry( selecting: merge.selectedTabId, among: merge.tabs, + restoredTabIds: merge.restoredTabIds, activeDatabase: activeDatabase, activeSchema: activeSchema ) @@ -117,32 +118,21 @@ extension MainContentCoordinator { ) } - /// Where a restore lands: the entry of the tab it selects, so the tab the user was on, or the one - /// a link opened before the restore read the disk, is the one on screen. The database and schema - /// the connection was last on are kept when they are that entry, and named only when no selected - /// tab names one. private func restoredEntry( selecting selection: UUID?, among tabs: [QueryTab], + restoredTabIds: Set, activeDatabase: String?, activeSchema: String? - ) -> (container: String?, database: String?, schema: String?) { - let target = tabManager.containerTarget - let lastActive = WorkspaceAnchoring.containerName(database: activeDatabase, schema: activeSchema, target: target) - guard let selected = tabs.first(where: { $0.id == selection }), - let container = tabManager.containerName(of: selected), - container != lastActive - else { - return (lastActive, activeDatabase, activeSchema) - } - switch target { - case .database: - return (container, container, nil) - case .schema: - return (container, activeDatabase, container) - case nil: - return (lastActive, activeDatabase, activeSchema) - } + ) -> RestoredEntry { + let selected = tabs.first { $0.id == selection } + return RestoredEntry.resolve( + selected: selected, + isPresent: selected.map { !restoredTabIds.contains($0.id) } ?? false, + target: tabManager.containerTarget, + activeDatabase: activeDatabase, + activeSchema: activeSchema + ) } internal func restoreSavedTabs() async { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift index a809601b12..3ffd745b03 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+VisibleContainer.swift @@ -72,10 +72,19 @@ extension MainContentCoordinator { services.databaseManager.session(for: connectionId)?.isConnected == true, followsBrowseCursor else { return } + moveCursor(to: container, persist: false) + } + + /// Claimed before the task is scheduled, so a selection made before it starts sees it in flight + /// and supersedes it, and a superseded task never switches at all. A failure is remembered so the + /// window coming back to the front, which is what dismissing the error does, does not ask again. + private func moveCursor(to container: String, persist: Bool) { failedCursorFollow = nil + let request = beginCursorRequest() Task { @MainActor [weak self] in - await self?.switchContainer(to: container, persist: false) - guard let self, self.browsedContainer != container, self.pendingCursorRequest == nil else { return } + guard let self, self.pendingCursorRequest == request else { return } + await self.switchContainer(to: container, persist: persist) + guard self.browsedContainer != container, self.pendingCursorRequest == nil else { return } self.failedCursorFollow = container } } @@ -94,8 +103,6 @@ extension MainContentCoordinator { func goToEntry(_ container: String) { tabManager.show(container: container) guard container != browsedContainer || pendingCursorRequest != nil else { return } - Task { @MainActor [weak self] in - await self?.switchContainer(to: container) - } + moveCursor(to: container, persist: true) } } diff --git a/TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift b/TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift index 9ad1e93003..d0efb9dee2 100644 --- a/TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift +++ b/TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift @@ -25,6 +25,30 @@ struct EditorTabOpenerTests { DatabaseManager.shared.injectSession(session, for: connection.id) } + /// While a switch to the entry on screen is on its way the cursor still names the database being + /// left. A new tab belongs to the entry on screen; a table named without a database is the one + /// in the database being browsed, which is also what the lookup for an open copy of it uses. + @Test("A new tab takes the entry on screen and a bare table takes the browsed database") + func unnamedPayloadsResolveByKind() throws { + let connection = TestFixtures.makeConnection(database: "tenant_a", type: .mysql) + let manager = QueryTabManager(containerTarget: .database, visibleContainer: "tenant_b") + + EditorTabOpener.apply( + EditorTabPayload(connectionId: connection.id, tabType: .query, intent: .newEmptyTab), + to: manager, + connection: connection, + toolbarState: nil + ) + EditorTabOpener.apply( + EditorTabPayload(connectionId: connection.id, tabType: .table, tableName: "orders", isView: false), + to: manager, + connection: connection, + toolbarState: nil + ) + + #expect(manager.tabs.map(\.tableContext.databaseName) == ["tenant_b", "tenant_a"]) + } + /// A link, MCP or AppleScript naming another database and no schema bound the table to the /// schema browsed in this one, so its first query named a relation that database may not have. @Test("A table payload naming another database takes no schema from the browsed one") diff --git a/TableProTests/Models/RestoredEntryTests.swift b/TableProTests/Models/RestoredEntryTests.swift new file mode 100644 index 0000000000..ef94481b64 --- /dev/null +++ b/TableProTests/Models/RestoredEntryTests.swift @@ -0,0 +1,53 @@ +// +// RestoredEntryTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +struct RestoredEntryTests { + private func tab(database: String) -> QueryTab { + var tab = QueryTab(title: "orders", tabType: .table, tableName: "orders") + tab.tableContext.databaseName = database + return tab + } + + /// An entry with no tab of its own saves the connection's first tab as selected, so the saved + /// selection names another database than the one that was on screen. + @Test("A restore lands on the database the connection was last on, not the saved first tab") + func savedDatabaseWinsOverASavedTab() { + let entry = RestoredEntry.resolve( + selected: tab(database: "tenant_a"), + isPresent: false, + target: .database, + activeDatabase: "tenant_b", + activeSchema: nil + ) + #expect(entry == RestoredEntry(container: "tenant_b", database: "tenant_b", schema: nil)) + } + + @Test("A tab a link opened before the restore keeps its own database") + func presentTabWins() { + let entry = RestoredEntry.resolve( + selected: tab(database: "tenant_a"), + isPresent: true, + target: .database, + activeDatabase: "tenant_b", + activeSchema: "public" + ) + #expect(entry == RestoredEntry(container: "tenant_a", database: "tenant_a", schema: nil)) + } + + @Test("With no database saved the selected tab decides") + func selectedTabDecidesWithoutASavedDatabase() { + let entry = RestoredEntry.resolve( + selected: tab(database: "tenant_a"), + isPresent: false, + target: .database, + activeDatabase: nil, + activeSchema: nil + ) + #expect(entry.container == "tenant_a") + } +} diff --git a/TableProTests/Views/VisibleContainerFollowingTests.swift b/TableProTests/Views/VisibleContainerFollowingTests.swift index 2992b58fe4..e9e82fc3cc 100644 --- a/TableProTests/Views/VisibleContainerFollowingTests.swift +++ b/TableProTests/Views/VisibleContainerFollowingTests.swift @@ -158,6 +158,23 @@ struct VisibleContainerFollowingTests { } } + /// A follow scheduled but not yet started has to be in flight already, or a newer move to the + /// database still browsed sees nothing to supersede and the older one takes the window away. + @Test("A scheduled switch that was superseded before it started never runs") + func supersededScheduledSwitchNeverRuns() async throws { + try await withConnectedCoordinator { coordinator, tabManager, _ in + tabManager.tabs = [queryTab(database: "db_a"), queryTab(database: "db_b")] + tabManager.show(container: "db_b") + coordinator.followVisibleContainer() + + coordinator.goToEntry("db_a") + + try await waitUntil { coordinator.pendingCursorRequest == nil } + #expect(coordinator.browsedContainer == "db_a") + #expect(tabManager.visibleContainer == "db_a") + } + } + /// Two windows of one connection share its cursor; the window the user comes back to decides. @Test("A window coming to the front takes the connection to the entry it shows") func activationMovesTheCursorToTheShownEntry() async throws {