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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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() {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<CoordinatorStubTab>()
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<CoordinatorStubTab>,
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<CoordinatorStubTab>()
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
)
}
}
34 changes: 34 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions Sources/AppDelegate+DockShortcutRouting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ extension KeyboardShortcutSettings.Action {
.switchRightSidebarToFeed,
.switchRightSidebarToDock,
.nextSidebarTab, .prevSidebarTab,
.nextSidebarTabInGroup, .prevSidebarTabInGroup,
.moveWorkspaceUp, .moveWorkspaceDown,
.selectWorkspaceByNumber,
.renameWorkspace, .editWorkspaceDescription,
Expand Down
Loading
Loading