From 3e907555afad492a6034283a464c86daf3743cbc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 03:05:18 -0700 Subject: [PATCH 1/4] Add multi-workspace close UI regression test --- .../CloseWorkspacesConfirmDialogUITests.swift | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift diff --git a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift new file mode 100644 index 000000000000..5e01a2fd0762 --- /dev/null +++ b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift @@ -0,0 +1,182 @@ +import XCTest +import Foundation + +final class CloseWorkspacesConfirmDialogUITests: XCTestCase { + private var socketPath = "" + + override func setUp() { + super.setUp() + continueAfterFailure = false + socketPath = "/tmp/cmux-ui-test-close-workspaces-\(UUID().uuidString).sock" + try? FileManager.default.removeItem(atPath: socketPath) + } + + func testCommandPaletteCloseOtherWorkspacesShowsSingleSummaryDialog() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1" + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for close-workspaces confirmation test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)") + + XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK") + XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK") + XCTAssertTrue( + waitForWorkspaceCount(3, timeout: 5.0), + "Expected 3 workspaces before running the close-other-workspaces command. list=\(socketCommand("list_workspaces") ?? "")" + ) + XCTAssertEqual(socketCommand("select_workspace 1"), "OK") + + app.typeKey("p", modifierFlags: [.command, .shift]) + + let searchField = app.textFields["CommandPaletteSearchField"] + XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field") + searchField.click() + searchField.typeText("Close Other Workspaces") + + let resultButton = app.buttons["Close Other Workspaces"].firstMatch + if resultButton.waitForExistence(timeout: 5.0) { + resultButton.click() + } else { + app.typeKey(.return, modifierFlags: []) + } + + XCTAssertTrue( + waitForCloseWorkspacesAlert(app: app, timeout: 5.0), + "Expected a single aggregated close-workspaces alert" + ) + + clickCancelOnCloseWorkspacesAlert(app: app) + + XCTAssertFalse( + isCloseWorkspacesAlertPresent(app: app), + "Expected aggregated close-workspaces alert to dismiss after clicking Cancel" + ) + XCTAssertTrue( + waitForWorkspaceCount(3, timeout: 5.0), + "Expected all workspaces to remain after cancelling multi-close. list=\(socketCommand("list_workspaces") ?? "")" + ) + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + + private func waitForSocketPong(timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if socketCommand("ping") == "PONG" { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return socketCommand("ping") == "PONG" + } + + private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if workspaceCount() == expectedCount { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return workspaceCount() == expectedCount + } + + private func workspaceCount() -> Int { + guard let response = socketCommand("list_workspaces") else { return -1 } + if response == "No workspaces" { + return 0 + } + return response + .split(separator: "\n") + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .count + } + + private func socketCommand(_ cmd: String) -> String? { + let nc = "/usr/bin/nc" + guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: nc) + proc.arguments = ["-U", socketPath, "-w", "2"] + + let inPipe = Pipe() + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardInput = inPipe + proc.standardOutput = outPipe + proc.standardError = errPipe + + do { + try proc.run() + } catch { + return nil + } + + if let data = (cmd + "\n").data(using: .utf8) { + inPipe.fileHandleForWriting.write(data) + } + inPipe.fileHandleForWriting.closeFile() + + proc.waitUntilExit() + + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + guard let outStr = String(data: outData, encoding: .utf8) else { return nil } + return outStr.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func isCloseWorkspacesAlertPresent(app: XCUIApplication) -> Bool { + if closeWorkspacesDialog(app: app).exists { return true } + if closeWorkspacesAlert(app: app).exists { return true } + return app.staticTexts["Close workspaces?"].exists + } + + private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if isCloseWorkspacesAlertPresent(app: app) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return isCloseWorkspacesAlertPresent(app: app) + } + + private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) { + let dialog = closeWorkspacesDialog(app: app) + if dialog.exists { + dialog.buttons["Cancel"].firstMatch.click() + return + } + let alert = closeWorkspacesAlert(app: app) + if alert.exists { + alert.buttons["Cancel"].firstMatch.click() + return + } + let anyDialog = app.dialogs.firstMatch + if anyDialog.exists, anyDialog.buttons["Cancel"].exists { + anyDialog.buttons["Cancel"].firstMatch.click() + } + } + + private func closeWorkspacesDialog(app: XCUIApplication) -> XCUIElement { + app.dialogs.containing(.staticText, identifier: "Close workspaces?").firstMatch + } + + private func closeWorkspacesAlert(app: XCUIApplication) -> XCUIElement { + app.alerts.containing(.staticText, identifier: "Close workspaces?").firstMatch + } +} From f0d15b96ebfe9a22dd46c6807ab382b580cd6f6f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 03:05:31 -0700 Subject: [PATCH 2/4] Summarize multi-workspace close confirmation --- Resources/Localizable.xcstrings | 51 +++++++++++ Sources/AppDelegate.swift | 1 + Sources/ContentView.swift | 21 +---- Sources/TabManager.swift | 84 ++++++++++++++++++- Sources/cmuxApp.swift | 6 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 71 ++++++++++++++++ 6 files changed, 210 insertions(+), 24 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 54aa5c859355..75d3bc8b82f2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -27859,6 +27859,57 @@ } } }, + "dialog.closeWorkspaces.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close %1$lld workspaces and all of their panels:\n%2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 個のワークスペースと、それぞれのすべてのパネルを閉じます:\n%2$@" + } + } + } + }, + "dialog.closeWorkspaces.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close workspaces?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じますか?" + } + } + } + }, + "dialog.closeWorkspacesWindow.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のウィンドウと、その %1$lld 個のワークスペースと、それぞれのすべてのパネルを閉じます:\n%2$@" + } + } + } + }, "dialog.closeWorkspace.message": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e441d37c3390..f437c9b0871d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7741,6 +7741,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // equivalents working and avoid surprising actions while the confirmation is up. let closeConfirmationTitles = [ String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"), String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d72a1e5d203c..7b1dcb52b754 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5962,11 +5962,7 @@ struct ContentView: View { } private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) { - for workspaceId in workspaceIds { - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue } - guard allowPinned || !workspace.isPinned else { continue } - tabManager.closeWorkspaceWithConfirmation(workspace) - } + tabManager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned) } private func closeOtherSelectedWorkspaces() { @@ -5976,14 +5972,14 @@ struct ContentView: View { } private func closeSelectedWorkspacesBelow() { - guard let workspace = tabManager.selectedWorkspace, + guard tabManager.selectedWorkspace != nil, let anchorIndex = selectedWorkspaceIndex() else { return } let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id) closeWorkspaceIds(workspaceIds, allowPinned: false) } private func closeSelectedWorkspacesAbove() { - guard let workspace = tabManager.selectedWorkspace, + guard tabManager.selectedWorkspace != nil, let anchorIndex = selectedWorkspaceIndex() else { return } let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id) closeWorkspaceIds(workspaceIds, allowPinned: false) @@ -10289,16 +10285,7 @@ private struct TabItemView: View, Equatable { } private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { - let idsToClose = targetIds.filter { id in - guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false } - return allowPinned || !tab.isPinned - } - for id in idsToClose { - if let tab = tabManager.tabs.first(where: { $0.id == id }) { - tabManager.closeWorkspaceWithConfirmation(tab) - } - } - selectedTabIds.subtract(idsToClose) + tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) syncSelectionAfterMutation() } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ea1cda352649..e788b799f3f5 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -713,6 +713,7 @@ class TabManager: ObservableObject { private var workspaceCycleGeneration: UInt64 = 0 private var workspaceCycleCooldownTask: Task? private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? + var confirmCloseHandler: ((String, String, Bool) -> Bool)? #if DEBUG private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0 @@ -1455,6 +1456,27 @@ class TabManager: ObservableObject { closeWorkspaceWithConfirmation(workspace) } + func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) { + let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned) + guard !workspaces.isEmpty else { return } + guard workspaces.count > 1 else { + closeWorkspaceWithConfirmation(workspaces[0]) + return + } + + let plan = closeWorkspacesPlan(for: workspaces) + guard confirmClose( + title: plan.title, + message: plan.message, + acceptCmdD: plan.acceptCmdD + ) else { return } + + for workspace in plan.workspaces { + guard tabs.contains(where: { $0.id == workspace.id }) else { continue } + closeWorkspaceIfRunningProcess(workspace, requiresConfirmation: false) + } + } + func selectWorkspace(_ workspace: Workspace) { selectedTabId = workspace.id } @@ -1463,6 +1485,10 @@ class TabManager: ObservableObject { func selectTab(_ tab: Workspace) { selectWorkspace(tab) } private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool { + if let confirmCloseHandler { + return confirmCloseHandler(title, message, acceptCmdD) + } + let alert = NSAlert() alert.messageText = title alert.informativeText = message @@ -1490,6 +1516,13 @@ class TabManager: ObservableObject { let titles: [String] } + private struct CloseWorkspacesPlan { + let workspaces: [Workspace] + let title: String + let message: String + let acceptCmdD: Bool + } + private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? { guard let workspace = selectedWorkspace else { return nil } guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { @@ -1532,9 +1565,56 @@ class TabManager: ObservableObject { return String(localized: "tab.untitled", defaultValue: "Untitled Tab") } - private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) { + private func orderedClosableWorkspaces(_ workspaceIds: [UUID], allowPinned: Bool) -> [Workspace] { + let targetIds = Set(workspaceIds) + return tabs.compactMap { workspace in + guard targetIds.contains(workspace.id) else { return nil } + guard allowPinned || !workspace.isPinned else { return nil } + return workspace + } + } + + private func closeWorkspacesPlan(for workspaces: [Workspace]) -> CloseWorkspacesPlan { + let willCloseWindow = workspaces.count == tabs.count + let title = willCloseWindow + ? String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + : String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") + let titleLines = workspaces + .map { "• \(closeWorkspaceDisplayTitle($0.title))" } + .joined(separator: "\n") + let format = willCloseWindow + ? String( + localized: "dialog.closeWorkspacesWindow.message", + defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" + ) + : String( + localized: "dialog.closeWorkspaces.message", + defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" + ) + let message = String(format: format, locale: .current, Int64(workspaces.count), titleLines) + return CloseWorkspacesPlan( + workspaces: workspaces, + title: title, + message: message, + acceptCmdD: willCloseWindow + ) + } + + private func closeWorkspaceDisplayTitle(_ title: String?) -> String { + let collapsed = title? + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if let collapsed, !collapsed.isEmpty { + return collapsed + } + return String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") + } + + private func closeWorkspaceIfRunningProcess(_ workspace: Workspace, requiresConfirmation: Bool = true) { let willCloseWindow = tabs.count <= 1 - if workspaceNeedsConfirmClose(workspace), + if requiresConfirmation, + workspaceNeedsConfirmClose(workspace), !confirmClose( title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 96703df93109..ec768e9453c9 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -881,11 +881,7 @@ struct cmuxApp: App { in manager: TabManager, allowPinned: Bool ) { - for workspaceId in workspaceIds { - guard let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue } - guard allowPinned || !workspace.isPinned else { continue } - manager.closeWorkspaceWithConfirmation(workspace) - } + manager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned) } private func closeOtherSelectedWorkspacePeers(in manager: TabManager) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 4fbe260ee6aa..39f3cedfdee5 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5084,6 +5084,77 @@ final class TabManagerWorkspaceOwnershipTests: XCTestCase { } } +@MainActor +final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase { + func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() { + let manager = TabManager() + let second = manager.addWorkspace() + let third = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + manager.setCustomTitle(tabId: third.id, title: "Gamma") + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return true + } + + manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspaces.message", + defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close") + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, false) + XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"]) + } + + func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() { + let manager = TabManager() + let second = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return false + } + + manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspacesWindow.message", + defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1) + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, true) + XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"]) + } +} + @MainActor final class TabManagerPendingUnfocusPolicyTests: XCTestCase { func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { From 13353b40bd3a9497dea1cf0d0c1ed043d0dcdadf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 03:13:43 -0700 Subject: [PATCH 3/4] Add Cmd+Shift+W multi-close UI regression test --- .../CloseWorkspacesConfirmDialogUITests.swift | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift index 5e01a2fd0762..c6604cb51205 100644 --- a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift @@ -61,6 +61,43 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { ) } + func testCmdShiftWUsesSidebarMultiSelectionSummaryDialog() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"] = "0,1" + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for close-workspaces shortcut test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)") + + XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK") + XCTAssertTrue( + waitForWorkspaceCount(2, timeout: 5.0), + "Expected 2 workspaces before running Cmd+Shift+W. list=\(socketCommand("list_workspaces") ?? "")" + ) + + app.typeKey("w", modifierFlags: [.command, .shift]) + + XCTAssertTrue( + waitForCloseWorkspacesAlert(app: app, timeout: 5.0), + "Expected Cmd+Shift+W to use the aggregated close-workspaces alert for sidebar multi-selection" + ) + + clickCancelOnCloseWorkspacesAlert(app: app) + + XCTAssertFalse( + isCloseWorkspacesAlertPresent(app: app), + "Expected aggregated close-workspaces alert to dismiss after clicking Cancel" + ) + XCTAssertTrue( + waitForWorkspaceCount(2, timeout: 5.0), + "Expected both workspaces to remain after cancelling Cmd+Shift+W multi-close. list=\(socketCommand("list_workspaces") ?? "")" + ) + } + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { if app.wait(for: .runningForeground, timeout: timeout) { return true From c8770d106f09ee7ac81768b42a520fc7eda59e6f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 03:17:07 -0700 Subject: [PATCH 4/4] Honor sidebar multi-select for close workspace --- Sources/ContentView.swift | 46 +++++++++++++++++++ Sources/TabManager.swift | 19 ++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 37 +++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7b1dcb52b754..19e2fa2edfc8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1330,6 +1330,7 @@ struct ContentView: View { @State private var retiringWorkspaceId: UUID? @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? + @State private var didApplyUITestSidebarSelection = false @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @@ -2233,6 +2234,8 @@ struct ContentView: View { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } + syncSidebarSelectedWorkspaceIds() + applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) updateTitlebarText() // Startup recovery (#399): if session restore or a race condition leaves the @@ -2267,6 +2270,9 @@ struct ContentView: View { didRecover = true } + syncSidebarSelectedWorkspaceIds() + applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) + if didRecover { #if DEBUG dlog("startup.recovery tabCount=\(tabManager.tabs.count) selected=\(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") mounted=\(mountedWorkspaceIds.count)") @@ -2302,6 +2308,10 @@ struct ContentView: View { updateTitlebarText() }) + view = AnyView(view.onChange(of: selectedTabIds) { _ in + syncSidebarSelectedWorkspaceIds() + }) + view = AnyView(view.onChange(of: tabManager.isWorkspaceCycleHot) { _ in #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -2401,6 +2411,8 @@ struct ContentView: View { lastSidebarSelectionIndex = nil } } + syncSidebarSelectedWorkspaceIds() + applyUITestSidebarSelectionIfNeeded(tabs: tabs) }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in @@ -5985,6 +5997,40 @@ struct ContentView: View { closeWorkspaceIds(workspaceIds, allowPinned: false) } + private func syncSidebarSelectedWorkspaceIds() { + tabManager.setSidebarSelectedWorkspaceIds(selectedTabIds) + } + + private func applyUITestSidebarSelectionIfNeeded(tabs: [Workspace]) { +#if DEBUG + guard !didApplyUITestSidebarSelection else { return } + let env = ProcessInfo.processInfo.environment + guard let rawValue = env["CMUX_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + return + } + + var indices: [Int] = [] + for token in rawValue.split(separator: ",") { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard let index = Int(trimmed), index >= 0 else { return } + if !indices.contains(index) { + indices.append(index) + } + } + + guard let lastIndex = indices.last, !indices.isEmpty, lastIndex < tabs.count else { return } + + let selectedIds = Set(indices.map { tabs[$0].id }) + selectedTabIds = selectedIds + lastSidebarSelectionIndex = lastIndex + tabManager.selectWorkspace(tabs[lastIndex]) + sidebarSelectionState.selection = .tabs + didApplyUITestSidebarSelection = true +#endif + } + private func beginRenameWorkspaceFlow() { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index e788b799f3f5..2c1c416315ee 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -713,6 +713,7 @@ class TabManager: ObservableObject { private var workspaceCycleGeneration: UInt64 = 0 private var workspaceCycleCooldownTask: Task? private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? + private var sidebarSelectedWorkspaceIds: Set = [] var confirmCloseHandler: ((String, String, Bool) -> Bool)? #if DEBUG private var debugWorkspaceSwitchCounter: UInt64 = 0 @@ -1339,6 +1340,7 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) clearInitialWorkspaceGitProbe(workspaceId: workspace.id) + sidebarSelectedWorkspaceIds.remove(workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) unwireClosedBrowserTracking(for: workspace) @@ -1361,6 +1363,7 @@ class TabManager: ObservableObject { func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } clearInitialWorkspaceGitProbe(workspaceId: tabId) + sidebarSelectedWorkspaceIds.remove(tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) @@ -1442,6 +1445,11 @@ class TabManager: ObservableObject { #if DEBUG UITestRecorder.incrementInt("closeTabInvocations") #endif + let sidebarSelectionIds = orderedSidebarSelectedWorkspaceIds() + if sidebarSelectionIds.count > 1 { + closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: true) + return + } guard let selectedId = selectedTabId, let workspace = tabs.first(where: { $0.id == selectedId }) else { return } closeWorkspaceWithConfirmation(workspace) @@ -1456,6 +1464,11 @@ class TabManager: ObservableObject { closeWorkspaceWithConfirmation(workspace) } + func setSidebarSelectedWorkspaceIds(_ workspaceIds: Set) { + let existingIds = Set(tabs.map(\.id)) + sidebarSelectedWorkspaceIds = workspaceIds.intersection(existingIds) + } + func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) { let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned) guard !workspaces.isEmpty else { return } @@ -1574,6 +1587,12 @@ class TabManager: ObservableObject { } } + private func orderedSidebarSelectedWorkspaceIds() -> [UUID] { + tabs.compactMap { workspace in + sidebarSelectedWorkspaceIds.contains(workspace.id) ? workspace.id : nil + } + } + private func closeWorkspacesPlan(for workspaces: [Workspace]) -> CloseWorkspacesPlan { let willCloseWindow = workspaces.count == tabs.count let title = willCloseWindow diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 39f3cedfdee5..44985ba4de44 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5153,6 +5153,43 @@ final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase { XCTAssertEqual(prompts.first?.acceptCmdD, true) XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"]) } + + func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() { + let manager = TabManager() + let second = manager.addWorkspace() + let third = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + manager.setCustomTitle(tabId: third.id, title: "Gamma") + manager.selectWorkspace(second) + manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id]) + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return false + } + + manager.closeCurrentWorkspaceWithConfirmation() + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspaces.message", + defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog") + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, false) + XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"]) + } } @MainActor