Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect acceptCmdD for multi-workspace close alerts

Including dialog.closeWorkspaces.title in closeConfirmationTitles causes handleCustomShortcut to route Cmd+D to the alert's Close button unconditionally (the matchShortcut(... "d") branch), even when the alert did not opt into Cmd+D. For non-window multi-workspace closes, closeWorkspacesPlan sets acceptCmdD to false, so pressing Cmd+D on that new summary dialog can unexpectedly confirm a destructive close instead of doing nothing.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot Mar 13, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Gate Cmd+D handling on the dialog’s acceptCmdD intent instead of title matching. Including the multi-workspace title here allows Cmd+D to confirm non-window multi-workspace closes even when that confirmation flow explicitly disables Cmd+D.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 7744:

<comment>Gate Cmd+D handling on the dialog’s `acceptCmdD` intent instead of title matching. Including the multi-workspace title here allows Cmd+D to confirm non-window multi-workspace closes even when that confirmation flow explicitly disables Cmd+D.</comment>

<file context>
@@ -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?"),
</file context>
Fix with Cubic

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?"),
Expand Down
67 changes: 50 additions & 17 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,7 @@ struct ContentView: View {
@State private var retiringWorkspaceId: UUID?
@State private var workspaceHandoffGeneration: UInt64 = 0
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
@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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -5962,11 +5974,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() {
Expand All @@ -5976,19 +5984,53 @@ 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)
}

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 })

@cubic-dev-ai cubic-dev-ai Bot Mar 13, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validate every parsed workspace index before mapping into tabs to avoid out-of-bounds crashes in the UI-test hook.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ContentView.swift, line 6025:

<comment>Validate every parsed workspace index before mapping into `tabs` to avoid out-of-bounds crashes in the UI-test hook.</comment>

<file context>
@@ -5985,6 +5997,40 @@ struct ContentView: View {
+
+        guard let lastIndex = indices.last, !indices.isEmpty, lastIndex < tabs.count else { return }
+
+        let selectedIds = Set(indices.map { tabs[$0].id })
+        selectedTabIds = selectedIds
+        lastSidebarSelectionIndex = lastIndex
</file context>
Fix with Cubic

Comment on lines +6023 to +6025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate all sidebar indices before mapping selected IDs

applyUITestSidebarSelectionIfNeeded only checks indices.last against tabs.count, but then indexes every parsed value (tabs[$0]). A value like CMUX_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES=0,99,1 passes the current guard when there are 2 tabs and then crashes on tabs[99], which can take down DEBUG/UI-test runs from a malformed env setting. Please validate every parsed index is in range before building selectedIds.

Useful? React with 👍 / 👎.

selectedTabIds = selectedIds
Comment on lines +6004 to +6026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate all parsed UI-test indices before subscripting tabs.

Line 6023 only validates indices.last, but Line 6025 subscripts every parsed index. An unsorted value like "3,0" can still crash with out-of-bounds access.

💡 Proposed fix
-        guard let lastIndex = indices.last, !indices.isEmpty, lastIndex < tabs.count else { return }
+        guard !indices.isEmpty else { return }
+        guard indices.allSatisfy({ $0 < tabs.count }) else { return }
+        guard let lastIndex = indices.last else { return }

         let selectedIds = Set(indices.map { tabs[$0].id })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 6004 - 6026, In
applyUITestSidebarSelectionIfNeeded, the code currently only validates
indices.last before using indices to subscript tabs which can crash for unsorted
values like "3,0"; update the logic to validate every parsed index (each element
of indices) is within 0..<tabs.count (and >= 0) before mapping to tabs, or
alternatively filter out-of-range indices and deduplicate after validation, then
build selectedIds from the safe indices and assign to selectedTabIds; reference
indices, tabs, selectedIds and selectedTabIds when making the checks and
early-return on any invalid input.

lastSidebarSelectionIndex = lastIndex
tabManager.selectWorkspace(tabs[lastIndex])
sidebarSelectionState.selection = .tabs
didApplyUITestSidebarSelection = true
#endif
}

private func beginRenameWorkspaceFlow() {
guard let workspace = tabManager.selectedWorkspace else {
NSSound.beep()
Expand Down Expand Up @@ -10289,16 +10331,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()
}

Expand Down
103 changes: 101 additions & 2 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,8 @@ class TabManager: ObservableObject {
private var workspaceCycleGeneration: UInt64 = 0
private var workspaceCycleCooldownTask: Task<Void, Never>?
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
private var sidebarSelectedWorkspaceIds: Set<UUID> = []
var confirmCloseHandler: ((String, String, Bool) -> Bool)?
#if DEBUG
private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0
Expand Down Expand Up @@ -1338,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)
Expand All @@ -1360,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)
Expand Down Expand Up @@ -1441,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)
Expand All @@ -1455,6 +1464,32 @@ class TabManager: ObservableObject {
closeWorkspaceWithConfirmation(workspace)
}

func setSidebarSelectedWorkspaceIds(_ workspaceIds: Set<UUID>) {
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 }
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
}
Expand All @@ -1463,6 +1498,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
Expand Down Expand Up @@ -1490,6 +1529,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 {
Expand Down Expand Up @@ -1532,9 +1578,62 @@ 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 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
? 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."),
Expand Down
6 changes: 1 addition & 5 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading