Skip to content
Closed
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
14 changes: 7 additions & 7 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
32547 CLI/cmux.swift
21983 Sources/TerminalController.swift
32576 CLI/cmux.swift
22034 Sources/TerminalController.swift
19875 Sources/Workspace.swift
19052 Sources/ContentView.swift
17860 Sources/AppDelegate.swift
19072 Sources/ContentView.swift
17919 Sources/AppDelegate.swift
16503 Sources/GhosttyTerminalView.swift
13589 Sources/Panels/BrowserPanel.swift
11756 cmuxTests/AppDelegateShortcutRoutingTests.swift
9914 Sources/TabManager.swift
9995 Sources/TabManager.swift
8494 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
7737 Sources/Panels/BrowserPanelView.swift
7214 cmuxTests/WorkspaceUnitTests.swift
Expand Down Expand Up @@ -38,7 +38,7 @@
2654 Sources/Panels/CmuxWebView.swift
2545 cmuxTests/WorkspaceManualUnreadTests.swift
2513 cmuxTests/CommandPaletteSearchEngineTests.swift
2491 Sources/KeyboardShortcutSettings.swift
2500 Sources/KeyboardShortcutSettings.swift
2378 Sources/TerminalNotificationStore.swift
2327 cmuxTests/CJKIMEInputTests.swift
2290 Sources/FileExplorerView.swift
Expand Down Expand Up @@ -82,6 +82,7 @@
1006 cmuxTests/CmuxSSHURLRequestTests.swift
1000 cmuxTests/CmuxTopSnapshotScopeTests.swift
969 cmuxUITests/TerminalCmdClickUITests.swift
959 cmuxTests/WorkspaceGroupTests.swift
949 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
947 Sources/TerminalNotificationPolicy.swift
945 Sources/SessionIndexRegisteredAgents.swift
Expand All @@ -97,7 +98,6 @@
866 Sources/CommandPalette/CommandPaletteSettingsToggle.swift
863 Sources/Panels/TerminalPanel.swift
856 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
853 cmuxTests/WorkspaceGroupTests.swift
846 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
842 Sources/Panels/MarkdownWebRenderer.swift
Expand Down
31 changes: 30 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7215,7 +7215,7 @@ struct CMUXCLI {
windowOverride: String?
) throws {
guard let sub = commandArgs.first?.lowercased() else {
throw CLIError(message: "workspace-group requires a subcommand. Try: list, create, ungroup, delete, rename, collapse, expand, pin, unpin, add, remove, set-anchor, new-workspace, set-color, set-icon, move, focus")
throw CLIError(message: "workspace-group requires a subcommand. Try: list, create, from-workspace, ungroup, delete, rename, collapse, expand, pin, unpin, add, remove, set-anchor, new-workspace, set-color, set-icon, move, focus")
}
let rest = Array(commandArgs.dropFirst())
var params: [String: Any] = [:]
Expand Down Expand Up @@ -7277,6 +7277,29 @@ struct CMUXCLI {
print("OK")
}

case "from-workspace":
// Turn an existing workspace into a group: the workspace itself
// becomes the anchor/header (no fresh anchor is created). Inverse
// of `ungroup`.
let (nameOpt, rem0) = parseOption(rest, name: "--name")
let (wsOpt, rem1) = parseOption(rem0, name: "--workspace")
// Strip --window before scanning for a positional so a `--window
// <value>` pair never gets parsed as the workspace id.
let (_, rem2) = parseOption(rem1, name: "--window")
guard let wsId = wsOpt ?? rem2.first(where: { !$0.hasPrefix("--") }) else {
throw CLIError(message: "from-workspace requires --workspace <id>")
}
params["workspace_id"] = wsId
if let nameOpt { params["name"] = nameOpt }
let response = try client.sendV2(method: "workspace.group.from_workspace", params: params)
if jsonOutput {
print(jsonString(formatIDs(response, mode: idFormat)))
} else if let group = response["group"] as? [String: Any] {
print("OK \(textHandle(group, idFormat: idFormat))")
} else {
print("OK")
}

case "ungroup":
params["group_id"] = try resolveGroupId(in: rest)
let resp = try client.sendV2(method: "workspace.group.ungroup", params: params)
Expand Down Expand Up @@ -13529,6 +13552,12 @@ struct CMUXCLI {
create [--name <name>] [--cwd <path>] [--from <id>,<id>...]
Defaults --from to the active sidebar
selection / caller workspace when omitted.
from-workspace --workspace <ws> [--name <name>]
Turn an existing workspace into a group:
the workspace itself becomes the anchor
(no fresh anchor is created). Inverse of
`ungroup`. Defaults the group name to the
workspace's title.
ungroup <group> Dissolve a group, preserving all members
delete <group> Delete a group AND close every workspace
inside it. Destructive. Use `ungroup` to
Expand Down
34 changes: 34 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -166856,6 +166856,23 @@
}
}
},
"shortcut.renameFocusedWorkspaceGroup.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rename Focused Workspace’s Group"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フォーカス中のワークスペースのグループ名を変更"
}
}
}
},
"shortcut.toggleFocusedWorkspaceGroupCollapsed.label": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -167015,6 +167032,23 @@
}
}
},
"workspaceGroup.error.workspaceAlreadyGrouped": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Workspace is already in a group; remove it first"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このワークスペースはすでにグループに属しています。先に削除してください"
}
}
}
},
"workspaceGroup.autoName.numbered": {
"extractionState": "manual",
"localizations": {
Expand Down
81 changes: 70 additions & 11 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13193,8 +13193,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if matchConfiguredShortcut(event: event, action: .groupSelectedWorkspaces) {
// Only consume the event when grouping actually happened; otherwise
// fall through so the dispatcher reaches the later
// `.toggleReactGrab` check (default ⌘⇧G collides with React Grab
// and grouping returns false when no multi-selection exists).
// `.toggleReactGrab` check (default ⌘⇧G collides with React Grab).
// Grouping returns false when React Grab would act on the current
// focus or there's nothing to group, so React Grab still wins on
// browser-bearing workspaces.
if handleGroupSelectedWorkspacesShortcut(
preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
) {
Expand All @@ -13213,6 +13215,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}

if matchConfiguredShortcut(event: event, action: .renameFocusedWorkspaceGroup) {
// Only consume when the focused workspace is actually in a group.
// Otherwise fall through (the default chord is shared with
// commandPalettePrevious, which is handled earlier while the
// palette is open).
if handleRenameFocusedWorkspaceGroupShortcut(
preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
) {
return true
}
}

if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) {
#if DEBUG
cmuxDebugLog(
Expand Down Expand Up @@ -14506,6 +14520,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}

@discardableResult
func handleRenameFocusedWorkspaceGroupShortcut(preferredWindow: NSWindow? = nil) -> Bool {
let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow
let resolvedTabManager: TabManager? = contextForMainWindow(targetWindow)?.tabManager ?? self.tabManager
guard let tabManager = resolvedTabManager else { return false }
guard let focusedId = tabManager.selectedTabId,
let groupId = tabManager.tabs.first(where: { $0.id == focusedId })?.groupId,
let group = tabManager.workspaceGroups.first(where: { $0.id == groupId }) else {
// Focused workspace isn't in a group — let the chord propagate.
return false
}
presentSidebarWorkspaceGroupRenamePrompt(
tabManager: tabManager,
groupId: group.id,
currentName: group.name
)
return true
}

@discardableResult
func handleGroupSelectedWorkspacesShortcut(preferredWindow: NSWindow? = nil) -> Bool {
// Resolve the TabManager for the preferred/key/main window first so
Expand All @@ -14522,15 +14555,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let orderedSelectedIds: [UUID] = selectedSet.isEmpty
? []
: tabManager.tabs.compactMap { selectedSet.contains($0.id) ? $0.id : nil }
// Only consume the shortcut when there's an explicit sidebar
// multi-selection. Anything ≤ 1 falls through so ⌘⇧G keeps working as
// React Grab's default in browser/terminal contexts. A single-tab
// group can still be created via right-click → New Group from
// Workspace. `sidebarSelectedWorkspaceIds` is normally synced to the
// focused workspace (clearSidebarMultiSelection sets it to a
// singleton after keyboard nav), so the singleton case must be
// treated the same as "no selection."
guard orderedSelectedIds.count >= 2 else { return false }
// Two or more sidebar-selected workspaces → group them under a fresh
// anchor (the original multi-select behavior below).
//
// A single focused workspace (or no explicit multi-selection) → turn
// that workspace itself into a group in place, but only when React
// Grab would not act on the current focus. React Grab keeps ⌘⇧G on
// browser-bearing workspaces; in a plain terminal the chord otherwise
// just beeps, so we use it to promote the focused workspace.
// `sidebarSelectedWorkspaceIds` is normally synced to the focused
// workspace (a singleton after keyboard nav), so ≤ 1 is treated as
// "no multi-selection."
guard orderedSelectedIds.count >= 2 else {
// Single focused workspace. React Grab keeps ⌘⇧G in browser
// contexts; everything below is terminal-context only.
guard !tabManager.reactGrabWouldHandleCurrentFocus(),
let focusedId = tabManager.selectedTabId,
let focused = tabManager.tabs.first(where: { $0.id == focusedId }) else {
return false
}
// Ungrouped → turn it into a single-member group.
if focused.groupId == nil {
return tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: focusedId) != nil
}
// Already grouped: ⌘⇧G toggles OFF only when this workspace is the
// anchor of a SOLO group (itself, no other members), so the chord
// round-trips create<->ungroup. A workspace inside a multi-member
// group (its anchor or a child) is left untouched — consume the
// chord so it doesn't beep or fall through to React Grab.
if let group = tabManager.workspaceGroups.first(where: { $0.id == focused.groupId }),
group.anchorWorkspaceId == focusedId,
tabManager.tabs.filter({ $0.groupId == group.id }).count == 1 {
tabManager.ungroupWorkspaceGroup(groupId: group.id)
}
return true
}
let candidateIds: [UUID] = orderedSelectedIds
// Match the workspace context-menu eligibility filter so the shortcut
// doesn't silently create an anchor-only group when every selected
Expand Down
20 changes: 20 additions & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9772,6 +9772,13 @@ struct ContentView: View {
switch target.kind {
case .workspace(let workspaceId):
tabManager.setCustomTitle(tabId: workspaceId, title: normalizedName)
// A group anchor's displayed name IS its group's name, so renaming
// a "group workspace" must rename the group too or the header
// wouldn't change. Skip on clear (groups can't be unnamed).
if let normalizedName,
let anchoredGroup = tabManager.workspaceGroups.first(where: { $0.anchorWorkspaceId == workspaceId }) {
tabManager.renameWorkspaceGroup(groupId: anchoredGroup.id, name: normalizedName)
Comment on lines +9778 to +9780

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 Use a shared rename path for group anchors

When a promoted workspace is the group anchor, this special-case only fixes the command-palette rename flow. Other rename entrypoints still call setCustomTitle directly (for example socket/CLI workspace.rename in Sources/TerminalController.swift:5446 and the workspace rename alert in Sources/AppDelegate.swift:12436), but anchor display is sourced from group.name (TabManager.resolvedWorkspaceDisplayTitle), so those renames report success while the sidebar header/window title keep the old group name. Please move this anchor-aware behavior into a shared TabManager action used by every workspace rename path.

Useful? React with 👍 / 👎.

}
case .tab(let workspaceId, let panelId):
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
NSSound.beep()
Expand Down Expand Up @@ -12370,6 +12377,18 @@ struct VerticalTabsSidebar: View {
// drag mutations at 60fps invalidate only the rows/overlays that
// read them, never this sidebar body. See SidebarDragState and
// https://github.com/manaflow-ai/cmux/issues/2586.
// Force a clean row rebuild whenever the SET of group anchors changes
// (a workspace becoming, or ceasing to be, a group header). A sidebar
// row that switches in place between a `.workspace` row and a
// `.groupHeader` is mis-diffed by LazyVStack: turning the focused
// workspace into a single-member group left the header invisible, and
// ungrouping left a stale header. Keying on the anchor SET (sorted, so
// it ignores reorders) flips identity on group create/ungroup/anchor
// changes only, so neither drag-reorder nor rename triggers a rebuild.
let groupAnchorSignature = renderContext.workspaceGroups
.map { $0.anchorWorkspaceId.uuidString }
.sorted()
.joined(separator: ",")
let rows = LazyVStack(spacing: tabRowSpacing) {
ForEach(renderItems, id: \.id) { item in
switch item {
Expand All @@ -12391,6 +12410,7 @@ struct VerticalTabsSidebar: View {
}
.padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.id(groupAnchorSignature)

// Gate ONLY the per-row frame-anchor *reader* (the virtualization-defeating
// work) behind the drag-active check, and keep the Bonsplit drop-capture
Expand Down
9 changes: 9 additions & 0 deletions Sources/KeyboardShortcutSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ enum KeyboardShortcutSettings {
case closeWorkspace
case groupSelectedWorkspaces
case toggleFocusedWorkspaceGroupCollapsed
case renameFocusedWorkspaceGroup
case reopenClosedBrowserPanel
case newSurface
case toggleTerminalCopyMode
Expand Down Expand Up @@ -207,6 +208,7 @@ enum KeyboardShortcutSettings {
case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace")
case .groupSelectedWorkspaces: return String(localized: "shortcut.groupSelectedWorkspaces.label", defaultValue: "Group Selected Workspaces")
case .toggleFocusedWorkspaceGroupCollapsed: return String(localized: "shortcut.toggleFocusedWorkspaceGroupCollapsed.label", defaultValue: "Toggle Focused Workspace's Group Collapse")
case .renameFocusedWorkspaceGroup: return String(localized: "shortcut.renameFocusedWorkspaceGroup.label", defaultValue: "Rename Focused Workspace's Group")
case .reopenClosedBrowserPanel: return String(localized: "menu.history.reopenLastClosed", defaultValue: "Reopen Last Closed")
case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface")
case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode")
Expand Down Expand Up @@ -366,6 +368,13 @@ enum KeyboardShortcutSettings {
// mnemonic. No-ops gracefully when the focused workspace
// isn't in a group.
return StoredShortcut(key: ".", command: true, shift: false, option: false, control: true)
case .renameFocusedWorkspaceGroup:
// Cmd+Shift+P. Shares the chord with commandPalettePrevious,
// but that only acts while the command palette is OPEN (it's
// in-palette navigation, dispatched earlier), so this fires
// only when the palette is closed. No-ops gracefully when the
// focused workspace isn't in a group. Rebind in Settings.
return StoredShortcut(key: "p", command: true, shift: true, option: false, control: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rename group shortcut blocked

Medium Severity

The new renameFocusedWorkspaceGroup action uses the same default chord as commandPalette (⌘⇧P). Shortcut dispatch handles commandPalette first and always consumes that keypress, so the group-rename handler never runs with factory defaults despite docs describing ⌘⇧P for renaming a focused workspace’s group.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7bcf7fd. Configure here.

case .reopenClosedBrowserPanel:
return StoredShortcut(key: "t", command: true, shift: true, option: false, control: false)
case .focusLeft:
Expand Down
Loading
Loading