diff --git a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasModel.swift b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasModel.swift index f4d1b0e7aef4..7638f525f531 100644 --- a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasModel.swift +++ b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasModel.swift @@ -131,6 +131,35 @@ public final class CanvasModel { revision &+= 1 } + /// Reorders a panel within its current pane by a relative offset. + /// + /// The destination is clamped to the pane's tab bounds. Reordering at an + /// edge succeeds without changing the layout, matching adjacent-move + /// semantics used by the split layout. + /// + /// - Parameters: + /// - panelId: The panel to move. + /// - offset: The relative final-position offset. + /// - Returns: Whether the panel belongs to a canvas pane. + @discardableResult + public func reorderPanel(_ panelId: UUID, by offset: Int) -> Bool { + let panel = CanvasPanelID(rawValue: panelId) + guard let paneID = layout.pane(containing: panel), + let panelIds = layout.panelIds(in: paneID), + let currentIndex = panelIds.firstIndex(of: panel), + !panelIds.isEmpty else { return false } + let destinationIndex = min( + max(currentIndex + offset, panelIds.startIndex), + panelIds.index(before: panelIds.endIndex) + ) + guard destinationIndex != currentIndex else { return true } + + layout.removePanel(panel) + layout.addPanel(panel, toPane: paneID, at: destinationIndex, select: true) + revision &+= 1 + return true + } + /// Moves `panelId` into the pane hosting `targetPanelId` (a join). The /// source pane disappears when it loses its last tab. /// - Returns: Whether the join happened. diff --git a/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasModelTests.swift b/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasModelTests.swift index 593c5805869d..6b7d6f61aa8f 100644 --- a/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasModelTests.swift +++ b/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasModelTests.swift @@ -89,6 +89,27 @@ struct CanvasModelTests { #expect(model.revision != before) } + @Test func reorderPanelClampsExtremeOffsetsWithoutOverflow() { + let model = makeModel() + let a = UUID() + let b = UUID() + let c = UUID() + model.restoreFrames([ + (id: a, frame: CGRect(x: 0, y: 0, width: 300, height: 200)), + (id: b, frame: CGRect(x: 400, y: 0, width: 300, height: 200)), + (id: c, frame: CGRect(x: 800, y: 0, width: 300, height: 200)), + ]) + #expect(model.joinPanel(b, withPaneContaining: a)) + #expect(model.joinPanel(c, withPaneContaining: a)) + let paneID = model.paneID(containing: a)! + + #expect(model.reorderPanel(b, by: .max)) + #expect(model.layout.panelIds(in: paneID)?.map(\.rawValue) == [a, c, b]) + + #expect(model.reorderPanel(b, by: .min)) + #expect(model.layout.panelIds(in: paneID)?.map(\.rawValue) == [b, a, c]) + } + @Test func joinAndBreakRoundTrip() { let model = makeModel() let a = UUID() diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index baab8ec7b193..6adcb71fb6e8 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -109,8 +109,12 @@ extension ShortcutAction { return nil case .nextSurface: return ShortcutStroke(key: "]", command: true, shift: true) case .prevSurface: return ShortcutStroke(key: "[", command: true, shift: true) + case .moveSurfaceLeft: return ShortcutStroke(key: "[", command: true, shift: true, option: true) + case .moveSurfaceRight: return ShortcutStroke(key: "]", command: true, shift: true, option: true) case .selectSurfaceByNumber: return ShortcutStroke(key: "1", control: true) case .selectWorkspaceByNumber: return ShortcutStroke(key: "1", command: true) + case .moveWorkspaceUp: return ShortcutStroke(key: "[", command: true, option: true, control: true) + case .moveWorkspaceDown: return ShortcutStroke(key: "]", command: true, option: true, control: true) case .newSurface: return ShortcutStroke(key: "t", command: true) case .toggleTerminalCopyMode: return ShortcutStroke(key: "m", command: true, shift: true) case .focusTextBoxInput: return ShortcutStroke(key: "a", command: true, shift: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift new file mode 100644 index 000000000000..93a065abe52c --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -0,0 +1,26 @@ +extension ShortcutAction { + /// Logical grouping used for sectioning the shortcuts pane. + public enum Group: String, CaseIterable, Sendable, Hashable { + /// Application-wide actions. + case app + /// Workspace lifecycle and notification actions. + case workspace + /// Workspace and surface navigation actions. + case navigation + /// Pane layout and focus actions. + case panes + /// Browser, viewer, and find actions. + case browser + + /// The English section title used by shortcut catalog consumers. + public var title: String { + switch self { + case .app: return "App" + case .workspace: return "Workspace" + case .navigation: return "Navigation" + case .panes: return "Panes" + case .browser: return "Browser & Find" + } + } + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index a61b45149f04..e7ef8c571f76 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -44,9 +44,17 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod // MARK: Navigation case nextSurface case prevSurface + /// Moves the selected surface one position left. + case moveSurfaceLeft + /// Moves the selected surface one position right. + case moveSurfaceRight case selectSurfaceByNumber case nextSidebarTab case prevSidebarTab + /// Moves the selected workspace one position up within its pin tier. + case moveWorkspaceUp + /// Moves the selected workspace one position down within its pin tier. + case moveWorkspaceDown case focusHistoryBack case focusHistoryForward case selectWorkspaceByNumber @@ -165,25 +173,6 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod } extension ShortcutAction { - /// Logical grouping used for sectioning the shortcuts pane. - public enum Group: String, CaseIterable, Sendable, Hashable { - case app - case workspace - case navigation - case panes - case browser - - public var title: String { - switch self { - case .app: return "App" - case .workspace: return "Workspace" - case .navigation: return "Navigation" - case .panes: return "Panes" - case .browser: return "Browser & Find" - } - } - } - /// Which group this action belongs to in the settings pane. public var group: Group { switch self { @@ -197,8 +186,8 @@ extension ShortcutAction { .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock, .triggerFlash: return .workspace - case .nextSurface, .prevSurface, .selectSurfaceByNumber, .nextSidebarTab, - .prevSidebarTab, .focusHistoryBack, .focusHistoryForward, + case .nextSurface, .prevSurface, .moveSurfaceLeft, .moveSurfaceRight, .selectSurfaceByNumber, + .nextSidebarTab, .prevSidebarTab, .moveWorkspaceUp, .moveWorkspaceDown, .focusHistoryBack, .focusHistoryForward, .selectWorkspaceByNumber, .renameTab, .renameWorkspace, .editWorkspaceDescription, .markWorkspaceDone, .cycleWorkspaceStatus, .toggleChecklistItemComplete, .closeTab, .closeOtherTabsInPane, .closeWorkspace, .newWorkspaceGroup, .groupSelectedWorkspaces, .toggleFocusedWorkspaceGroupCollapsed, @@ -371,9 +360,13 @@ extension ShortcutAction { case .triggerFlash: return "Flash Focused Panel" case .nextSurface: return "Next Surface" case .prevSurface: return "Previous Surface" + case .moveSurfaceLeft: return String(localized: "shortcut.moveSurfaceLeft.label", defaultValue: "Move Surface Left") + case .moveSurfaceRight: return String(localized: "shortcut.moveSurfaceRight.label", defaultValue: "Move Surface Right") case .selectSurfaceByNumber: return "Select Surface 1…9" case .nextSidebarTab: return "Next Workspace" case .prevSidebarTab: return "Previous Workspace" + 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" case .focusHistoryForward: return "Focus Forward" case .selectWorkspaceByNumber: return "Select Workspace 1…9" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index eedc49c3f8ff..296bf29e55aa 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -191942,6 +191942,74 @@ } } }, + "shortcut.moveSurfaceLeft.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface Left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを左へ移動" + } + } + } + }, + "shortcut.moveSurfaceRight.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを右へ移動" + } + } + } + }, + "shortcut.moveWorkspaceDown.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Workspace Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを下へ移動" + } + } + } + }, + "shortcut.moveWorkspaceUp.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Workspace Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを上へ移動" + } + } + } + }, "shortcut.newBrowserWorkspace.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+AdjacentNavigationShortcut.swift b/Sources/AppDelegate+AdjacentNavigationShortcut.swift new file mode 100644 index 000000000000..790b36fc803a --- /dev/null +++ b/Sources/AppDelegate+AdjacentNavigationShortcut.swift @@ -0,0 +1,33 @@ +import AppKit + +extension AppDelegate { + /// Routes adjacent surface navigation and surface/workspace reordering through + /// the main-window context selected for the key event. + func handleAdjacentNavigationShortcut(event: NSEvent) -> Bool { + if matchConfiguredShortcut(event: event, action: .nextSurface) { + (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectNextSurface() + return true + } + if matchConfiguredShortcut(event: event, action: .prevSurface) { + (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectPreviousSurface() + return true + } + if matchConfiguredShortcut(event: event, action: .moveSurfaceLeft) { + (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectedWorkspace?.moveSelectedSurface(by: -1) + return true + } + if matchConfiguredShortcut(event: event, action: .moveSurfaceRight) { + (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectedWorkspace?.moveSelectedSurface(by: 1) + return true + } + if matchConfiguredShortcut(event: event, action: .moveWorkspaceUp) { + (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: -1) + return true + } + if matchConfiguredShortcut(event: event, action: .moveWorkspaceDown) { + (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: 1) + return true + } + return false + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8740c6c09a56..f0ad78b0864c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13550,15 +13550,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ - if matchConfiguredShortcut(event: event, action: .nextSurface) { - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectNextSurface() - return true - } - if matchConfiguredShortcut(event: event, action: .prevSurface) { - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectPreviousSurface() - return true - } + if handleAdjacentNavigationShortcut(event: event) { return true } if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index da7ad39963ce..7e5ed04a540d 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -50,6 +50,10 @@ extension ContentView { return .nextSidebarTab case "palette.previousWorkspace": return .prevSidebarTab + case "palette.moveWorkspaceUp": + return .moveWorkspaceUp + case "palette.moveWorkspaceDown": + return .moveWorkspaceDown case "palette.nextTabInPane": return .nextSurface case "palette.previousTabInPane": diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 003a17121720..038ec075adf5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7758,10 +7758,10 @@ struct ContentView: View { tabManager.selectPreviousTab() } registry.register(commandId: "palette.moveWorkspaceUp") { - moveSelectedWorkspace(by: -1) + tabManager.moveSelectedWorkspace(by: -1) } registry.register(commandId: "palette.moveWorkspaceDown") { - moveSelectedWorkspace(by: 1) + tabManager.moveSelectedWorkspace(by: 1) } registry.register(commandId: "palette.moveWorkspaceToTop") { guard let workspace = tabManager.selectedWorkspace else { @@ -9069,15 +9069,6 @@ struct ContentView: View { return tabManager.tabs.firstIndex { $0.id == workspace.id } } - private func moveSelectedWorkspace(by delta: Int) { - guard let workspace = tabManager.selectedWorkspace, - let currentIndex = selectedWorkspaceIndex() else { return } - let targetIndex = currentIndex + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) - tabManager.selectWorkspace(workspace) - } - private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) { tabManager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned) } @@ -13982,9 +13973,7 @@ struct TabItemView: View, Equatable { } func moveBy(_ delta: Int) { - let targetIndex = index + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } + guard tabManager.reorderWorkspace(tabId: tab.id, by: delta) else { return } selectedTabIds = [tab.id] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } tabManager.selectTab(tab) diff --git a/Sources/KeyboardShortcutSettings+ActionVisibility.swift b/Sources/KeyboardShortcutSettings+ActionVisibility.swift new file mode 100644 index 000000000000..2689ec7d8001 --- /dev/null +++ b/Sources/KeyboardShortcutSettings+ActionVisibility.swift @@ -0,0 +1,15 @@ +extension KeyboardShortcutSettings.Action { + /// Whether this action is part of the public, user-configurable shortcut catalog. + var isPublicShortcutAction: Bool { + switch self { + case .switchRightSidebarToFiles, + .switchRightSidebarToFind, + .switchRightSidebarToSessions, + .switchRightSidebarToFeed, + .switchRightSidebarToDock: + return false + default: + return true + } + } +} diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 3ed7667956d0..f8ed3a845ba9 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -99,9 +99,11 @@ enum KeyboardShortcutSettings { // Navigation case nextSurface case prevSurface + case moveSurfaceLeft, moveSurfaceRight case selectSurfaceByNumber case nextSidebarTab case prevSidebarTab + case moveWorkspaceUp, moveWorkspaceDown case focusHistoryBack case focusHistoryForward case selectWorkspaceByNumber @@ -228,9 +230,13 @@ enum KeyboardShortcutSettings { case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel") case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface") case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface") + case .moveSurfaceLeft: return String(localized: "shortcut.moveSurfaceLeft.label", defaultValue: "Move Surface Left") + case .moveSurfaceRight: return String(localized: "shortcut.moveSurfaceRight.label", defaultValue: "Move Surface Right") 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 .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") case .focusHistoryForward: return String(localized: "shortcut.focusHistoryForward.label", defaultValue: "Focus Forward") case .selectWorkspaceByNumber: return String(localized: "shortcut.selectWorkspaceByNumber.label", defaultValue: "Select Workspace 1…9") @@ -322,19 +328,6 @@ enum KeyboardShortcutSettings { var defaultsKey: String { "shortcut.\(rawValue)" } - var isPublicShortcutAction: Bool { - switch self { - case .switchRightSidebarToFiles, - .switchRightSidebarToFind, - .switchRightSidebarToSessions, - .switchRightSidebarToFeed, - .switchRightSidebarToDock: - return false - default: - return true - } - } - var defaultShortcut: StoredShortcut { switch self { case .openSettings: @@ -492,6 +485,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false) case .prevSurface: return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false) + case .moveSurfaceLeft: return StoredShortcut(key: "[", command: true, shift: true, option: true, control: false) + case .moveSurfaceRight: return StoredShortcut(key: "]", command: true, shift: true, option: true, control: false) case .selectSurfaceByNumber: return StoredShortcut(key: "1", command: false, shift: false, option: false, control: true) case .newSurface: @@ -513,6 +508,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "k", command: true, shift: true, option: false, control: false) case .selectWorkspaceByNumber: return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) + case .moveWorkspaceUp: return StoredShortcut(key: "[", command: true, shift: false, option: true, control: true) + case .moveWorkspaceDown: return StoredShortcut(key: "]", command: true, shift: false, option: true, control: true) case .toggleRightSidebar: return StoredShortcut(key: "b", command: true, shift: false, option: true, control: false) case .fileExplorerOpenSelection: diff --git a/Sources/TabManager+AdjacentWorkspaceReordering.swift b/Sources/TabManager+AdjacentWorkspaceReordering.swift new file mode 100644 index 000000000000..63a0810ea479 --- /dev/null +++ b/Sources/TabManager+AdjacentWorkspaceReordering.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Shared adjacent workspace-reorder entrypoints for shortcuts, menus, and automation. +extension TabManager { + /// Reorders one workspace by a relative offset. The existing coordinator + /// clamps the result to the workspace's pinned or unpinned tier. + @discardableResult + func reorderWorkspace(tabId: UUID, by offset: Int) -> Bool { + guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } + return reorderWorkspace(tabId: tabId, toIndex: currentIndex + offset) + } + + /// Reorders the selected workspace while preserving its selection. + @discardableResult + func moveSelectedWorkspace(by offset: Int) -> Bool { + guard let workspace = selectedWorkspace, + reorderWorkspace(tabId: workspace.id, by: offset) else { return false } + selectWorkspace(workspace) + return true + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2a00da8abbd7..90ce535c148b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4254,19 +4254,11 @@ class TerminalController { finish(["description": NSNull()]) case "move_up": - guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return - } - _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: max(currentIndex - 1, 0)) + _ = tabManager.reorderWorkspace(tabId: workspace.id, by: -1) finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) case "move_down": - guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return - } - _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1)) + _ = tabManager.reorderWorkspace(tabId: workspace.id, by: 1) finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) case "move_top": diff --git a/Sources/Workspace+SurfaceNavigation.swift b/Sources/Workspace+SurfaceNavigation.swift index 65d1fa38af29..16a183282f48 100644 --- a/Sources/Workspace+SurfaceNavigation.swift +++ b/Sources/Workspace+SurfaceNavigation.swift @@ -66,6 +66,42 @@ extension Workspace { } } + /// Moves the selected surface within its focused split or Canvas pane + /// without wrapping. + @discardableResult + func moveSelectedSurface(by offset: Int) -> Bool { + if layoutMode == .canvas { + guard let focusedPanelId else { return false } + return reorderSurface(panelId: focusedPanelId, by: offset) + } + guard let paneId = bonsplitController.focusedPaneId, + let selectedTab = bonsplitController.selectedTab(inPane: paneId), + let panelId = panelIdFromSurfaceId(selectedTab.id) else { return false } + return reorderSurface(panelId: panelId, by: offset) + } + + /// Reorders one surface by a relative final-position offset in the + /// current layout's authoritative tab model. + @discardableResult + func reorderSurface(panelId: UUID, by offset: Int) -> Bool { + if layoutMode == .canvas { + let previousRevision = canvasModel.revision + guard canvasModel.reorderPanel(panelId, by: offset) else { return false } + if canvasModel.revision != previousRevision { + canvasModel.viewport?.modelDidChangeExternally(animated: false) + } + return true + } + guard let paneId = paneId(forPanelId: panelId), + let tabId = surfaceIdFromPanelId(panelId) else { return false } + let tabs = bonsplitController.tabs(inPane: paneId) + guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }), !tabs.isEmpty else { return false } + let finalIndex = min(max(currentIndex + offset, tabs.startIndex), tabs.index(before: tabs.endIndex)) + guard finalIndex != currentIndex else { return true } + let insertionIndex = finalIndex > currentIndex ? finalIndex + 1 : finalIndex + return reorderSurface(panelId: panelId, toIndex: insertionIndex) + } + /// Select a surface by index in the currently focused split pane, or in /// workspace Canvas order when Canvas layout is active. func selectSurface(at index: Int) { diff --git a/Sources/cmuxApp+WorkspaceCommandHelpers.swift b/Sources/cmuxApp+WorkspaceCommandHelpers.swift new file mode 100644 index 000000000000..901c561e1faa --- /dev/null +++ b/Sources/cmuxApp+WorkspaceCommandHelpers.swift @@ -0,0 +1,8 @@ +import Foundation + +extension cmuxApp { + /// Resolves a workspace's current sidebar position for command enablement. + func selectedWorkspaceIndex(in manager: TabManager, workspaceId: UUID) -> Int? { + manager.tabs.firstIndex { $0.id == workspaceId } + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 25931c92f11d..00eede80e8ce 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -916,6 +916,12 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: menuShortcut(for: .prevSurface)) { activeTabManager.selectPreviousSurface() } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceLeft.label", defaultValue: "Move Surface Left"), shortcut: menuShortcut(for: .moveSurfaceLeft)) { + activeTabManager.selectedWorkspace?.moveSelectedSurface(by: -1) + } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceRight.label", defaultValue: "Move Surface Right"), shortcut: menuShortcut(for: .moveSurfaceRight)) { + activeTabManager.selectedWorkspace?.moveSelectedSurface(by: 1) + } splitCommandButton(title: String(localized: "menu.view.back", defaultValue: "Back"), shortcut: menuShortcut(for: .browserBack)) { activeTabManager.focusedBrowserPanel?.goBack() @@ -987,6 +993,12 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: menuShortcut(for: .prevSidebarTab)) { activeTabManager.selectPreviousTab() } + splitCommandButton(title: String(localized: "shortcut.moveWorkspaceUp.label", defaultValue: "Move Workspace Up"), shortcut: menuShortcut(for: .moveWorkspaceUp)) { + activeTabManager.moveSelectedWorkspace(by: -1) + } + splitCommandButton(title: String(localized: "shortcut.moveWorkspaceDown.label", defaultValue: "Move Workspace Down"), shortcut: menuShortcut(for: .moveWorkspaceDown)) { + activeTabManager.moveSelectedWorkspace(by: 1) + } splitCommandButton(title: String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…"), shortcut: menuShortcut(for: .renameWorkspace)) { _ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette() @@ -1177,10 +1189,6 @@ struct cmuxApp: App { _ = tabManager.createBrowserSplit(direction: direction) } - private func selectedWorkspaceIndex(in manager: TabManager, workspaceId: UUID) -> Int? { - manager.tabs.firstIndex { $0.id == workspaceId } - } - private func selectedWorkspaceWindowMoveTargets(in manager: TabManager) -> [AppDelegate.WindowMoveTarget] { let referenceWindowId = AppDelegate.shared?.windowId(for: manager) return AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] @@ -1197,15 +1205,6 @@ struct cmuxApp: App { manager.clearCustomTitle(tabId: workspace.id) } - private func moveSelectedWorkspace(in manager: TabManager, by delta: Int) { - guard let workspace = manager.selectedWorkspace, - let currentIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } - let targetIndex = currentIndex + delta - guard targetIndex >= 0, targetIndex < manager.tabs.count else { return } - _ = manager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) - manager.selectWorkspace(workspace) - } - private func moveSelectedWorkspaceToTop(in manager: TabManager) { guard let workspace = manager.selectedWorkspace else { return } manager.moveTabsToTop([workspace.id]) @@ -1301,12 +1300,12 @@ struct cmuxApp: App { Divider() Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { - moveSelectedWorkspace(in: manager, by: -1) + manager.moveSelectedWorkspace(by: -1) } .disabled(workspaceIndex == nil || workspaceIndex == 0) Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { - moveSelectedWorkspace(in: manager, by: 1) + manager.moveSelectedWorkspace(by: 1) } .disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0c4111974806..0fb4d49420e1 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ A115C0DE0000000000000002 /* AllShortcutsPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = A115C0DE0000000000000001 /* AllShortcutsPopover.swift */; }; F4350A110000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */; }; F4350A120000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */; }; + 804100000000000000000009 /* AppDelegate+AdjacentNavigationShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80410000000000000000000A /* AppDelegate+AdjacentNavigationShortcut.swift */; }; C0DE45AC0000000000000001 /* AppDelegate+AgentChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE45AC0000000000000002 /* AppDelegate+AgentChat.swift */; }; C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */; }; C4A570010000000000000001 /* AppDelegate+CanvasShortcutRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A570010000000000000002 /* AppDelegate+CanvasShortcutRouting.swift */; }; @@ -433,6 +434,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A5B00005A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; E3309A07 /* cmuxApp+EqualizeSplitsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A08 /* cmuxApp+EqualizeSplitsMenu.swift */; }; C4160A010000000000000001 /* cmuxApp+HistoryMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A010000000000000002 /* cmuxApp+HistoryMenu.swift */; }; + 80410000000000000000000D /* cmuxApp+WorkspaceCommandHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80410000000000000000000E /* cmuxApp+WorkspaceCommandHelpers.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; C9A2B00000000000000000B3 /* CmuxAppKitSupportUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2B00000000000000000B2 /* CmuxAppKitSupportUI */; }; C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */; }; @@ -894,6 +896,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 6042A0026042A0026042A002 /* KeyboardShortcutModifierHoldHintsSettingsFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6042B0026042B0026042B002 /* KeyboardShortcutModifierHoldHintsSettingsFileTests.swift */; }; A50012F7 /* KeyboardShortcutRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F6 /* KeyboardShortcutRecorder.swift */; }; C0DE5A100000000000000001 /* KeyboardShortcutSavedLayoutTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5A100000000000000002 /* KeyboardShortcutSavedLayoutTemplateTests.swift */; }; + 80410000000000000000000B /* KeyboardShortcutSettings+ActionVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80410000000000000000000C /* KeyboardShortcutSettings+ActionVisibility.swift */; }; F7216002000000000000001 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7216002000000000000002 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; A50012F8 /* KeyboardShortcutSettingsControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F9 /* KeyboardShortcutSettingsControls.swift */; }; @@ -1280,6 +1283,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D36A00060000000000000001 /* RendererRealizationPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00060000000000000002 /* RendererRealizationPlanner.swift */; }; D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */; }; D36A00070000000000000001 /* RendererRealizationReclaimTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00070000000000000002 /* RendererRealizationReclaimTrigger.swift */; }; + 804100000000000000000003 /* ReorderShortcutActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804100000000000000000004 /* ReorderShortcutActionTests.swift */; }; F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */; }; F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */; }; C6711A040000000000000001 /* RestorableAgentHookSessionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B040000000000000001 /* RestorableAgentHookSessionRecord.swift */; }; @@ -1552,6 +1556,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; A5F100000000000000000005 /* TabItemView+WorkspaceNotificationsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F100000000000000000006 /* TabItemView+WorkspaceNotificationsMenu.swift */; }; 186A0F2EE1C92446E0A2D1E3 /* TabItemView+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56EA28B32C38ED8C753C82D3 /* TabItemView+WorkspaceTodo.swift */; }; + 804100000000000000000001 /* TabManager+AdjacentWorkspaceReordering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804100000000000000000002 /* TabManager+AdjacentWorkspaceReordering.swift */; }; DCDC1000000000000000B015 /* TabManager+BrowserFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; @@ -2089,6 +2094,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources REE0CA0000000000000000E1 /* AIAccountsClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIAccountsClient.swift; sourceTree = ""; }; A115C0DE0000000000000001 /* AllShortcutsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllShortcutsPopover.swift; sourceTree = ""; }; F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/AppBundleIconPersistencePolicy.swift; sourceTree = ""; }; + 80410000000000000000000A /* AppDelegate+AdjacentNavigationShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AdjacentNavigationShortcut.swift"; sourceTree = ""; }; C0DE45AC0000000000000002 /* AppDelegate+AgentChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AgentChat.swift"; sourceTree = ""; }; C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AgentChatNotifications.swift"; sourceTree = ""; }; C4A570010000000000000002 /* AppDelegate+CanvasShortcutRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CanvasShortcutRouting.swift"; sourceTree = ""; }; @@ -2396,6 +2402,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE4300000000000000000A /* CmuxAgentChatConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxAgentChatConfigTests.swift; sourceTree = ""; }; E3309A08 /* cmuxApp+EqualizeSplitsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+EqualizeSplitsMenu.swift"; sourceTree = ""; }; C4160A010000000000000002 /* cmuxApp+HistoryMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+HistoryMenu.swift"; sourceTree = ""; }; + 80410000000000000000000E /* cmuxApp+WorkspaceCommandHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+WorkspaceCommandHelpers.swift"; sourceTree = ""; }; A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = ""; }; 61AD48A9E6C3F1BE2B547FFE /* CMUXCLI+AgentHookCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookCatalog.swift"; sourceTree = ""; }; B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookDefinitions.swift"; sourceTree = ""; }; @@ -2797,6 +2804,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6042B0026042B0026042B002 /* KeyboardShortcutModifierHoldHintsSettingsFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutModifierHoldHintsSettingsFileTests.swift; sourceTree = ""; }; A50012F6 /* KeyboardShortcutRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutRecorder.swift; sourceTree = ""; }; C0DE5A100000000000000002 /* KeyboardShortcutSavedLayoutTemplateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSavedLayoutTemplateTests.swift; sourceTree = ""; }; + 80410000000000000000000C /* KeyboardShortcutSettings+ActionVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettings+ActionVisibility.swift"; sourceTree = ""; }; F7216002000000000000002 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift"; sourceTree = ""; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = ""; }; A50012F9 /* KeyboardShortcutSettingsControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsControls.swift; sourceTree = ""; }; @@ -3179,6 +3187,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D36A00060000000000000002 /* RendererRealizationPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationPlanner.swift; sourceTree = ""; }; D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D36A00070000000000000002 /* RendererRealizationReclaimTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationReclaimTrigger.swift; sourceTree = ""; }; + 804100000000000000000004 /* ReorderShortcutActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderShortcutActionTests.swift; sourceTree = ""; }; F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderHermesTests.swift; sourceTree = ""; }; F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderResumeTests.swift; sourceTree = ""; }; C6711B040000000000000001 /* RestorableAgentHookSessionRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookSessionRecord.swift; sourceTree = ""; }; @@ -3439,6 +3448,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; A5F100000000000000000006 /* TabItemView+WorkspaceNotificationsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceNotificationsMenu.swift"; sourceTree = ""; }; 56EA28B32C38ED8C753C82D3 /* TabItemView+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceTodo.swift"; sourceTree = ""; }; + 804100000000000000000002 /* TabManager+AdjacentWorkspaceReordering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+AdjacentWorkspaceReordering.swift"; sourceTree = ""; }; DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+BrowserFocus.swift"; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; @@ -4114,6 +4124,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */, A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */, A5001011 /* cmuxApp.swift */, + 80410000000000000000000E /* cmuxApp+WorkspaceCommandHelpers.swift */, CA5CADF00000000000000001 /* CanvasDebugMenuButtons.swift */, C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */, C46790000000000000000006 /* RosettaNativeRelaunch.swift */, @@ -4338,6 +4349,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A50012F0 /* Backport.swift */, BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, + 80410000000000000000000C /* KeyboardShortcutSettings+ActionVisibility.swift */, F7216002000000000000002 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */, C0DE7B330000000000000002 /* TextBoxAgentDetection.swift */, 7898A0020000000000000002 /* TextBoxGitIgnoreProbe.swift */, @@ -4408,6 +4420,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E3B7A4000000000000000008 /* WorkspaceIndicatorStyle+Display.swift */, E3B7A400000000000000000A /* TabManager+NotificationDismissalHosting.swift */, E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */, + 804100000000000000000002 /* TabManager+AdjacentWorkspaceReordering.swift */, E3B7A400000000000000020B /* Workspace+WorkspaceSurfaceTreeReading.swift */, CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */, A5001511 /* UITestRecorder.swift */, @@ -4694,6 +4707,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + 80410000000000000000000A /* AppDelegate+AdjacentNavigationShortcut.swift */, C0DE45AC0000000000000002 /* AppDelegate+AgentChat.swift */, C0DE71A00000000000000002 /* AgentChatThemeSync.swift */, C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */, @@ -5505,6 +5519,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 3069F1D10000000000000004 /* GhosttyPasteboardFidelityTests.swift */, D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */, D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */, + 804100000000000000000004 /* ReorderShortcutActionTests.swift */, D7AB3605C10DEF0000000002 /* WorkspaceCloseTabsContextMenuTests.swift */, 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */, D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */, @@ -6334,6 +6349,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = REE0CA0000000000000000E2 /* AIAccountsClient.swift in Sources */, A115C0DE0000000000000002 /* AllShortcutsPopover.swift in Sources */, F4350A110000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */, + 804100000000000000000009 /* AppDelegate+AdjacentNavigationShortcut.swift in Sources */, C0DE45AC0000000000000001 /* AppDelegate+AgentChat.swift in Sources */, C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources */, C4A570010000000000000001 /* AppDelegate+CanvasShortcutRouting.swift in Sources */, @@ -6507,6 +6523,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE43000000000000000001 /* CmuxAgentChatConfig.swift in Sources */, E3309A07 /* cmuxApp+EqualizeSplitsMenu.swift in Sources */, C4160A010000000000000001 /* cmuxApp+HistoryMenu.swift in Sources */, + 80410000000000000000000D /* cmuxApp+WorkspaceCommandHelpers.swift in Sources */, A5001001 /* cmuxApp.swift in Sources */, A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, @@ -6728,6 +6745,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C4A570020000000000000001 /* KeyboardShortcutActionContext.swift in Sources */, C34670030000000000000001 /* KeyboardShortcutContext.swift in Sources */, A50012F7 /* KeyboardShortcutRecorder.swift in Sources */, + 80410000000000000000000B /* KeyboardShortcutSettings+ActionVisibility.swift in Sources */, F7216002000000000000001 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, A50012F8 /* KeyboardShortcutSettingsControls.swift in Sources */, @@ -7156,6 +7174,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */, A5F100000000000000000005 /* TabItemView+WorkspaceNotificationsMenu.swift in Sources */, 186A0F2EE1C92446E0A2D1E3 /* TabItemView+WorkspaceTodo.swift in Sources */, + 804100000000000000000001 /* TabManager+AdjacentWorkspaceReordering.swift in Sources */, DCDC1000000000000000B015 /* TabManager+BrowserFocus.swift in Sources */, D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, @@ -7924,6 +7943,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7C9D1E3F5B7A9C1D3E5F702 /* RemoteTmuxWindowReorderTests.swift in Sources */, C58410010000000000000001 /* RenderableSystemSymbolTests.swift in Sources */, D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */, + 804100000000000000000003 /* ReorderShortcutActionTests.swift in Sources */, F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */, F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */, F5410002A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift in Sources */, diff --git a/cmuxTests/ReorderShortcutActionTests.swift b/cmuxTests/ReorderShortcutActionTests.swift new file mode 100644 index 000000000000..d566f144beb1 --- /dev/null +++ b/cmuxTests/ReorderShortcutActionTests.swift @@ -0,0 +1,158 @@ +import Bonsplit +import CmuxCanvasUI +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +private final class ReorderCanvasViewportSpy: CanvasViewportControlling { + var modelDidChangeCount = 0 + var currentMagnification: CGFloat = 1 + var currentCenterInCanvas: CGPoint = .zero + + func revealPane(_ panelId: UUID, animated: Bool) {} + func toggleOverview() {} + func zoom(by factor: CGFloat) {} + func resetZoom() {} + func setViewport(center: CGPoint, magnification: CGFloat?) {} + func modelDidChangeExternally(animated: Bool) { modelDidChangeCount += 1 } +} + +@MainActor +@Suite("Reorder shortcut actions", .serialized) +struct ReorderShortcutActionTests { + @Test func selectedSurfaceMovesByFinalPositionAndClampsAtEdges() throws { + let workspace = Workspace() + let firstPanelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let secondPanel = try #require(workspace.newTerminalSurface(inPane: paneId, focus: false)) + let thirdPanel = try #require(workspace.newTerminalSurface(inPane: paneId, focus: false)) + workspace.focusPanel(firstPanelId) + + #expect(workspace.moveSelectedSurface(by: 1)) + #expect(panelOrder(in: workspace, paneId: paneId) == [secondPanel.id, firstPanelId, thirdPanel.id]) + #expect(workspace.focusedPanelId == firstPanelId) + + #expect(workspace.moveSelectedSurface(by: 1)) + let rightEdgeOrder = [secondPanel.id, thirdPanel.id, firstPanelId] + #expect(panelOrder(in: workspace, paneId: paneId) == rightEdgeOrder) + #expect(workspace.moveSelectedSurface(by: 1)) + #expect(panelOrder(in: workspace, paneId: paneId) == rightEdgeOrder) + + #expect(workspace.moveSelectedSurface(by: -1)) + #expect(panelOrder(in: workspace, paneId: paneId) == [secondPanel.id, firstPanelId, thirdPanel.id]) + } + + @Test func singleSurfaceReorderIsANoOp() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: panelId)) + + #expect(workspace.moveSelectedSurface(by: -1)) + #expect(workspace.moveSelectedSurface(by: 1)) + #expect(panelOrder(in: workspace, paneId: paneId) == [panelId]) + } + + @Test func selectedCanvasSurfaceMovesWithinVisiblePaneWithoutMutatingSplitOrder() throws { + let workspace = Workspace() + let firstPanelId = try #require(workspace.focusedPanelId) + let splitPaneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let secondPanel = try #require(workspace.newTerminalSurface(inPane: splitPaneId, focus: false)) + let thirdPanel = try #require(workspace.newTerminalSurface(inPane: splitPaneId, focus: false)) + let originalOrder = [firstPanelId, secondPanel.id, thirdPanel.id] + + workspace.canvasModel.syncPanes(panelIds: originalOrder, focusedPanelId: firstPanelId) + #expect(workspace.canvasModel.joinPanel(secondPanel.id, withPaneContaining: firstPanelId)) + #expect(workspace.canvasModel.joinPanel(thirdPanel.id, withPaneContaining: firstPanelId)) + workspace.setLayoutMode(.canvas) + workspace.focusPanel(firstPanelId) + let canvasPaneId = try #require(workspace.canvasModel.paneID(containing: firstPanelId)) + let viewport = ReorderCanvasViewportSpy() + workspace.canvasModel.viewport = viewport + + #expect(workspace.moveSelectedSurface(by: -1)) + #expect(viewport.modelDidChangeCount == 0) + + #expect(workspace.moveSelectedSurface(by: 1)) + #expect(viewport.modelDidChangeCount == 1) + #expect( + workspace.canvasModel.layout.panelIds(in: canvasPaneId)?.map(\.rawValue) == + [secondPanel.id, firstPanelId, thirdPanel.id] + ) + #expect(workspace.focusedPanelId == firstPanelId) + #expect(panelOrder(in: workspace, paneId: splitPaneId) == originalOrder) + } + + @Test func selectedWorkspaceMovesWithinItsPinTierAndStaysSelected() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let firstUnpinned = manager.addWorkspace() + let secondUnpinned = manager.addWorkspace() + + manager.selectWorkspace(secondPinned) + let initialOrder = [firstPinned.id, secondPinned.id, firstUnpinned.id, secondUnpinned.id] + #expect(manager.tabs.map(\.id) == initialOrder) + #expect(manager.moveSelectedWorkspace(by: 1)) + #expect(manager.tabs.map(\.id) == initialOrder) + #expect(manager.moveSelectedWorkspace(by: -1)) + #expect(manager.tabs.map(\.id) == [secondPinned.id, firstPinned.id, firstUnpinned.id, secondUnpinned.id]) + #expect(manager.selectedTabId == secondPinned.id) + #expect(manager.moveSelectedWorkspace(by: -1)) + #expect(manager.tabs.map(\.id) == [secondPinned.id, firstPinned.id, firstUnpinned.id, secondUnpinned.id]) + + manager.selectWorkspace(firstUnpinned) + #expect(manager.moveSelectedWorkspace(by: -1)) + #expect(manager.tabs.map(\.id) == [secondPinned.id, firstPinned.id, firstUnpinned.id, secondUnpinned.id]) + #expect(manager.moveSelectedWorkspace(by: 1)) + #expect(manager.tabs.map(\.id) == [secondPinned.id, firstPinned.id, secondUnpinned.id, firstUnpinned.id]) + #expect(manager.selectedTabId == firstUnpinned.id) + #expect(manager.moveSelectedWorkspace(by: 1)) + #expect(manager.tabs.map(\.id) == [secondPinned.id, firstPinned.id, secondUnpinned.id, firstUnpinned.id]) + } + + @Test func reorderActionsArePublicAndHaveAlignedCollisionFreeDefaults() throws { + let actions: [KeyboardShortcutSettings.Action] = [ + .moveSurfaceLeft, + .moveSurfaceRight, + .moveWorkspaceUp, + .moveWorkspaceDown, + ] + + for action in actions { + #expect(KeyboardShortcutSettings.publicShortcutActions.contains(action)) + #expect(KeyboardShortcutSettings.settingsVisibleActions.contains(action)) + let settingsAction = try #require(ShortcutAction(rawValue: action.rawValue)) + let settingsStroke = try #require(settingsAction.defaultStroke) + let runtimeShortcut = action.defaultShortcut + #expect(settingsAction.displayName == action.label) + #expect(settingsStroke.key == runtimeShortcut.key) + #expect(settingsStroke.command == runtimeShortcut.command) + #expect(settingsStroke.shift == runtimeShortcut.shift) + #expect(settingsStroke.option == runtimeShortcut.option) + #expect(settingsStroke.control == runtimeShortcut.control) + #expect( + !KeyboardShortcutSettings.Action.allCases.contains { + $0 != action && $0.defaultShortcut == runtimeShortcut + } + ) + } + + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveWorkspaceUp") == .moveWorkspaceUp) + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveWorkspaceDown") == .moveWorkspaceDown) + } + + private func panelOrder(in workspace: Workspace, paneId: PaneID) -> [UUID] { + workspace.bonsplitController.tabs(inPane: paneId).compactMap { + workspace.panelIdFromSurfaceId($0.id) + } + } +} diff --git a/skills/cmux-settings/references/shortcut-actions.md b/skills/cmux-settings/references/shortcut-actions.md index e4c2a2382854..c2f1acf7a483 100644 --- a/skills/cmux-settings/references/shortcut-actions.md +++ b/skills/cmux-settings/references/shortcut-actions.md @@ -40,11 +40,15 @@ Values for `shortcuts.bindings.`: - `shortcuts.bindings.newWorkspaceGroup` - `shortcuts.bindings.groupSelectedWorkspaces` - `shortcuts.bindings.toggleFocusedWorkspaceGroupCollapsed` +- `shortcuts.bindings.moveWorkspaceUp` +- `shortcuts.bindings.moveWorkspaceDown` ## Panes and surfaces - `shortcuts.bindings.nextSurface` - `shortcuts.bindings.prevSurface` +- `shortcuts.bindings.moveSurfaceLeft` +- `shortcuts.bindings.moveSurfaceRight` - `shortcuts.bindings.selectSurfaceByNumber` - `shortcuts.bindings.newSurface` - `shortcuts.bindings.toggleTerminalCopyMode` diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 51181cef54ab..811346908d0d 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -101,6 +101,8 @@ export const shortcutCategories: ShortcutCategory[] = [ }, { id: "nextSidebarTab", combos: [["⌃", "⌘", "]"]], description: { en: "Next workspace", ja: "次のワークスペース" } }, { id: "prevSidebarTab", combos: [["⌃", "⌘", "["]], description: { en: "Previous workspace", ja: "前のワークスペース" } }, + { id: "moveWorkspaceUp", combos: [["⌃", "⌥", "⌘", "["]], description: { en: "Move workspace up", ja: "ワークスペースを上へ移動" } }, + { id: "moveWorkspaceDown", combos: [["⌃", "⌥", "⌘", "]"]], description: { en: "Move workspace down", ja: "ワークスペースを下へ移動" } }, { id: "focusHistoryBack", combos: [["⌘", "["]], @@ -257,6 +259,8 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "newSurface", combos: [["⌘", "T"]], description: { en: "New surface", ja: "新規サーフェス" } }, { id: "nextSurface", combos: [["⌘", "⇧", "]"]], description: { en: "Next surface", ja: "次のサーフェス" } }, { id: "prevSurface", combos: [["⌘", "⇧", "["]], description: { en: "Previous surface", ja: "前のサーフェス" } }, + { id: "moveSurfaceLeft", combos: [["⌥", "⌘", "⇧", "["]], description: { en: "Move surface left", ja: "サーフェスを左へ移動" } }, + { id: "moveSurfaceRight", combos: [["⌥", "⌘", "⇧", "]"]], description: { en: "Move surface right", ja: "サーフェスを右へ移動" } }, { id: "selectSurfaceByNumber", combos: [["⌃", "1…9"]], description: { en: "Select surface 1…9", ja: "サーフェス1…9を選択" } }, { id: "renameTab", combos: [["⌘", "R"]], description: { en: "Rename tab", ja: "タブ名を変更" } }, { id: "closeTab", combos: [["⌘", "W"]], description: { en: "Close tab", ja: "タブを閉じる" } }, diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 0e9938aabfbc..32d8441a31c8 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1571,9 +1571,13 @@ "triggerFlash", "nextSurface", "prevSurface", + "moveSurfaceLeft", + "moveSurfaceRight", "selectSurfaceByNumber", "nextSidebarTab", "prevSidebarTab", + "moveWorkspaceUp", + "moveWorkspaceDown", "focusHistoryBack", "focusHistoryForward", "selectWorkspaceByNumber",