From 0f9d745a3d50d3650cba68c0c87845ab7e2609e6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:32:29 -0700 Subject: [PATCH] Add adjacent workspace actions --- Resources/Localizable.xcstrings | 68 ++++++++++ Sources/ContentView.swift | 50 +++++++ Sources/SidebarWorkspaceGroupHeaderView.swift | 17 +++ Sources/TabManager.swift | 124 +++++++++++++++++- .../VerticalTabsSidebar+WorkspaceGroups.swift | 14 ++ cmuxTests/WorkspaceGroupTests.swift | 123 ++++++++++++++++- 6 files changed, 394 insertions(+), 2 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cb80bc8799e3..532f8e9e4648 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -50081,6 +50081,40 @@ } } }, + "command.newWorkspaceAbove.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace Above" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上に新規ワークスペース" + } + } + } + }, + "command.newWorkspaceBelow.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace Below" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に新規ワークスペース" + } + } + } + }, "command.nextTabInPane.subtitle": { "extractionState": "manual", "localizations": { @@ -64002,6 +64036,40 @@ } } }, + "contextMenu.newWorkspaceAbove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace Above" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上に新規ワークスペース" + } + } + } + }, + "contextMenu.newWorkspaceBelow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Workspace Below" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下に新規ワークスペース" + } + } + } + }, "contextMenu.newWindow": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 98e428feb631..d64f93dc56ed 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6746,6 +6746,22 @@ struct ContentView: View { keywords: ["create", "new", "workspace"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWorkspaceAbove", + title: constant(String(localized: "command.newWorkspaceAbove.title", defaultValue: "New Workspace Above")), + subtitle: constant(String(localized: "command.newWorkspace.subtitle", defaultValue: "Workspace")), + keywords: ["create", "new", "workspace", "above", "before"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWorkspaceBelow", + title: constant(String(localized: "command.newWorkspaceBelow.title", defaultValue: "New Workspace Below")), + subtitle: constant(String(localized: "command.newWorkspace.subtitle", defaultValue: "Workspace")), + keywords: ["create", "new", "workspace", "below", "after"] + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.newWindow", @@ -7952,6 +7968,26 @@ struct ContentView: View { debugSource: "palette.newWorkspace" ) } + registry.register(commandId: "palette.newWorkspaceAbove") { + guard let selectedTabId = tabManager.selectedTabId else { + NSSound.beep() + return + } + guard tabManager.createWorkspaceAdjacent(to: selectedTabId, position: .above) != nil else { + NSSound.beep() + return + } + } + registry.register(commandId: "palette.newWorkspaceBelow") { + guard let selectedTabId = tabManager.selectedTabId else { + NSSound.beep() + return + } + guard tabManager.createWorkspaceAdjacent(to: selectedTabId, position: .below) != nil else { + NSSound.beep() + return + } + } registry.register(commandId: "palette.openFolder") { // Defer so the command palette dismisses before the modal sheet appears. DispatchQueue.main.async { @@ -16024,6 +16060,20 @@ struct TabItemView: View, Equatable { } .disabled(contextMenuPinState == nil) + Button(String(localized: "contextMenu.newWorkspaceAbove", defaultValue: "New Workspace Above")) { + guard tabManager.createWorkspaceAdjacent(to: tab.id, position: .above) != nil else { + NSSound.beep() + return + } + } + + Button(String(localized: "contextMenu.newWorkspaceBelow", defaultValue: "New Workspace Below")) { + guard tabManager.createWorkspaceAdjacent(to: tab.id, position: .below) != nil else { + NSSound.beep() + return + } + } + workspaceGroupContextMenuSection(targetIds: targetIds, isMulti: isMulti) if let key = renameWorkspaceShortcut.keyEquivalent { diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 9c45d697145d..b55ea3256d8c 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -58,6 +58,8 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let onToggleCollapsed: () -> Void let onFocusAnchor: () -> Void let onTapPlus: () -> Void + let onNewWorkspaceAbove: () -> Void + let onNewWorkspaceBelow: () -> Void let onRunResolvedItem: (CmuxResolvedConfigMenuAction) -> Void let onRename: () -> Void let onTogglePinned: () -> Void @@ -241,6 +243,21 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { isHovered = hovering } .contextMenu { + Button( + String( + localized: "contextMenu.newWorkspaceAbove", + defaultValue: "New Workspace Above" + ), + action: onNewWorkspaceAbove + ) + Button( + String( + localized: "contextMenu.newWorkspaceBelow", + defaultValue: "New Workspace Below" + ), + action: onNewWorkspaceBelow + ) + Divider() Button( String( localized: "workspaceGroup.contextMenu.rename", diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 39ae663431ba..ef06ae776b7c 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -126,6 +126,11 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { } } +enum WorkspaceAdjacentInsertionPosition: Sendable { + case above + case below +} + enum WorkspaceAutoReorderSettings { static let key = "workspaceAutoReorderOnNotification" static let defaultValue = true @@ -2612,11 +2617,12 @@ class TabManager: ObservableObject { select: Bool = true, eagerLoadTerminal: Bool = false, placementOverride: NewWorkspacePlacement? = nil, + sourceWorkspaceOverride: Workspace? = nil, autoWelcomeIfNeeded: Bool = true, autoRefreshMetadata: Bool = true, normalizeWorkspaceGroupsAfterInsert: Bool = true ) -> Workspace { - let sourceWorkspace = selectedWorkspace + let sourceWorkspace = sourceWorkspaceOverride ?? selectedWorkspace let capturedTabs = tabs // Snapshot the selected tab from the pinned workspace instead of rereading the // @Published selectedTabId storage after the inheritance helpers. The arm64 Nightly @@ -4273,6 +4279,122 @@ class TabManager: ObservableObject { return newWorkspace } + @discardableResult + func createWorkspaceAdjacent( + to referenceWorkspaceId: UUID, + position: WorkspaceAdjacentInsertionPosition, + select: Bool = true + ) -> Workspace? { + guard let referenceWorkspace = tabs.first(where: { $0.id == referenceWorkspaceId }) else { + return nil + } + + if let groupId = referenceWorkspace.groupId, + let group = workspaceGroups.first(where: { $0.id == groupId }) { + if group.anchorWorkspaceId == referenceWorkspaceId { + return createWorkspaceAdjacentToGroup( + groupId: groupId, + position: position, + select: select + ) + } + return createWorkspaceAdjacentWithinGroup( + to: referenceWorkspace, + groupId: groupId, + position: position, + select: select + ) + } + + let newWorkspace = addWorkspace( + select: select, + placementOverride: .end, + sourceWorkspaceOverride: referenceWorkspace + ) + reorderCreatedWorkspace( + newWorkspace.id, + adjacentTo: referenceWorkspaceId, + position: position + ) + return newWorkspace + } + + private func createWorkspaceAdjacentWithinGroup( + to referenceWorkspace: Workspace, + groupId: UUID, + position: WorkspaceAdjacentInsertionPosition, + select: Bool + ) -> Workspace? { + guard let group = workspaceGroups.first(where: { $0.id == groupId }) else { return nil } + let cwd = tabs.first(where: { $0.id == group.anchorWorkspaceId })?.currentDirectory + let newWorkspace = addWorkspace( + workingDirectory: cwd, + inheritWorkingDirectory: cwd == nil, + select: select, + placementOverride: .end, + sourceWorkspaceOverride: referenceWorkspace, + autoWelcomeIfNeeded: false + ) + assignGroup(workspaceId: newWorkspace.id, groupId: groupId) + reorderCreatedWorkspace( + newWorkspace.id, + adjacentTo: referenceWorkspace.id, + position: position + ) + expandGroupIfNeeded(groupId: groupId, select: select) + return newWorkspace + } + + private func createWorkspaceAdjacentToGroup( + groupId: UUID, + position: WorkspaceAdjacentInsertionPosition, + select: Bool + ) -> Workspace? { + guard let group = workspaceGroups.first(where: { $0.id == groupId }), + let anchorWorkspace = tabs.first(where: { $0.id == group.anchorWorkspaceId }) else { + return nil + } + let newWorkspace = addWorkspace( + select: select, + placementOverride: .end, + sourceWorkspaceOverride: anchorWorkspace + ) + let groupMemberIds = tabs.filter { $0.groupId == groupId && $0.id != newWorkspace.id }.map(\.id) + switch position { + case .above: + if let firstGroupWorkspaceId = groupMemberIds.first { + _ = reorderWorkspace(tabId: newWorkspace.id, before: firstGroupWorkspaceId) + } + case .below: + if let lastGroupWorkspaceId = groupMemberIds.last { + _ = reorderWorkspace(tabId: newWorkspace.id, after: lastGroupWorkspaceId) + } + } + return newWorkspace + } + + private func reorderCreatedWorkspace( + _ workspaceId: UUID, + adjacentTo referenceWorkspaceId: UUID, + position: WorkspaceAdjacentInsertionPosition + ) { + switch position { + case .above: + _ = reorderWorkspace(tabId: workspaceId, before: referenceWorkspaceId) + case .below: + _ = reorderWorkspace(tabId: workspaceId, after: referenceWorkspaceId) + } + } + + private func expandGroupIfNeeded(groupId: UUID, select: Bool) { + guard select, + let idx = workspaceGroups.firstIndex(where: { $0.id == groupId }), + workspaceGroups[idx].isCollapsed else { + return + } + workspaceGroups[idx].isCollapsed = false + } + /// Move an existing group member to the requested in-group slot. Called /// after `createWorkspaceInGroup` and any other path that needs to /// pin the new member relative to the group's members. diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index e11fb6b8f6b4..feecd4a9b8af 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -118,6 +118,20 @@ extension VerticalTabsSidebar { let resolved = placement ?? WorkspaceGroupNewWorkspacePlacementSettings.resolved() _ = tabManager.createWorkspaceInGroup(groupId: groupId, placement: resolved) }, + onNewWorkspaceAbove: { [weak tabManager, anchorId = group.anchorWorkspaceId] in + guard let tabManager, + tabManager.createWorkspaceAdjacent(to: anchorId, position: .above) != nil else { + NSSound.beep() + return + } + }, + onNewWorkspaceBelow: { [weak tabManager, anchorId = group.anchorWorkspaceId] in + guard let tabManager, + tabManager.createWorkspaceAdjacent(to: anchorId, position: .below) != nil else { + NSSound.beep() + return + } + }, onRunResolvedItem: { [weak tabManager, groupId = group.id] item in guard let tabManager else { return } SidebarWorkspaceGroupContextMenuRunner.run( diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index 6d0b9e7981f3..e0e486a34bd8 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -8,7 +8,7 @@ import Testing #endif @MainActor -@Suite("Workspace group model") +@Suite("Workspace group model", .serialized) struct WorkspaceGroupTests { private func makeTabManager() -> TabManager { @@ -576,6 +576,127 @@ struct WorkspaceGroupTests { ]) } + @Test func createWorkspaceAdjacentPlacesUngroupedWorkspaceAboveAndBelowReference() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + + let insertedAbove = try #require(manager.createWorkspaceAdjacent( + to: originalIds[1], + position: .above, + select: false + )) + + #expect(manager.tabs.map(\.id) == [ + originalIds[0], + insertedAbove.id, + originalIds[1], + originalIds[2], + originalIds[3], + ]) + + let insertedBelow = try #require(manager.createWorkspaceAdjacent( + to: originalIds[2], + position: .below, + select: false + )) + + #expect(manager.tabs.map(\.id) == [ + originalIds[0], + insertedAbove.id, + originalIds[1], + originalIds[2], + insertedBelow.id, + originalIds[3], + ]) + } + + @Test func createWorkspaceAdjacentPlacesGroupedMemberAboveAndBelowReferenceInsideGroup() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + + let groupId = try #require(manager.createWorkspaceGroup(name: "G", childWorkspaceIds: [ + originalIds[1], + originalIds[2], + ])) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + + let insertedAbove = try #require(manager.createWorkspaceAdjacent( + to: originalIds[2], + position: .above, + select: false + )) + + #expect(insertedAbove.groupId == groupId) + #expect(manager.tabs.filter { $0.groupId == groupId }.map(\.id) == [ + group.anchorWorkspaceId, + originalIds[1], + insertedAbove.id, + originalIds[2], + ]) + + let insertedBelow = try #require(manager.createWorkspaceAdjacent( + to: originalIds[1], + position: .below, + select: false + )) + + #expect(insertedBelow.groupId == groupId) + #expect(manager.tabs.filter { $0.groupId == groupId }.map(\.id) == [ + group.anchorWorkspaceId, + originalIds[1], + insertedBelow.id, + insertedAbove.id, + originalIds[2], + ]) + } + + @Test func createWorkspaceAdjacentToGroupHeaderPlacesOutsideWholeGroup() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + + let groupId = try #require(manager.createWorkspaceGroup(name: "G", childWorkspaceIds: [ + originalIds[1], + originalIds[2], + ])) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + + let insertedAbove = try #require(manager.createWorkspaceAdjacent( + to: group.anchorWorkspaceId, + position: .above, + select: false + )) + + #expect(insertedAbove.groupId == nil) + #expect(manager.tabs.map(\.id) == [ + originalIds[0], + insertedAbove.id, + group.anchorWorkspaceId, + originalIds[1], + originalIds[2], + originalIds[3], + ]) + + let insertedBelow = try #require(manager.createWorkspaceAdjacent( + to: group.anchorWorkspaceId, + position: .below, + select: false + )) + + #expect(insertedBelow.groupId == nil) + #expect(manager.tabs.map(\.id) == [ + originalIds[0], + insertedAbove.id, + group.anchorWorkspaceId, + originalIds[1], + originalIds[2], + insertedBelow.id, + originalIds[3], + ]) + } + @Test func addingExistingWorkspaceToGroupHonorsPlacementReference() throws { let manager = makeTabManager() manager.addWorkspace(autoWelcomeIfNeeded: false)