diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7b7f7925b71a..920952672f09 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,15 +1,15 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative 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 @@ -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 @@ -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 @@ -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 diff --git a/CLI/cmux.swift b/CLI/cmux.swift index dc9c5a17d79c..669aa0a92be3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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] = [:] @@ -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 + // ` 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 ") + } + 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) @@ -13529,6 +13552,12 @@ struct CMUXCLI { create [--name ] [--cwd ] [--from ,...] Defaults --from to the active sidebar selection / caller workspace when omitted. + from-workspace --workspace [--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 Dissolve a group, preserving all members delete Delete a group AND close every workspace inside it. Destructive. Use `ungroup` to diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cb80bc8799e3..a508971ab674 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { @@ -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": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 14213ca1c040..0ccaa6d607c4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 ) { @@ -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( @@ -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 @@ -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 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 98e428feb631..6df5db533fab 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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) + } case .tab(let workspaceId, let panelId): guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { NSSound.beep() @@ -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 { @@ -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 diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 53bc68ec069b..39cdd8e3ebd5 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -107,6 +107,7 @@ enum KeyboardShortcutSettings { case closeWorkspace case groupSelectedWorkspaces case toggleFocusedWorkspaceGroupCollapsed + case renameFocusedWorkspaceGroup case reopenClosedBrowserPanel case newSurface case toggleTerminalCopyMode @@ -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") @@ -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) case .reopenClosedBrowserPanel: return StoredShortcut(key: "t", command: true, shift: true, option: false, control: false) case .focusLeft: diff --git a/Sources/TabItemView+WorkspaceGroups.swift b/Sources/TabItemView+WorkspaceGroups.swift index 8c5d6e308636..c46ace4ab7c7 100644 --- a/Sources/TabItemView+WorkspaceGroups.swift +++ b/Sources/TabItemView+WorkspaceGroups.swift @@ -23,24 +23,37 @@ extension TabItemView { }() let hasAnyGroupedTarget = eligibleTargets.contains { $0.groupId != nil } - let groupSelectedShortcut = KeyboardShortcutSettings.shortcut(for: .groupSelectedWorkspaces) - let groupSelectedLabel = isMulti - ? String( + if isMulti { + // Multi-select grouping creates a fresh anchor above the + // selection (the documented `create` contract). ⌘⇧G drives + // this same path. + let groupSelectedShortcut = KeyboardShortcutSettings.shortcut(for: .groupSelectedWorkspaces) + let groupSelectedLabel = String( localized: "contextMenu.workspaceGroup.newFromSelection", defaultValue: "New Group from Selection" ) - : String( - localized: "contextMenu.workspaceGroup.newFromWorkspace", - defaultValue: "New Group from Workspace" - ) - if let key = groupSelectedShortcut.keyEquivalent { - Button(groupSelectedLabel) { - promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + if let key = groupSelectedShortcut.keyEquivalent { + Button(groupSelectedLabel) { + promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + } + .keyboardShortcut(key, modifiers: groupSelectedShortcut.eventModifiers) + } else { + Button(groupSelectedLabel) { + promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + } } - .keyboardShortcut(key, modifiers: groupSelectedShortcut.eventModifiers) - } else { - Button(groupSelectedLabel) { - promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + } else if let target = eligibleTargets.first, target.groupId == nil { + // Single ungrouped workspace: turn it into a group by making + // the workspace itself the anchor (no phantom empty anchor). + // Hidden for workspaces already in a group — those use + // "Move to Group" / "Remove from Group" below. + Button( + String( + localized: "contextMenu.workspaceGroup.newFromWorkspace", + defaultValue: "New Group from Workspace" + ) + ) { + promptWorkspaceGroupFromWorkspace(workspaceId: target.id) } } @@ -80,4 +93,8 @@ extension TabItemView { guard !workspaceIds.isEmpty else { return } tabManager.createWorkspaceGroup(name: "", childWorkspaceIds: workspaceIds) } + + func promptWorkspaceGroupFromWorkspace(workspaceId: UUID) { + tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: workspaceId) + } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 195ff4b383c8..07e3c520e07e 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4233,6 +4233,69 @@ class TabManager: ObservableObject { return group.id } + /// Turn an existing workspace into a group by promoting it to be the + /// anchor (the sidebar header) of a brand-new single-member group. + /// + /// Unlike `createWorkspaceGroup`, this does NOT synthesize a fresh anchor + /// workspace: the passed workspace becomes the group header in place, so + /// no extra workspace is created and the row keeps its position. The exact + /// inverse is `ungroupWorkspaceGroup`, which dissolves the group and + /// returns the (former) anchor to a regular ungrouped workspace at the + /// same sidebar spot — so workspace → group → ungroup round-trips back to + /// the original single workspace. + /// + /// No-op (returns nil) when the workspace doesn't exist or is already in a + /// group; grouped workspaces use `addWorkspaceToGroup` / + /// `removeWorkspaceFromGroup` instead. + @discardableResult + func makeWorkspaceGroupFromWorkspace( + anchorWorkspaceId: UUID, + name: String = "" + ) -> UUID? { + guard let anchorTab = tabs.first(where: { $0.id == anchorWorkspaceId }), + anchorTab.groupId == nil else { return nil } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + // Inherit the workspace's own display name so "turn THIS workspace + // into a group" keeps its identity in the header instead of an + // anonymous "Group N". Mirrors the workspace display-name precedence + // (customTitle → title). Reads only — never mutates the workspace's + // title, so Ungroup restores the original row name. Falls back to the + // auto group name only when the workspace has no title at all. + let resolvedName: String + if !trimmedName.isEmpty { + resolvedName = trimmedName + } else { + let custom = anchorTab.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let title = anchorTab.title.trimmingCharacters(in: .whitespacesAndNewlines) + let inherited = custom.isEmpty ? title : custom + resolvedName = inherited.isEmpty ? nextAutoWorkspaceGroupName() : inherited + } + + // Inherit the workspace's pinned state so a pinned workspace stays in + // the pinned sidebar tier (group tier is read from `group.isPinned`, + // not the anchor's `Workspace.isPinned`) and Ungroup restores it as a + // pinned row. + let group = WorkspaceGroup( + id: UUID(), + name: resolvedName, + isCollapsed: false, + isPinned: anchorTab.isPinned, + anchorWorkspaceId: anchorWorkspaceId, + customColor: nil, + iconSymbol: nil + ) + workspaceGroups.append(group) + assignGroup(workspaceId: anchorWorkspaceId, groupId: group.id) + // The anchor is already at its sidebar position; normalize keeps it + // there (`sidebarTopLevelWorkspaceIds` maps the now-grouped workspace + // back to its anchor's slot) while syncing the workspaceGroups order + // to the tabs order. + normalizeWorkspaceGroupContiguity() + postWorkspaceOrderDidChange(movedWorkspaceIds: [anchorWorkspaceId]) + return group.id + } + /// Create a brand-new workspace inheriting the anchor's cwd, attach it /// to the group, and position it within the group's tabs[] range per /// `placement`. Returns the new workspace. @@ -6214,9 +6277,13 @@ class TabManager: ObservableObject { } @discardableResult - func toggleReactGrabFromCurrentFocus() -> Bool { - guard let workspace = selectedWorkspace else { return false } - + /// Resolve the React Grab shortcut route for the currently-focused + /// workspace, if any. Read-only; used both to decide whether ⌘⇧G should + /// defer to React Grab and to actually run it. + private func reactGrabShortcutRouteForCurrentFocus() + -> (workspace: Workspace, route: ReactGrabShortcutRoute, browserPanel: BrowserPanel)? + { + guard let workspace = selectedWorkspace else { return nil } let snapshots = workspace.panels.values.map { panel in ReactGrabShortcutPanelSnapshot( id: panel.id, @@ -6226,8 +6293,22 @@ class TabManager: ObservableObject { } guard let route = resolveReactGrabShortcutRoute(panels: snapshots), let browserPanel = workspace.browserPanel(for: route.browserPanelId) else { - return false + return nil } + return (workspace, route, browserPanel) + } + + /// True when ⌘⇧G would activate React Grab for the current focus (a + /// browser is focused, or a single browser panel is reachable from the + /// focused terminal). The "turn this workspace into a group" shortcut + /// defers to React Grab in that case so React Grab keeps ⌘⇧G on + /// browser-bearing workspaces. + func reactGrabWouldHandleCurrentFocus() -> Bool { + reactGrabShortcutRouteForCurrentFocus() != nil + } + + func toggleReactGrabFromCurrentFocus() -> Bool { + guard let (workspace, route, browserPanel) = reactGrabShortcutRouteForCurrentFocus() else { return false } if let returnTerminalPanelId = route.returnTerminalPanelId { browserPanel.armReactGrabRoundTrip(returnTo: returnTerminalPanelId) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 701b2acb362a..17e5762a823e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1957,6 +1957,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceGroupList(params: params)) case "workspace.group.create": return v2Result(id: id, self.v2WorkspaceGroupCreate(params: params)) + case "workspace.group.from_workspace": + return v2Result(id: id, self.v2WorkspaceGroupFromWorkspace(params: params)) case "workspace.group.ungroup": return v2Result(id: id, self.v2WorkspaceGroupUngroup(params: params)) case "workspace.group.delete": @@ -2462,6 +2464,7 @@ class TerminalController { "workspace.rename", "workspace.group.list", "workspace.group.create", + "workspace.group.from_workspace", "workspace.group.ungroup", "workspace.group.delete", "workspace.group.rename", @@ -5024,6 +5027,54 @@ class TerminalController { ]) } + /// Turn an existing workspace into a group: the workspace itself becomes + /// the new group's anchor (no fresh anchor is synthesized, unlike + /// `workspace.group.create`). The inverse is `workspace.group.ungroup`. + /// Not a focus-intent method, so the active workspace is left unchanged. + private func v2WorkspaceGroupFromWorkspace(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let wsId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let name = (params["name"] as? String) ?? "" + // Distinguish "no such workspace" from "already grouped" so scripts + // get an actionable error instead of a bare not-created. + let knownState: (exists: Bool, grouped: Bool) = v2MainSync { + guard let tab = tabManager.tabs.first(where: { $0.id == wsId }) else { + return (false, false) + } + return (true, tab.groupId != nil) + } + guard knownState.exists else { + return .err(code: "not_found", message: "Workspace not found in target window", data: [ + "workspace_id": wsId.uuidString + ]) + } + guard !knownState.grouped else { + return .err( + code: "invalid_state", + message: String( + localized: "workspaceGroup.error.workspaceAlreadyGrouped", + defaultValue: "Workspace is already in a group; remove it first" + ), + data: ["workspace_id": wsId.uuidString] + ) + } + var createdGroupId: UUID? + v2MainSync { + createdGroupId = tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: wsId, name: name) + } + guard let gid = createdGroupId, + let group = v2MainSync({ tabManager.workspaceGroups.first(where: { $0.id == gid }) }) else { + return .err(code: "not_created", message: "Group was not created", data: nil) + } + return .ok([ + "group": v2MainSync { v2WorkspaceGroupPayload(group, tabManager: tabManager) } + ]) + } + private func v2WorkspaceGroupUngroup(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index 6d0b9e7981f3..556c9f286450 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -850,4 +850,110 @@ struct WorkspaceGroupTests { #expect(manager.resolvedWorkspaceDisplayTitle(for: member) == memberTitle) } + + // Turning a single workspace into a group promotes that workspace to be + // the anchor (no fresh anchor is synthesized), keeps it at the same + // sidebar position, inherits its title as the group name, and ungroup is + // the exact inverse: the same single workspace at the same spot. + @Test func makeWorkspaceGroupFromWorkspacePromotesInPlaceAndRoundTrips() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + #expect(originalIds.count == 3) + let targetId = originalIds[1] + let target = try #require(manager.tabs.first { $0.id == targetId }) + target.title = "api-server" + let countBefore = manager.tabs.count + + let groupId = try #require(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: targetId)) + + // No phantom anchor workspace was created. + #expect(manager.tabs.count == countBefore) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + // The workspace itself is the anchor and the sole member. + #expect(group.anchorWorkspaceId == targetId) + #expect(target.groupId == groupId) + #expect(manager.tabs.filter { $0.groupId == groupId }.map(\.id) == [targetId]) + // Position is unchanged. + #expect(manager.tabs.map(\.id) == originalIds) + // The header inherits the workspace's title rather than "Group N". + #expect(group.name == "api-server") + #expect(manager.resolvedWorkspaceDisplayTitle(for: target) == "api-server") + + // Ungroup returns the anchor to a regular ungrouped workspace in place. + manager.ungroupWorkspaceGroup(groupId: groupId) + #expect(manager.workspaceGroups.contains { $0.id == groupId } == false) + #expect(target.groupId == nil) + #expect(manager.tabs.count == countBefore) + #expect(manager.tabs.map(\.id) == originalIds) + #expect(manager.resolvedWorkspaceDisplayTitle(for: target) == "api-server") + } + + // Promote is for ungrouped workspaces only. A workspace that already + // belongs to a group must use add/remove, so the call is a no-op. + @Test func makeWorkspaceGroupFromWorkspaceNoOpsForGroupedWorkspace() throws { + let manager = makeTabManager() + let memberId = manager.tabs[1].id + let groupId = try #require( + manager.createWorkspaceGroup(name: "Group 1", childWorkspaceIds: [memberId]) + ) + let groupCountBefore = manager.workspaceGroups.count + let tabCountBefore = manager.tabs.count + + #expect(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: memberId) == nil) + #expect(manager.workspaceGroups.count == groupCountBefore) + #expect(manager.tabs.count == tabCountBefore) + #expect(manager.tabs.first { $0.id == memberId }?.groupId == groupId) + } + + // A pinned workspace turned into a group must stay in the pinned sidebar + // tier (group tier reads `group.isPinned`, not the anchor's own pin), so + // it does not get demoted below other pinned rows. Guards the + // `isPinned: anchorTab.isPinned` inheritance. + @Test func makeWorkspaceGroupFromWorkspaceKeepsPinnedWorkspaceInPinnedTier() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let ids = manager.tabs.map(\.id) + #expect(ids.count == 4) + // Pin the first two so they form the pinned tier, in order. Promoting + // the FIRST pinned workspace without inheriting its pin would demote + // it below the second pinned workspace (pinned-first reordering). + manager.tabs[0].isPinned = true + manager.tabs[1].isPinned = true + + let groupId = try #require(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: ids[0])) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + + #expect(group.isPinned) + #expect(manager.tabs.map(\.id) == ids) + + // Ungroup restores the anchor as a still-pinned row in the same spot. + manager.ungroupWorkspaceGroup(groupId: groupId) + #expect(manager.tabs.first { $0.id == ids[0] }?.isPinned == true) + #expect(manager.tabs.map(\.id) == ids) + } + + // A promoted single-workspace group has its anchor as the only member, so + // it renders as a header with no child rows. Sanity-check that the anchor + // is excluded from the rendered workspace rows (it is the header). + @Test func singleWorkspaceGroupRendersOnlyAsHeader() throws { + let manager = makeTabManager() + let targetId = manager.tabs[0].id + + let groupId = try #require(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: targetId)) + let groupsById = Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) + let items = SidebarWorkspaceRenderItem.renderItems(tabs: manager.tabs, groupsById: groupsById) + + let headerGroupIds: [UUID] = items.compactMap { + if case let .groupHeader(group, _) = $0 { return group.id } + return nil + } + let workspaceRowIds: [UUID] = items.compactMap { + if case let .workspace(workspace) = $0 { return workspace.id } + return nil + } + #expect(headerGroupIds.contains(groupId)) + #expect(workspaceRowIds.contains(targetId) == false) + } } diff --git a/docs/workspace-groups.md b/docs/workspace-groups.md index 56dae9ebeb13..eec2e15a387b 100644 --- a/docs/workspace-groups.md +++ b/docs/workspace-groups.md @@ -8,9 +8,11 @@ Workspace groups let you nest workspaces into collapsible named sections in the Every group is owned by exactly one workspace called the **anchor**. The group header in the sidebar IS the anchor's representation — there is no separate row for it. Clicking the header name area focuses the anchor's panels. Clicking the chevron toggles collapse. -Anchors are always brand new when a group is created. They are never promoted from an existing workspace. The anchor's working directory is inherited from the first selected workspace (when grouping a selection) or from the active workspace (when creating via the CLI without `--cwd`). +When you create a group from a **selection** (or via `create`), the anchor is brand new: a fresh workspace is inserted above the selected members, inheriting its working directory from the first selected workspace (or from the active workspace when creating via the CLI without `--cwd`). -Closing the anchor workspace **dissolves the group**: every other member loses its `groupId` and stays in the tabs list as an ungrouped workspace. Nothing is closed besides the anchor itself. The app shows a confirm dialog with a "Don't ask again" toggle before this happens. +When you **turn a single workspace into a group**, that existing workspace becomes the anchor itself: no fresh workspace is inserted, the row keeps its position, and the group name defaults to the workspace's own title. This is the exact inverse of ungrouping, so workspace → group → ungroup round-trips back to the same single workspace. + +Closing the anchor workspace **dissolves the group**: every other member loses its `groupId` and stays in the tabs list as an ungrouped workspace. Nothing is closed besides the anchor itself. The app shows a confirm dialog with a "Don't ask again" toggle before this happens. Ungrouping a single-workspace group simply turns the anchor back into a regular workspace at the same spot. ### Group identity @@ -28,13 +30,19 @@ The sidebar layout, top to bottom: ### From the keyboard (`⌘⇧G`) -Select two or more workspaces in the sidebar, press `⌘⇧G`. A fresh anchor workspace is inserted above the selection; all selected workspaces become children. The group is auto-named `Group 1`, `Group 2`, … (rename anytime via the header context menu). `⌘⇧G` collides with React Grab's default; the group handler only consumes the chord when there is an explicit sidebar multi-selection of at least two workspaces, so React Grab still fires in single-selection and browser/terminal contexts. Rebind in Settings → Keyboard if you'd rather the two not share a key. +Select two or more workspaces in the sidebar, press `⌘⇧G`. A fresh anchor workspace is inserted above the selection; all selected workspaces become children. The group is auto-named `Group 1`, `Group 2`, … (rename anytime via the header context menu). + +With a single focused workspace (no multi-selection), `⌘⇧G` **toggles** that workspace into and out of a group in place: the first press turns the workspace into a single-member group (the workspace becomes the anchor, named after itself), and pressing `⌘⇧G` again on that solo group ungroups it back into a normal workspace. A workspace that is part of a multi-member group (its anchor or a child) is left untouched, so the toggle never dissolves a group that has other members. `⌘⇧G` collides with React Grab's default, so the group handler defers to React Grab whenever React Grab would act on the current focus (a browser is focused, or a single browser panel is reachable from the focused terminal). Rebind in Settings → Keyboard if you'd rather the two not share a key. + +You can also turn a single workspace into a group from the workspace context menu's **New Group from Workspace** entry, which works regardless of focus. + +### Renaming from the keyboard -Single-tab groups are not created from the shortcut. Use the workspace context menu's **New Group from Workspace** entry for that. +When the focused workspace is in a group, `⌘⇧P` opens the group rename prompt (it shares the chord with the command palette's previous-selection navigation, which only applies while the palette is open). `⌘⇧R` renames the focused workspace; when that workspace is a **group anchor**, it renames both the workspace and its group, since the group name is what the header displays. Both are rebindable in Settings → Keyboard. ### From a workspace context menu -Right-click any workspace in the sidebar, choose **New Group from Workspace** (or **New Group from Selection** when multiple workspaces are selected). Same auto-naming behavior as the shortcut. +Right-click an ungrouped workspace in the sidebar and choose **New Group from Workspace** to turn it into a group (the workspace becomes the anchor; the group takes the workspace's name). With multiple workspaces selected, the entry becomes **New Group from Selection**, which inserts a fresh auto-named anchor above them. Workspaces already in a group show **Move to Group** / **Remove from Group** instead. ### From the group header context menu @@ -55,6 +63,7 @@ All group operations are scriptable via `cmux workspace-group `. The ```bash cmux workspace-group list [--json] cmux workspace-group create --name "manaflow" [--cwd ~/projects/manaflow] [--from ,] +cmux workspace-group from-workspace --workspace [--name "manaflow"] cmux workspace-group ungroup cmux workspace-group delete # destructive: closes every member workspace cmux workspace-group rename --name "new name" @@ -68,7 +77,7 @@ cmux workspace-group set-anchor --group --workspace cmux workspace-group new-workspace [--placement afterCurrent|top|end] ``` -`create` returns a group handle (`workspace_group:N` by default). Pass `--json` for the full structured payload. +`create` and `from-workspace` return a group handle (`workspace_group:N` by default). Pass `--json` for the full structured payload. ### Examples @@ -78,6 +87,12 @@ Group the three currently selected workspaces under a name: cmux workspace-group create --name manaflow ``` +Turn an existing workspace into a group (the workspace becomes the anchor): + +```bash +cmux workspace-group from-workspace --workspace workspace:2 +``` + Spin up a new workspace inside an existing group (e.g. wired to a worktree script): ```bash diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 5a0d13c5e3cd..63f8448e36da 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1132,6 +1132,7 @@ "closeWorkspace", "groupSelectedWorkspaces", "toggleFocusedWorkspaceGroupCollapsed", + "renameFocusedWorkspaceGroup", "reopenClosedBrowserPanel", "newSurface", "toggleTerminalCopyMode",