diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 2e574a60877a..2221efc943fe 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -66,6 +66,7 @@ extension ShortcutAction { case .triggerFlash: return ShortcutStroke(key: "h", command: true, shift: true) case .nextSidebarTab: return ShortcutStroke(key: "]", command: true, control: true) case .prevSidebarTab: return ShortcutStroke(key: "[", command: true, control: true) + case .nextSidebarTabInGroup, .prevSidebarTabInGroup: return nil case .focusHistoryBack: return ShortcutStroke(key: "[", command: true) case .focusHistoryForward: return ShortcutStroke(key: "]", command: true) case .renameTab: return ShortcutStroke(key: "r", command: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index 22be5a15dc81..a25ef20420b9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -55,6 +55,10 @@ extension ShortcutAction { case .selectSurfaceByNumber: return "Select Surface 1…9" case .nextSidebarTab: return "Next Workspace" case .prevSidebarTab: return "Previous Workspace" + case .nextSidebarTabInGroup: + return String(localized: "shortcut.nextWorkspaceInGroup.label", defaultValue: "Next Workspace in Group") + case .prevSidebarTabInGroup: + return String(localized: "shortcut.previousWorkspaceInGroup.label", defaultValue: "Previous Workspace in Group") case .moveWorkspaceUp: return String(localized: "shortcut.moveWorkspaceUp.label", defaultValue: "Move Workspace Up") case .moveWorkspaceDown: return String(localized: "shortcut.moveWorkspaceDown.label", defaultValue: "Move Workspace Down") case .focusHistoryBack: return "Focus Back" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index 373912067fe9..fdce36884275 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -17,7 +17,9 @@ extension ShortcutAction { .moveSurfaceToPaneLeft, .moveSurfaceToPaneRight, .moveSurfaceToPaneUp, .moveSurfaceToPaneDown, .selectSurfaceByNumber, - .nextSidebarTab, .prevSidebarTab, .moveWorkspaceUp, .moveWorkspaceDown, + .nextSidebarTab, .prevSidebarTab, + .nextSidebarTabInGroup, .prevSidebarTabInGroup, + .moveWorkspaceUp, .moveWorkspaceDown, .focusHistoryBack, .focusHistoryForward, .selectWorkspaceByNumber, .renameTab, .renameWorkspace, .editWorkspaceDescription, .markWorkspaceDone, .cycleWorkspaceStatus, .toggleChecklistItemComplete, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index bfdc23a27d86..b7d0c754bfa4 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -63,6 +63,10 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case selectSurfaceByNumber case nextSidebarTab case prevSidebarTab + /// Selects the next non-anchor workspace in the focused workspace group. + case nextSidebarTabInGroup + /// Selects the previous non-anchor workspace in the focused workspace group. + case prevSidebarTabInGroup /// Moves the selected workspace one position up within its pin tier. case moveWorkspaceUp /// Moves the selected workspace one position down within its pin tier. diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Cycling.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Cycling.swift new file mode 100644 index 000000000000..6cd948867541 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Cycling.swift @@ -0,0 +1,75 @@ +public import Foundation + +extension WorkspacesModel { + /// Resolves the workspace selected by a cycle action without mutating model state. + /// + /// Group-member cycling excludes the group's anchor because the sidebar renders + /// that workspace as the group header. When the focused workspace is ungrouped, + /// group-member cycling falls back to the window-wide order. + /// + /// ```swift + /// let destination = model.cycleDestination( + /// from: model.selectedTabId, + /// direction: .next, + /// scope: .focusedGroupMembers + /// ) + /// ``` + /// + /// - Parameters: + /// - currentWorkspaceId: The currently selected workspace identifier. + /// - direction: The direction in which to advance through the resolved order. + /// - scope: The workspace set that participates in the cycle. + /// - Returns: The destination workspace identifier, or `nil` when the current + /// workspace is missing or the resolved scope has no selectable workspace. + public func cycleDestination( + from currentWorkspaceId: UUID?, + direction: WorkspaceCycleDirection, + scope: WorkspaceCycleScope + ) -> UUID? { + guard let currentWorkspaceId, + let currentWorkspace = tabs.first(where: { $0.id == currentWorkspaceId }) else { + return nil + } + + let candidates: [Tab] + switch scope { + case .window: + candidates = tabs + case .focusedGroupMembers: + if let groupId = currentWorkspace.groupId, + let group = workspaceGroups.first(where: { $0.id == groupId }) { + candidates = tabs.filter { + $0.groupId == groupId && $0.id != group.anchorWorkspaceId + } + } else { + candidates = tabs + } + } + + return cycleDestination( + from: currentWorkspaceId, + direction: direction, + candidates: candidates + ) + } + + private func cycleDestination( + from currentWorkspaceId: UUID, + direction: WorkspaceCycleDirection, + candidates: [Tab] + ) -> UUID? { + guard !candidates.isEmpty else { return nil } + guard let currentIndex = candidates.firstIndex(where: { $0.id == currentWorkspaceId }) else { + return switch direction { + case .next: candidates.first?.id + case .previous: candidates.last?.id + } + } + + let destinationIndex = switch direction { + case .next: (currentIndex + 1) % candidates.count + case .previous: (currentIndex - 1 + candidates.count) % candidates.count + } + return candidates[destinationIndex].id + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceCycleDirection.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceCycleDirection.swift new file mode 100644 index 000000000000..c851a8ee1561 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceCycleDirection.swift @@ -0,0 +1,8 @@ +/// The direction in which workspace cycling advances through an ordered set. +public enum WorkspaceCycleDirection: Sendable { + /// Advances to the following workspace, wrapping to the first workspace. + case next + + /// Advances to the preceding workspace, wrapping to the last workspace. + case previous +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceCycleScope.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceCycleScope.swift new file mode 100644 index 000000000000..bacba63c6337 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Values/WorkspaceCycleScope.swift @@ -0,0 +1,8 @@ +/// The ordered workspace set used to resolve a cycle destination. +public enum WorkspaceCycleScope: Sendable { + /// Cycles through every workspace in the window. + case window + + /// Cycles through the focused group's non-anchor member workspaces. + case focusedGroupMembers +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestoredPanelTitleBoundaryTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestoredPanelTitleBoundaryTests.swift index 559ea04637bf..0a66c7efae98 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestoredPanelTitleBoundaryTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/RestoredPanelTitleBoundaryTests.swift @@ -11,10 +11,17 @@ import Testing shellState: .promptIdle ) - #expect(!boundary.shouldApply(rawTitle: seededTitle)) - #expect(boundary.observe(shellState: .commandRunning) == nil) - #expect(!boundary.shouldApply(rawTitle: seededTitle)) - #expect(boundary.shouldApply(rawTitle: "Resumed Codex session")) + let appliesSeededTitleBeforeBootstrap = boundary.shouldApply(rawTitle: seededTitle) + #expect(!appliesSeededTitleBeforeBootstrap) + + let bufferedTitle = boundary.observe(shellState: .commandRunning) + #expect(bufferedTitle == nil) + + let appliesSeededTitleDuringBootstrap = boundary.shouldApply(rawTitle: seededTitle) + #expect(!appliesSeededTitleDuringBootstrap) + + let appliesResumedAgentTitle = boundary.shouldApply(rawTitle: "Resumed Codex session") + #expect(appliesResumedAgentTitle) #expect(!boundary.isReleased) } @@ -24,10 +31,15 @@ import Testing shellState: .promptIdle ) - #expect(!boundary.shouldApply(rawTitle: "cd /tmp/cmux")) - #expect(boundary.observe(shellState: .commandRunning) == "cd /tmp/cmux") + let appliesPreexecTitle = boundary.shouldApply(rawTitle: "cd /tmp/cmux") + #expect(!appliesPreexecTitle) + + let bufferedTitle = boundary.observe(shellState: .commandRunning) + #expect(bufferedTitle == "cd /tmp/cmux") #expect(boundary.isReleased) - #expect(boundary.shouldApply(rawTitle: "/tmp/cmux")) + + let appliesReleasedTitle = boundary.shouldApply(rawTitle: "/tmp/cmux") + #expect(appliesReleasedTitle) } @Test func alreadyRunningUnseededShellStartsReleased() { @@ -36,7 +48,9 @@ import Testing shellState: .commandRunning ) + let appliesRunningCommandTitle = boundary.shouldApply(rawTitle: "Genuine running command") + #expect(boundary.isReleased) - #expect(boundary.shouldApply(rawTitle: "Genuine running command")) + #expect(appliesRunningCommandTitle) } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCycleDestinationTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCycleDestinationTests.swift new file mode 100644 index 000000000000..271aa85efade --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCycleDestinationTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing + +@testable import CmuxWorkspaces + +@MainActor +@Suite("Workspace cycle destinations") +struct WorkspaceCycleDestinationTests { + @Test("group scope wraps through members without selecting the anchor") + func groupScopeWrapsThroughMembers() { + let fixture = makeFixture() + + #expect(fixture.model.cycleDestination( + from: fixture.firstMember.id, + direction: .next, + scope: .focusedGroupMembers + ) == fixture.secondMember.id) + #expect(fixture.model.cycleDestination( + from: fixture.secondMember.id, + direction: .next, + scope: .focusedGroupMembers + ) == fixture.firstMember.id) + #expect(fixture.model.cycleDestination( + from: fixture.firstMember.id, + direction: .previous, + scope: .focusedGroupMembers + ) == fixture.secondMember.id) + } + + @Test("group anchor enters the member cycle in the requested direction") + func groupAnchorEntersMemberCycle() { + let fixture = makeFixture() + + #expect(fixture.model.cycleDestination( + from: fixture.anchor.id, + direction: .next, + scope: .focusedGroupMembers + ) == fixture.firstMember.id) + #expect(fixture.model.cycleDestination( + from: fixture.anchor.id, + direction: .previous, + scope: .focusedGroupMembers + ) == fixture.secondMember.id) + } + + @Test("ungrouped workspace falls back to the window-wide order") + func ungroupedWorkspaceFallsBackToWindowOrder() { + let fixture = makeFixture() + + #expect(fixture.model.cycleDestination( + from: fixture.ungroupedBefore.id, + direction: .next, + scope: .focusedGroupMembers + ) == fixture.anchor.id) + #expect(fixture.model.cycleDestination( + from: fixture.ungroupedBefore.id, + direction: .previous, + scope: .focusedGroupMembers + ) == fixture.ungroupedAfter.id) + } + + @Test("window scope preserves flat cycling across group boundaries") + func windowScopePreservesFlatCycling() { + let fixture = makeFixture() + + #expect(fixture.model.cycleDestination( + from: fixture.ungroupedBefore.id, + direction: .next, + scope: .window + ) == fixture.anchor.id) + #expect(fixture.model.cycleDestination( + from: fixture.secondMember.id, + direction: .next, + scope: .window + ) == fixture.ungroupedAfter.id) + } + + @Test("group with only an anchor has no member destination") + func anchorOnlyGroupHasNoDestination() { + let groupId = UUID() + let anchor = CoordinatorStubTab(groupId: groupId) + let model = WorkspacesModel() + model.tabs = [anchor] + model.workspaceGroups = [workspaceGroup( + id: groupId, + anchorWorkspaceId: anchor.id + )] + + #expect(model.cycleDestination( + from: anchor.id, + direction: .next, + scope: .focusedGroupMembers + ) == nil) + #expect(model.cycleDestination( + from: anchor.id, + direction: .previous, + scope: .focusedGroupMembers + ) == nil) + } + + private func makeFixture() -> ( + model: WorkspacesModel, + ungroupedBefore: CoordinatorStubTab, + anchor: CoordinatorStubTab, + firstMember: CoordinatorStubTab, + secondMember: CoordinatorStubTab, + ungroupedAfter: CoordinatorStubTab + ) { + let groupId = UUID() + let ungroupedBefore = CoordinatorStubTab() + let anchor = CoordinatorStubTab(groupId: groupId) + let firstMember = CoordinatorStubTab(groupId: groupId) + let secondMember = CoordinatorStubTab(groupId: groupId) + let ungroupedAfter = CoordinatorStubTab() + let model = WorkspacesModel() + model.tabs = [ + ungroupedBefore, + anchor, + firstMember, + secondMember, + ungroupedAfter, + ] + model.workspaceGroups = [workspaceGroup( + id: groupId, + anchorWorkspaceId: anchor.id + )] + return ( + model, + ungroupedBefore, + anchor, + firstMember, + secondMember, + ungroupedAfter + ) + } + + private func workspaceGroup(id: UUID, anchorWorkspaceId: UUID) -> WorkspaceGroup { + WorkspaceGroup( + id: id, + name: "Group", + isCollapsed: false, + isPinned: false, + anchorWorkspaceId: anchorWorkspaceId, + customColor: nil, + iconSymbol: nil + ) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8a0e72551675..c3359e0a6304 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -211418,6 +211418,23 @@ } } }, + "shortcut.nextWorkspaceInGroup.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Workspace in Group" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループ内の次のワークスペース" + } + } + } + }, "shortcut.openBrowser.label": { "extractionState": "manual", "localizations": { @@ -212138,6 +212155,23 @@ } } }, + "shortcut.previousWorkspaceInGroup.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous Workspace in Group" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループ内の前のワークスペース" + } + } + } + }, "shortcut.recorder.clear": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+DockShortcutRouting.swift b/Sources/AppDelegate+DockShortcutRouting.swift index f39e6fc7cf8f..fb976b56ef19 100644 --- a/Sources/AppDelegate+DockShortcutRouting.swift +++ b/Sources/AppDelegate+DockShortcutRouting.swift @@ -84,6 +84,7 @@ extension KeyboardShortcutSettings.Action { .switchRightSidebarToFeed, .switchRightSidebarToDock, .nextSidebarTab, .prevSidebarTab, + .nextSidebarTabInGroup, .prevSidebarTabInGroup, .moveWorkspaceUp, .moveWorkspaceDown, .selectWorkspaceByNumber, .renameWorkspace, .editWorkspaceDescription, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 16e9dfb48371..6c5be51a8e80 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14078,6 +14078,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredShortcut(event: event, action: .nextSidebarTabInGroup) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + cmuxDebugLog( + "ws.shortcut dir=next scope=group repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectNextTab(scope: .focusedGroupMembers) + return true + } + + if matchConfiguredShortcut(event: event, action: .prevSidebarTabInGroup) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + cmuxDebugLog( + "ws.shortcut dir=prev scope=group repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectPreviousTab(scope: .focusedGroupMembers) + return true + } + if matchConfiguredShortcut(event: event, action: .renameWorkspace) { return requestRenameWorkspaceViaCommandPalette( preferredWindow: commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 64786d2a8de8..86c3121f9eb7 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -106,6 +106,8 @@ enum KeyboardShortcutSettings { case selectSurfaceByNumber case nextSidebarTab case prevSidebarTab + case nextSidebarTabInGroup + case prevSidebarTabInGroup case moveWorkspaceUp, moveWorkspaceDown case focusHistoryBack case focusHistoryForward @@ -253,6 +255,8 @@ enum KeyboardShortcutSettings { case .selectSurfaceByNumber: return String(localized: "shortcut.selectSurfaceByNumber.label", defaultValue: "Select Surface 1…9") case .nextSidebarTab: return String(localized: "shortcut.nextWorkspace.label", defaultValue: "Next Workspace") case .prevSidebarTab: return String(localized: "shortcut.previousWorkspace.label", defaultValue: "Previous Workspace") + case .nextSidebarTabInGroup: return String(localized: "shortcut.nextWorkspaceInGroup.label", defaultValue: "Next Workspace in Group") + case .prevSidebarTabInGroup: return String(localized: "shortcut.previousWorkspaceInGroup.label", defaultValue: "Previous Workspace in Group") case .moveWorkspaceUp: return String(localized: "shortcut.moveWorkspaceUp.label", defaultValue: "Move Workspace Up") case .moveWorkspaceDown: return String(localized: "shortcut.moveWorkspaceDown.label", defaultValue: "Move Workspace Down") case .focusHistoryBack: return String(localized: "shortcut.focusHistoryBack.label", defaultValue: "Focus Back") @@ -439,6 +443,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true) + case .nextSidebarTabInGroup, .prevSidebarTabInGroup: + return .unbound case .focusHistoryBack: return StoredShortcut(key: "[", command: true, shift: false, option: false, control: false) case .focusHistoryForward: diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7447846e2d85..c68a0afbb13f 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3613,40 +3613,43 @@ class TabManager: ObservableObject { workspace.surfaceOwnershipTarget(for: surfaceOrPanelId)?.containerPanelID } - func selectNextTab() { + func selectNextTab(scope: WorkspaceCycleScope = .window) { + cycleWorkspace(direction: .next, scope: scope) + } + + func selectPreviousTab(scope: WorkspaceCycleScope = .window) { + cycleWorkspace(direction: .previous, scope: scope) + } + + private func cycleWorkspace( + direction: WorkspaceCycleDirection, + scope: WorkspaceCycleScope + ) { guard let currentId = selectedTabId, - let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return } - let nextIndex = (currentIndex + 1) % tabs.count + let destinationId = workspaces.cycleDestination( + from: currentId, + direction: direction, + scope: scope + ) else { + return + } #if DEBUG - let nextId = tabs[nextIndex].id - debugPrepareWorkspaceSwitch("next", from: currentId, to: nextId) + let directionLabel = switch direction { + case .next: "next" + case .previous: "prev" + } + debugPrepareWorkspaceSwitch(directionLabel, from: currentId, to: destinationId) #endif activateWorkspaceCycleHotWindow() selectWorkspaceId( - tabs[nextIndex].id, + destinationId, notificationDismissalContext: .explicitWorkspaceResume ) // Keyboard nav is an explicit "focus one workspace" gesture, so drop // any stale sidebar multi-selection (Shift-click range) so subsequent // batch actions don't operate on workspaces the user thought they // had unselected by moving on. - clearSidebarMultiSelection(except: tabs[nextIndex].id) - } - - func selectPreviousTab() { - guard let currentId = selectedTabId, - let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return } - let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count -#if DEBUG - let prevId = tabs[prevIndex].id - debugPrepareWorkspaceSwitch("prev", from: currentId, to: prevId) -#endif - activateWorkspaceCycleHotWindow() - selectWorkspaceId( - tabs[prevIndex].id, - notificationDismissalContext: .explicitWorkspaceResume - ) - clearSidebarMultiSelection(except: tabs[prevIndex].id) + clearSidebarMultiSelection(except: destinationId) } /// Reduce sidebar multi-selection to a single workspace (or clear if diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bc91bfa7809f..fde5ece006e5 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2566,6 +2566,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE36230000000000000001 /* WorkspaceFinderDirectoryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */; }; F0ACC0DE000000000000000B /* WorkspaceForkAgentConversationAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE000000000000000C /* WorkspaceForkAgentConversationAvailability.swift */; }; F0ACC0DE0000000000000005 /* WorkspaceForkConversationContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE0000000000000006 /* WorkspaceForkConversationContextMenuTests.swift */; }; + 9137C0019137C0019137C001 /* WorkspaceGroupCycleShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9137C0029137C0029137C002 /* WorkspaceGroupCycleShortcutTests.swift */; }; C9A57203C9A57203C9A57203 /* WorkspaceGroupMenuSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */; }; C9A57206C9A57206C9A57206 /* WorkspaceGroupMoveToMenuState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57205C9A57205C9A57205 /* WorkspaceGroupMoveToMenuState.swift */; }; C9A57401C9A57401C9A57401 /* WorkspaceGroupMoveToMenuStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */; }; @@ -5206,6 +5207,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceFinderDirectoryResolver.swift; sourceTree = ""; }; F0ACC0DE000000000000000C /* WorkspaceForkAgentConversationAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceForkAgentConversationAvailability.swift; sourceTree = ""; }; F0ACC0DE0000000000000006 /* WorkspaceForkConversationContextMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceForkConversationContextMenuTests.swift; sourceTree = ""; }; + 9137C0029137C0029137C002 /* WorkspaceGroupCycleShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupCycleShortcutTests.swift; sourceTree = ""; }; C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMenuSnapshot.swift; sourceTree = ""; }; C9A57205C9A57205C9A57205 /* WorkspaceGroupMoveToMenuState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMoveToMenuState.swift; sourceTree = ""; }; C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMoveToMenuStateTests.swift; sourceTree = ""; }; @@ -7924,6 +7926,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1FFC0DE000000000000C001 /* DiffCommentStoreTests.swift */, CC0033E15A1B2C3D4E5F0006 /* CommentsListPayloadTests.swift */, C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, + 9137C0029137C0029137C002 /* WorkspaceGroupCycleShortcutTests.swift */, 9164A0029164A0029164A002 /* WorkspaceGroupNumberedSelectionTests.swift */, C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */, FEED49850000000000000002 /* FeedEventClassificationTests.swift */, @@ -11148,6 +11151,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 281F3AEA52379AF45C5C1189 /* WorkspaceCustomSidebarPullRequestContextTests.swift in Sources */, 72DB1838A0F1B710BFB45DC1 /* WorkspaceEnvironmentTests.swift in Sources */, F0ACC0DE0000000000000005 /* WorkspaceForkConversationContextMenuTests.swift in Sources */, + 9137C0019137C0019137C001 /* WorkspaceGroupCycleShortcutTests.swift in Sources */, C9A57401C9A57401C9A57401 /* WorkspaceGroupMoveToMenuStateTests.swift in Sources */, 9164A0019164A0019164A001 /* WorkspaceGroupNumberedSelectionTests.swift in Sources */, C9A57001C9A57001C9A57001 /* WorkspaceGroupTests.swift in Sources */, diff --git a/cmuxTests/WorkspaceGroupCycleShortcutTests.swift b/cmuxTests/WorkspaceGroupCycleShortcutTests.swift new file mode 100644 index 000000000000..9113c520469d --- /dev/null +++ b/cmuxTests/WorkspaceGroupCycleShortcutTests.swift @@ -0,0 +1,127 @@ +import AppKit +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +#if DEBUG +@MainActor +@Suite("Workspace group cycle shortcuts", .serialized) +struct WorkspaceGroupCycleShortcutTests { + @Test func actionsAreVisibleAndUnboundByDefault() throws { + let actions: [KeyboardShortcutSettings.Action] = [ + .nextSidebarTabInGroup, + .prevSidebarTabInGroup, + ] + + for action in actions { + let sharedAction = try #require(ShortcutAction(rawValue: action.rawValue)) + #expect(KeyboardShortcutSettings.publicShortcutActions.contains(action)) + #expect(KeyboardShortcutSettings.settingsVisibleActions.contains(action)) + #expect(action.defaultShortcut == .unbound) + #expect(sharedAction.defaultShortcut == nil) + #expect(sharedAction.displayName == action.label) + } + } + + @Test func configuredActionsCycleMembersWithoutSelectingAnchor() throws { + let appDelegate = try #require(AppDelegate.shared) + let originalSettingsFileStore = KeyboardShortcutSettings.installIsolatedTestFileStore( + prefix: "cmux-workspace-group-cycle" + ) + KeyboardShortcutSettings.resetAll() + try """ + { + "shortcuts": { + "bindings": { + "nextSidebarTabInGroup": "ctrl+opt+cmd+j", + "prevSidebarTabInGroup": "ctrl+opt+cmd+k" + } + } + } + """.write( + to: KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing(), + atomically: true, + encoding: .utf8 + ) + KeyboardShortcutSettings.settingsFileStore.reload() + appDelegate.debugResetShortcutRoutingStateForTesting() + defer { + KeyboardShortcutSettings.resetAll() + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + appDelegate.debugResetShortcutRoutingStateForTesting() + } + + let windowId = appDelegate.createMainWindow() + defer { appDelegate.discardMainWindowWithoutClosedHistory(windowId: windowId) } + let context = try #require(appDelegate.mainWindowContexts.values.first { $0.windowId == windowId }) + let window = try #require(context.window) + let manager = context.tabManager + let ungroupedWorkspace = try #require(manager.selectedWorkspace) + let firstMember = manager.addTab(select: false) + let secondMember = manager.addTab(select: false) + let groupId = try #require(manager.createWorkspaceGroup( + name: "Grouped", + childWorkspaceIds: [firstMember.id, secondMember.id] + )) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + let anchor = try #require(manager.tabs.first { $0.id == group.anchorWorkspaceId }) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + let nextEvent = try #require(keyEvent( + key: "j", + keyCode: 38, + windowNumber: window.windowNumber + )) + let previousEvent = try #require(keyEvent( + key: "k", + keyCode: 40, + windowNumber: window.windowNumber + )) + + manager.selectWorkspace(firstMember) + #expect(appDelegate.debugHandleCustomShortcut(event: nextEvent)) + #expect(manager.selectedTabId == secondMember.id) + #expect(appDelegate.debugHandleCustomShortcut(event: nextEvent)) + #expect(manager.selectedTabId == firstMember.id) + #expect(appDelegate.debugHandleCustomShortcut(event: previousEvent)) + #expect(manager.selectedTabId == secondMember.id) + + manager.selectWorkspace(anchor) + #expect(appDelegate.debugHandleCustomShortcut(event: nextEvent)) + #expect(manager.selectedTabId == firstMember.id) + manager.selectWorkspace(anchor) + #expect(appDelegate.debugHandleCustomShortcut(event: previousEvent)) + #expect(manager.selectedTabId == secondMember.id) + + manager.selectWorkspace(ungroupedWorkspace) + #expect(appDelegate.debugHandleCustomShortcut(event: nextEvent)) + #expect(manager.selectedTabId == group.anchorWorkspaceId) + } + + private func keyEvent( + key: String, + keyCode: UInt16, + windowNumber: Int + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.control, .option, .command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) + } +} +#endif diff --git a/skills/cmux-settings/references/shortcut-actions.md b/skills/cmux-settings/references/shortcut-actions.md index c05ee8e2d3ba..9625737b3f2f 100644 --- a/skills/cmux-settings/references/shortcut-actions.md +++ b/skills/cmux-settings/references/shortcut-actions.md @@ -123,6 +123,8 @@ Values for `shortcuts.bindings.`: - `shortcuts.bindings.switchRightSidebarToDock` - `shortcuts.bindings.nextSidebarTab` - `shortcuts.bindings.prevSidebarTab` +- `shortcuts.bindings.nextSidebarTabInGroup` +- `shortcuts.bindings.prevSidebarTabInGroup` ## Browser diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index a8049da5668f..113c6d37ec7f 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -101,6 +101,24 @@ export const shortcutCategories: ShortcutCategory[] = [ }, { id: "nextSidebarTab", combos: [["⌃", "⌘", "]"]], description: { en: "Next workspace", ja: "次のワークスペース" } }, { id: "prevSidebarTab", combos: [["⌃", "⌘", "["]], description: { en: "Previous workspace", ja: "前のワークスペース" } }, + { + id: "nextSidebarTabInGroup", + combos: [], + description: { en: "Next workspace in group", ja: "グループ内の次のワークスペース" }, + note: { + en: "unbound by default; skips the group anchor and uses window-wide cycling when ungrouped", + ja: "デフォルトでは未割り当て。グループのアンカーを除いて循環し、グループに属していない場合はウインドウ全体を循環", + }, + }, + { + id: "prevSidebarTabInGroup", + combos: [], + description: { en: "Previous workspace in group", ja: "グループ内の前のワークスペース" }, + note: { + en: "unbound by default; skips the group anchor and uses window-wide cycling when ungrouped", + ja: "デフォルトでは未割り当て。グループのアンカーを除いて循環し、グループに属していない場合はウインドウ全体を循環", + }, + }, { id: "moveWorkspaceUp", combos: [["⌃", "⌥", "⌘", "["]], description: { en: "Move workspace up", ja: "ワークスペースを上へ移動" } }, { id: "moveWorkspaceDown", combos: [["⌃", "⌥", "⌘", "]"]], description: { en: "Move workspace down", ja: "ワークスペースを下へ移動" } }, { diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 9500053b1d0f..6ac2c410a5e0 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1632,6 +1632,8 @@ "selectSurfaceByNumber", "nextSidebarTab", "prevSidebarTab", + "nextSidebarTabInGroup", + "prevSidebarTabInGroup", "moveWorkspaceUp", "moveWorkspaceDown", "focusHistoryBack",