diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fb5dff9..4189ab24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **File > Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** close a group of tabs in one step instead of one at a time. Closing every tab leaves the window open on its empty state with the connection still live, and each closed tab stays in **Recently Closed**. None of the three has a shortcut out of the box; bind them in **Settings > Keyboard**. (#1972) - AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945) - Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Closing a window that holds more than one tab now asks about unsaved changes in any of them, not just the tab on screen. (#1972) - MongoDB no longer fails with an authentication error when you open a database your credentials do not belong to. TablePro authenticated against whichever database you were browsing instead of the one set on the connection, which left the connection broken until you deleted and recreated it. Creating a database hit this every time. (#1970) - Creating a MongoDB database now asks for its first collection and keeps it. The database was created and then removed again, because MongoDB drops a database as soon as its last collection goes. (#1970) - A MongoDB authentication error now names the user and the database it tried to authenticate against, instead of a bare driver code. (#1970) diff --git a/TablePro/Models/Query/TabBatchClosePlanner.swift b/TablePro/Models/Query/TabBatchClosePlanner.swift new file mode 100644 index 000000000..3ec712088 --- /dev/null +++ b/TablePro/Models/Query/TabBatchClosePlanner.swift @@ -0,0 +1,68 @@ +// +// TabBatchClosePlanner.swift +// TablePro +// + +import Foundation + +struct TabBatchCloseTarget: Equatable { + let windowId: ObjectIdentifier + let databaseNames: Set +} + +enum TabBatchClosePlanner { + struct Plan: Equatable { + let windowsToCloseOutright: [ObjectIdentifier] + let survivorWindowId: ObjectIdentifier? + + static let empty = Plan(windowsToCloseOutright: [], survivorWindowId: nil) + + var isEmpty: Bool { + windowsToCloseOutright.isEmpty && survivorWindowId == nil + } + } + + static func planCloseAll( + targets: [TabBatchCloseTarget], + currentWindowId: ObjectIdentifier + ) -> Plan { + Plan( + windowsToCloseOutright: windowIds(in: targets, excluding: currentWindowId), + survivorWindowId: currentWindowId + ) + } + + static func planCloseOthers( + targets: [TabBatchCloseTarget], + currentWindowId: ObjectIdentifier + ) -> Plan { + Plan( + windowsToCloseOutright: windowIds(in: targets, excluding: currentWindowId), + survivorWindowId: nil + ) + } + + /// A window closes only when every tab in it names a database other than the active one. + /// An unnamed database means "whatever the connection is pointed at", which is never foreign, + /// and an unknown active database cannot classify anything, so both yield an empty plan. + static func planCloseForOtherDatabases( + targets: [TabBatchCloseTarget], + currentWindowId: ObjectIdentifier, + currentDatabaseName: String + ) -> Plan { + guard !currentDatabaseName.isEmpty else { return .empty } + let foreign = targets.filter { target in + target.windowId != currentWindowId + && !target.databaseNames.isEmpty + && !target.databaseNames.contains(currentDatabaseName) + } + return Plan(windowsToCloseOutright: foreign.map(\.windowId), survivorWindowId: nil) + } + + private static func windowIds( + in targets: [TabBatchCloseTarget], + excluding currentWindowId: ObjectIdentifier + ) -> [ObjectIdentifier] { + targets.map(\.windowId).filter { $0 != currentWindowId } + } +} diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index c161e2d1c..ef48cc3c2 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -110,6 +110,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { // Navigation case newTab case closeTab + case closeOtherTabs + case closeTabsForOtherDatabases + case closeAllTabs case reopenClosedTab case quickSwitcher case toggleTableBrowser @@ -142,7 +145,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage, .nextPage, .firstPage, .lastPage, .refresh, .export, .importData: return .dataGrid - case .newTab, .closeTab, .reopenClosedTab, .quickSwitcher, .toggleTableBrowser, + case .newTab, .closeTab, .closeOtherTabs, .closeTabsForOtherDatabases, .closeAllTabs, + .reopenClosedTab, .quickSwitcher, .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab, .nextResultTab, .pinResultTab, .closeResultTab, .focusSidebarSearch, .showSidebarTables, .showSidebarFavorites, .showPreviousTab, .showNextTab: @@ -190,6 +194,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .saveAs: return String(localized: "Save As") case .previewSQL: return String(localized: "Preview SQL") case .closeTab: return String(localized: "Close Tab") + case .closeOtherTabs: return String(localized: "Close Other Tabs") + case .closeTabsForOtherDatabases: return String(localized: "Close Tabs for Other Databases") + case .closeAllTabs: return String(localized: "Close All Tabs") case .reopenClosedTab: return String(localized: "Reopen Closed Tab") case .refresh: return String(localized: "Refresh") case .explainQuery: return String(localized: "Explain Query") diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index a6c005338..cbfdb392b 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -337,6 +337,25 @@ struct AppMenuCommands: Commands { } .optionalKeyboardShortcut(shortcut(for: .closeTab)) + Button(String(localized: "Close Other Tabs")) { + resolvedCloseTabActions?.closeOtherTabs() + } + .optionalKeyboardShortcut(shortcut(for: .closeOtherTabs)) + .disabled(resolvedCloseTabActions?.canCloseOtherTabs != true) + + Button(resolvedCloseTabActions?.closeTabsForOtherDatabasesTitle + ?? String(localized: "Close Tabs for Other Databases")) { + resolvedCloseTabActions?.closeTabsForOtherDatabases() + } + .optionalKeyboardShortcut(shortcut(for: .closeTabsForOtherDatabases)) + .disabled(resolvedCloseTabActions?.canCloseTabsForOtherDatabases != true) + + Button(String(localized: "Close All Tabs")) { + resolvedCloseTabActions?.closeAllTabs() + } + .optionalKeyboardShortcut(shortcut(for: .closeAllTabs)) + .disabled(resolvedCloseTabActions?.canCloseAllTabs != true) + Button(String(localized: "Reopen Closed Tab")) { RecentlyClosedTabReopener.reopenMostRecent() } diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift new file mode 100644 index 000000000..b0155dbcb --- /dev/null +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -0,0 +1,122 @@ +// +// MainContentCommandActions+BulkClose.swift +// TablePro +// + +import AppKit +import Foundation + +extension MainContentCommandActions { + enum BatchCloseKind { + case all + case others + case otherDatabases + } + + func closeAllTabs() { + Task { await runBatchClose(kind: .all) } + } + + func closeOtherTabs() { + Task { await runBatchClose(kind: .others) } + } + + func closeTabsForOtherDatabases() { + Task { await runBatchClose(kind: .otherDatabases) } + } + + var canCloseAllTabs: Bool { + openTabCount > 0 || !batchClosePlan(kind: .all).windowsToCloseOutright.isEmpty + } + + var canCloseOtherTabs: Bool { + !batchClosePlan(kind: .others).windowsToCloseOutright.isEmpty + } + + var canCloseTabsForOtherDatabases: Bool { + guard supportsContainerSwitching else { return false } + return !batchClosePlan(kind: .otherDatabases).windowsToCloseOutright.isEmpty + } + + var closeTabsForOtherDatabasesTitle: String { + switch PluginManager.shared.containerSwitchTarget(for: currentDatabaseType) { + case .schema: + return String(localized: "Close Tabs for Other Schemas") + case .database, .none: + return String(localized: "Close Tabs for Other Databases") + } + } + + /// Closes every window the plan names, one at a time so each keeps the ordinary single-window + /// save prompt, then empties the survivor. Siblings go first: a cancel part-way through then + /// leaves the window the user is actually looking at untouched. + private func runBatchClose(kind: BatchCloseKind) async { + let lookup = closeCandidateLookup(kind: kind) + let plan = batchClosePlan(kind: kind, lookup: lookup) + guard !plan.isEmpty else { return } + + for windowId in plan.windowsToCloseOutright { + guard let actions = lookup[windowId]?.commandActions else { continue } + guard await actions.closeWindowAwaiting(asBatchSurvivor: false) == .closed else { return } + } + + guard plan.survivorWindowId != nil, openTabCount > 0 else { return } + await closeWindowAwaiting(asBatchSurvivor: true) + } + + private func batchClosePlan(kind: BatchCloseKind) -> TabBatchClosePlanner.Plan { + batchClosePlan(kind: kind, lookup: closeCandidateLookup(kind: kind)) + } + + private func batchClosePlan( + kind: BatchCloseKind, + lookup: [ObjectIdentifier: MainContentCoordinator] + ) -> TabBatchClosePlanner.Plan { + guard let anchor = closeAnchorWindow else { return .empty } + let currentWindowId = ObjectIdentifier(anchor) + let targets = lookup.map { windowId, coordinator in + TabBatchCloseTarget(windowId: windowId, databaseNames: coordinator.openTabDatabaseNames) + } + + switch kind { + case .all: + return TabBatchClosePlanner.planCloseAll(targets: targets, currentWindowId: currentWindowId) + case .others: + return TabBatchClosePlanner.planCloseOthers(targets: targets, currentWindowId: currentWindowId) + case .otherDatabases: + return TabBatchClosePlanner.planCloseForOtherDatabases( + targets: targets, + currentWindowId: currentWindowId, + currentDatabaseName: activeDatabaseName + ) + } + } + + /// Tab-group scope for the positional commands, because that is the strip the user is looking + /// at. Connection scope for the database command, because a database means nothing across + /// connections and one connection's tabs can be spread over sibling windows. + private func closeCandidateLookup(kind: BatchCloseKind) -> [ObjectIdentifier: MainContentCoordinator] { + let coordinators: [MainContentCoordinator] + switch kind { + case .all, .others: + guard let anchor = closeAnchorWindow else { return [:] } + coordinators = (anchor.tabGroup?.windows ?? [anchor]) + .filter(\.isVisible) + .compactMap { MainContentCoordinator.coordinator(forWindow: $0) } + case .otherDatabases: + coordinators = MainContentCoordinator.allActiveCoordinators() + .filter { $0.connectionId == connectionId } + } + + return coordinators.reduce(into: [:]) { result, coordinator in + guard let window = coordinator.contentWindow else { return } + result[ObjectIdentifier(window)] = coordinator + } + } +} + +private extension MainContentCoordinator { + var openTabDatabaseNames: Set { + Set(tabManager.tabs.map(\.tableContext.databaseName).filter { !$0.isEmpty }) + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 2e4541e12..95869492b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -22,6 +22,11 @@ import UniformTypeIdentifiers final class MainContentCommandActions { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCommandActions") + enum WindowCloseOutcome { + case closed + case cancelled + } + // MARK: - Dependencies @ObservationIgnored private weak var coordinator: MainContentCoordinator? @@ -256,6 +261,12 @@ final class MainContentCommandActions { var currentDatabaseType: DatabaseType { connection.type } + var connectionId: UUID { connection.id } + + var activeDatabaseName: String { coordinator?.activeDatabaseName ?? "" } + + var openTabCount: Int { coordinator?.tabManager.tabs.count ?? 0 } + var supportsContainerSwitching: Bool { PluginManager.shared.supportsContainerSwitching(for: connection.type) } @@ -311,8 +322,10 @@ final class MainContentCommandActions { // MARK: - Unsaved Changes Check - private var hasUnsavedChanges: Bool { - coordinator?.hasUnsavedWorkInSelectedTab() ?? false + /// Scoped to the whole window, not the selected tab: closing a window closes every tab in it, + /// so a tab the user is not looking at must still get its prompt. + private var hasUnsavedWorkInWindow: Bool { + coordinator?.hasAnyUnsavedWork() ?? false } private var isUsersRolesTab: Bool { @@ -363,27 +376,52 @@ final class MainContentCommandActions { } func closeTab() { + Task { await closeWindowAwaiting() } + } + + /// The single close primitive. `asBatchSurvivor` is `nil` for a lone close gesture, which lets + /// the window decide for itself whether it can go away; a batch passes `true` for the one + /// window it keeps blank and `false` for every window it tears down. + @discardableResult + func closeWindowAwaiting(asBatchSurvivor: Bool? = nil) async -> WindowCloseOutcome { let seq = MainContentCoordinator.nextSwitchSeq() - Self.logger.info("[close] closeTab seq=\(seq) hasUnsavedChanges=\(self.hasUnsavedChanges)") - if hasUnsavedChanges { - Task { - let keyWindow = NSApp.keyWindow - let result = await AlertHelper.confirmSaveChanges( - message: String(localized: "Your changes will be lost if you don't save them."), - window: keyWindow - ) + Self.logger.info("[close] closeWindowAwaiting seq=\(seq) hasUnsavedWork=\(self.hasUnsavedWorkInWindow)") - switch result { - case .save: - await saveAndClose() - case .dontSave: - discardAndClose() - case .cancel: - break - } - } - } else { - performClose() + guard hasUnsavedWorkInWindow else { + finish(asBatchSurvivor: asBatchSurvivor) + return .closed + } + + selectInTabGroup() + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Your changes will be lost if you don't save them."), + window: closeAnchorWindow + ) + + switch result { + case .save: + return await saveAndClose(asBatchSurvivor: asBatchSurvivor) ? .closed : .cancelled + case .dontSave: + discardAndClose(asBatchSurvivor: asBatchSurvivor) + return .closed + case .cancel: + return .cancelled + } + } + + var closeAnchorWindow: NSWindow? { + coordinator?.contentWindow ?? window ?? NSApp.keyWindow + } + + /// A background tabbed window is occluded by the selected tab, so its confirmation sheet would + /// animate onto a surface the user cannot see. Bring it forward first. + private func selectInTabGroup() { + guard let target = coordinator?.contentWindow ?? window, + let tabGroup = target.tabGroup, + tabGroup.selectedWindow !== target else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + tabGroup.selectedWindow = target } } @@ -397,37 +435,51 @@ final class MainContentCommandActions { } } - private func performClose() { + private func finish(asBatchSurvivor: Bool?) { let t0 = Date() guard let window = coordinator?.contentWindow ?? NSApp.keyWindow else { return } captureClosingTabsForRecovery() + + if let asBatchSurvivor { + Self.logger.info("[close] finish batch survivor=\(asBatchSurvivor)") + if asBatchSurvivor { + clearTabsInPlace() + } else { + window.close() + } + return + } + let visibleTabbedWindows = (window.tabbedWindows ?? [window]).filter(\.isVisible) - Self.logger.info("[close] performClose visibleTabs=\(visibleTabbedWindows.count) tabManagerTabs=\(self.coordinator?.tabManager.tabs.count ?? 0)") + Self.logger.info("[close] finish visibleTabs=\(visibleTabbedWindows.count) tabManagerTabs=\(self.coordinator?.tabManager.tabs.count ?? 0)") - if visibleTabbedWindows.count > 1 { - window.close() - } else if coordinator?.tabManager.tabs.isEmpty == true { + if visibleTabbedWindows.count > 1 || coordinator?.tabManager.tabs.isEmpty == true { window.close() } else { - if let coordinator { - for tab in coordinator.tabManager.tabs { - coordinator.tabSessionRegistry.removeTableRows(for: tab.id) - if let url = tab.content.sourceFileURL { - WindowLifecycleMonitor.shared.unregisterSourceFile(url) - } - } - coordinator.tabManager.tabs.removeAll() - coordinator.tabManager.selectedTabId = nil - coordinator.toolbarState.isTableTab = false + clearTabsInPlace() + } + Self.logger.info("[close] finish done ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + } + + /// Empties the window instead of closing it, which is how the last tab of a group lands on the + /// no-tabs state with its connection still live. + private func clearTabsInPlace() { + guard let coordinator else { return } + for tab in coordinator.tabManager.tabs { + coordinator.tabSessionRegistry.removeTableRows(for: tab.id) + if let url = tab.content.sourceFileURL { + WindowLifecycleMonitor.shared.unregisterSourceFile(url) } } - Self.logger.info("[close] performClose done ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + coordinator.tabManager.tabs.removeAll() + coordinator.tabManager.selectedTabId = nil + coordinator.toolbarState.isTableTab = false } - private func saveAndClose() async { + private func saveAndClose(asBatchSurvivor: Bool?) async -> Bool { guard let coordinator = coordinator else { - performClose() - return + finish(asBatchSurvivor: asBatchSurvivor) + return true } // User and role changes can only be applied after the SQL is reviewed, so Save opens the @@ -435,14 +487,14 @@ final class MainContentCommandActions { // destroy every staged change. if isUsersRolesTab, coordinator.usersRolesActions?.hasChanges() == true { coordinator.usersRolesActions?.reviewAndApply() - return + return false } // Structure view saves via direct coordinator call if coordinator.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator.structureActions?.saveChanges?() - performClose() - return + finish(asBatchSurvivor: asBatchSurvivor) + return true } // Data grid changes or pending table operations take priority @@ -455,26 +507,27 @@ final class MainContentCommandActions { saveChanges() } if saved { - performClose() + finish(asBatchSurvivor: asBatchSurvivor) } - return + return saved } // Sidebar-only edits (made directly in the inspector panel) if rightPanelState.editState.hasEdits { rightPanelState.onSave?() - performClose() - return + finish(asBatchSurvivor: asBatchSurvivor) + return true } // File save (query editor with source file) if coordinator.tabManager.selectedTab?.content.isFileDirty == true { saveFileToSourceURL() - performClose() - return + finish(asBatchSurvivor: asBatchSurvivor) + return true } - performClose() + finish(asBatchSurvivor: asBatchSurvivor) + return true } private func saveFileToSourceURL() { @@ -546,12 +599,12 @@ final class MainContentCommandActions { } } - private func discardAndClose() { + private func discardAndClose(asBatchSurvivor: Bool?) { coordinator?.changeManager.clearChangesAndUndoHistory() pendingTruncates.wrappedValue.removeAll() pendingDeletes.wrappedValue.removeAll() rightPanelState.editState.clearEdits() - performClose() + finish(asBatchSurvivor: asBatchSurvivor) } func copyTableNames() { diff --git a/TableProTests/Models/Query/TabBatchClosePlannerTests.swift b/TableProTests/Models/Query/TabBatchClosePlannerTests.swift new file mode 100644 index 000000000..886422e83 --- /dev/null +++ b/TableProTests/Models/Query/TabBatchClosePlannerTests.swift @@ -0,0 +1,128 @@ +// +// TabBatchClosePlannerTests.swift +// TableProTests +// +// Covers which windows a bulk close targets and which one survives (#1972). +// + +import Foundation +import Testing + +@testable import TablePro + +private final class WindowToken {} + +@Suite("TabBatchClosePlanner") +struct TabBatchClosePlannerTests { + private let current = WindowToken() + private let second = WindowToken() + private let third = WindowToken() + + private var currentId: ObjectIdentifier { ObjectIdentifier(current) } + private var secondId: ObjectIdentifier { ObjectIdentifier(second) } + private var thirdId: ObjectIdentifier { ObjectIdentifier(third) } + + private func target(_ token: WindowToken, databases: Set = []) -> TabBatchCloseTarget { + TabBatchCloseTarget(windowId: ObjectIdentifier(token), databaseNames: databases) + } + + // MARK: - Close All + + @Test("closing all keeps the invoking window as the survivor") + func closeAllKeepsCurrentWindowAsSurvivor() { + let plan = TabBatchClosePlanner.planCloseAll( + targets: [target(second), target(current), target(third)], + currentWindowId: currentId + ) + + #expect(plan.windowsToCloseOutright == [secondId, thirdId]) + #expect(plan.survivorWindowId == currentId) + } + + @Test("closing all with only the invoking window still empties it") + func closeAllWithSingleWindowStillHasSurvivor() { + let plan = TabBatchClosePlanner.planCloseAll(targets: [target(current)], currentWindowId: currentId) + + #expect(plan.windowsToCloseOutright.isEmpty) + #expect(plan.survivorWindowId == currentId) + #expect(!plan.isEmpty) + } + + // MARK: - Close Others + + @Test("closing others targets the same windows but keeps no survivor") + func closeOthersHasNoSurvivor() { + let plan = TabBatchClosePlanner.planCloseOthers( + targets: [target(second), target(current), target(third)], + currentWindowId: currentId + ) + + #expect(plan.windowsToCloseOutright == [secondId, thirdId]) + #expect(plan.survivorWindowId == nil) + } + + @Test("closing others is a no-op when the invoking window is alone") + func closeOthersWithSingleWindowIsEmpty() { + let plan = TabBatchClosePlanner.planCloseOthers(targets: [target(current)], currentWindowId: currentId) + + #expect(plan.isEmpty) + } + + // MARK: - Close for other databases + + @Test("only windows whose every tab names another database are closed") + func closeForOtherDatabasesTargetsForeignWindowsOnly() { + let plan = TabBatchClosePlanner.planCloseForOtherDatabases( + targets: [target(current, databases: ["db_a"]), target(second, databases: ["db_b"])], + currentWindowId: currentId, + currentDatabaseName: "db_a" + ) + + #expect(plan.windowsToCloseOutright == [secondId]) + #expect(plan.survivorWindowId == nil) + } + + @Test("a window holding a tab for the active database is kept") + func closeForOtherDatabasesKeepsMixedWindow() { + let plan = TabBatchClosePlanner.planCloseForOtherDatabases( + targets: [target(second, databases: ["db_a", "db_b"])], + currentWindowId: currentId, + currentDatabaseName: "db_a" + ) + + #expect(plan.isEmpty) + } + + @Test("a window with no named database follows the connection and is kept") + func closeForOtherDatabasesKeepsUnnamedWindow() { + let plan = TabBatchClosePlanner.planCloseForOtherDatabases( + targets: [target(second)], + currentWindowId: currentId, + currentDatabaseName: "db_a" + ) + + #expect(plan.isEmpty) + } + + @Test("the invoking window is never closed even when it names another database") + func closeForOtherDatabasesNeverClosesCurrentWindow() { + let plan = TabBatchClosePlanner.planCloseForOtherDatabases( + targets: [target(current, databases: ["db_b"])], + currentWindowId: currentId, + currentDatabaseName: "db_a" + ) + + #expect(plan.isEmpty) + } + + @Test("an unknown active database closes nothing") + func closeForOtherDatabasesWithoutActiveDatabaseIsEmpty() { + let plan = TabBatchClosePlanner.planCloseForOtherDatabases( + targets: [target(second, databases: ["db_b"]), target(third, databases: ["db_c"])], + currentWindowId: currentId, + currentDatabaseName: "" + ) + + #expect(plan.isEmpty) + } +} diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift new file mode 100644 index 000000000..5d77a7009 --- /dev/null +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -0,0 +1,138 @@ +// +// CommandActionsBulkCloseTests.swift +// TableProTests +// +// Covers the bulk tab-close commands: which sibling windows they target and +// that the surviving window empties in place instead of closing (#1972). +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor @Suite("CommandActions Bulk Close") +struct CommandActionsBulkCloseTests { + private struct Window { + let actions: MainContentCommandActions + let coordinator: MainContentCoordinator + let window: NSWindow + } + + private func makeWindow(connection: DatabaseConnection) -> Window { + let state = SessionStateFactory.create(connection: connection, payload: nil) + let coordinator = state.coordinator + + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [String: TableOperationOptions] = [:] + + let actions = MainContentCommandActions( + coordinator: coordinator, + connection: connection, + selectionState: coordinator.selectionState, + selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), + pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), + pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), + tableOperationOptions: Binding( + get: { tableOperationOptions }, + set: { tableOperationOptions = $0 } + ), + rightPanelState: RightPanelState() + ) + + let window = NSWindow() + coordinator.contentWindow = window + actions.window = window + + return Window(actions: actions, coordinator: coordinator, window: window) + } + + // MARK: - Survivor + + @Test("the surviving window empties in place instead of closing") + func survivorClearsTabsInPlace() async { + let connection = TestFixtures.makeConnection(database: "db_a") + let survivor = makeWindow(connection: connection) + defer { survivor.coordinator.teardown() } + + survivor.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") + #expect(survivor.coordinator.tabManager.tabs.count == 1) + + let outcome = await survivor.actions.closeWindowAwaiting(asBatchSurvivor: true) + + #expect(outcome == .closed) + #expect(survivor.coordinator.tabManager.tabs.isEmpty) + #expect(survivor.coordinator.tabManager.selectedTabId == nil) + } + + // MARK: - Database scope + + @Test("a sibling window on another database is offered for closing") + func canCloseTabsForOtherDatabasesWhenSiblingIsForeign() { + let connection = TestFixtures.makeConnection(database: "db_a") + let current = makeWindow(connection: connection) + let sibling = makeWindow(connection: connection) + defer { + current.coordinator.teardown() + sibling.coordinator.teardown() + } + + current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") + sibling.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_b") + + #expect(current.actions.activeDatabaseName == "db_a") + #expect(current.actions.canCloseTabsForOtherDatabases) + } + + @Test("nothing is offered when every sibling is on the active database") + func cannotCloseTabsForOtherDatabasesWhenAllMatch() { + let connection = TestFixtures.makeConnection(database: "db_a") + let current = makeWindow(connection: connection) + let sibling = makeWindow(connection: connection) + defer { + current.coordinator.teardown() + sibling.coordinator.teardown() + } + + current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") + sibling.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_a") + + #expect(!current.actions.canCloseTabsForOtherDatabases) + } + + @Test("another connection's window is never a database-scoped target") + func otherConnectionsAreOutOfScope() { + let connection = TestFixtures.makeConnection(database: "db_a") + let otherConnection = TestFixtures.makeConnection(database: "db_b") + let current = makeWindow(connection: connection) + let unrelated = makeWindow(connection: otherConnection) + defer { + current.coordinator.teardown() + unrelated.coordinator.teardown() + } + + current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") + unrelated.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_b") + + #expect(!current.actions.canCloseTabsForOtherDatabases) + } + + // MARK: - Enablement + + @Test("closing all tabs is offered while the window still holds a tab") + func canCloseAllTabsFollowsOpenTabs() { + let connection = TestFixtures.makeConnection(database: "db_a") + let current = makeWindow(connection: connection) + defer { current.coordinator.teardown() } + + #expect(!current.actions.canCloseAllTabs) + + current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") + + #expect(current.actions.canCloseAllTabs) + } +} diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index 520c31e77..1bd24b0a6 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -2,34 +2,46 @@ // SwitchDatabaseTests.swift // TableProTests // -// Tests for the "switch database" flow: verifies that switching databases -// (Cmd+K) does NOT create new macOS windows, and that table tabs are -// properly reset to avoid "table not found" errors in the new database. +// Tests for the "switch database" flow: switching databases (Cmd+K) does not +// create new macOS windows and does not close open tabs. Tabs carry their own +// database and coexist across databases (#1776); an earlier build wiped them +// and discarded unsaved SQL (#1669). // import Foundation -import TableProPluginKit import SwiftUI +import TableProPluginKit import Testing @testable import TablePro -// MARK: - Helpers - -/// Simulates the tab-clearing logic from switchDatabase(to:). -/// All tabs are removed to prevent stale queries from the previous database. -@MainActor -private func simulateDatabaseSwitch( - tabManager: QueryTabManager -) { - tabManager.tabs = [] - tabManager.selectedTabId = nil -} - @Suite("SwitchDatabase") +@MainActor struct SwitchDatabaseTests { + private func withConnectedCoordinator( + _ body: (MainContentCoordinator, QueryTabManager) async throws -> Void + ) async throws { + let connection = TestFixtures.makeConnection(type: .mysql, database: "db_a") + let driver = MockDatabaseDriver(connection: connection) + DatabaseManager.shared.injectSession( + ConnectionSession(connection: connection, driver: driver), + for: connection.id + ) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try await body(coordinator, tabManager) + } + @Test("openTableTab skips when table is already active tab in same database") - @MainActor func openTableTabSkipsForSameTableSameDatabase() throws { let connection = TestFixtures.makeConnection(database: "db_a") let tabManager = QueryTabManager() @@ -44,11 +56,9 @@ struct SwitchDatabaseTests { ) defer { coordinator.teardown() } - // Add a tab for "users" in "db_a" try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") let tabCountBefore = tabManager.tabs.count - // Opening "users" again in same database should be a no-op (fast path) coordinator.openTableTab("users") #expect(tabManager.tabs.count == tabCountBefore) @@ -56,42 +66,32 @@ struct SwitchDatabaseTests { // MARK: - Tab state after database switch - @Test("switchDatabase clears all table tabs") - @MainActor - func switchDatabaseClearsTableTabs() throws { - let tabManager = QueryTabManager() - try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") - - simulateDatabaseSwitch(tabManager: tabManager) - - #expect(tabManager.tabs.isEmpty) - #expect(tabManager.selectedTabId == nil) + @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.tabs.contains { $0.content.query == "SELECT NOW()" }) + #expect(coordinator.toolbarState.currentDatabase == "db_b") + } } - @Test("switchDatabase clears all query tabs") - @MainActor - func switchDatabaseClearsQueryTabs() { - let tabManager = QueryTabManager() - tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") - - simulateDatabaseSwitch(tabManager: tabManager) - - #expect(tabManager.tabs.isEmpty) - #expect(tabManager.selectedTabId == nil) - } - - @Test("switchDatabase clears mixed table and query tabs") - @MainActor - func switchDatabaseClearsMixedTabs() throws { - let tabManager = QueryTabManager() - try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") - tabManager.addTab(initialQuery: "SELECT NOW()", databaseName: "db_a") - try tabManager.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "db_a") - #expect(tabManager.tabs.count == 3) + @Test("switchDatabase leaves each tab bound to the database it was opened against") + func switchDatabaseKeepsPerTabDatabase() async throws { + try await withConnectedCoordinator { coordinator, tabManager in + try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") - simulateDatabaseSwitch(tabManager: tabManager) + _ = await coordinator.switchDatabase(to: "db_b", persist: false) - #expect(tabManager.tabs.isEmpty) - #expect(tabManager.selectedTabId == nil) + #expect(tabManager.tabs.allSatisfy { $0.tableContext.databaseName == "db_a" }) + } } } diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 74e0de487..5a7974bb1 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -141,6 +141,9 @@ See [Filtering](/features/filtering) for the filter panel itself. |--------|----------| | New tab | `Cmd+T` | | Close tab / window | `Cmd+W` | +| Close other tabs | none by default, rebindable | +| Close tabs for other databases | none by default, rebindable | +| Close all tabs | none by default, rebindable | | Reopen closed tab | `Cmd+Shift+T` | | Select tab 1-9 | `Cmd+1` through `Cmd+9` | | Previous tab | `Cmd+Shift+[` | diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index ec708ad38..34d99c5c6 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -36,9 +36,17 @@ Turn preview tabs off with **Settings > General > Tabs > Enable preview tabs** i | New query tab | `Cmd+T` or the **+** button in the tab bar | | New table tab | Double-click a table in the sidebar (single-click reuses the preview tab) | | Close tab | `Cmd+W` or the tab's close button | -| Close other tabs | Right-click the tab (native macOS menu, also has **Move Tab to New Window**) | +| Close other tabs | **File > Close Other Tabs**, or Option-click the close button of the tab you want to keep | +| Close every tab in the window | **File > Close All Tabs** | +| Close tabs belonging to other databases | **File > Close Tabs for Other Databases** | -Closing a query tab keeps its SQL in Recently Closed, so an accidental close costs nothing. TablePro asks before closing only when unsaved work would be lost, such as pending data edits, pending structure changes, or unsaved edits to a `.sql` file on disk. +The three bulk commands have no shortcut out of the box. Bind them in **Settings > Keyboard** under Navigation. + +**Close All Tabs** leaves the window open and empty rather than closing it, so the connection stays live and you can carry on with `Cmd+T` or a click in the sidebar. + +**Close Tabs for Other Databases** closes only windows whose every tab was opened against a database other than the one the connection is on now. It never touches the tab you are looking at, and it is limited to the current connection. Tabs from different databases are meant to coexist, so switching databases never closes anything on its own. On engines that switch schemas instead of databases, such as BigQuery and Oracle, the command reads **Close Tabs for Other Schemas**. + +Closing a query tab keeps its SQL in Recently Closed, so an accidental close costs nothing, including when you close a whole group at once. TablePro asks before closing only when unsaved work would be lost, such as pending data edits, pending structure changes, or unsaved edits to a `.sql` file on disk. A bulk close asks once per window that has something to lose, and cancelling stops the rest. Window tabs cannot be pinned. Pinning exists for result tabs inside a query tab (`Cmd+Option+P`, see [Keyboard Shortcuts](/features/keyboard-shortcuts)).