diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 0dbf0792b397..7487d42633e0 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -112,6 +112,18 @@ extension ShortcutAction { 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 .moveSurfaceToPreviousPane: + return ShortcutStroke(key: "[", command: true, shift: true, control: true) + case .moveSurfaceToNextPane: + return ShortcutStroke(key: "]", command: true, shift: true, control: true) + case .moveSurfaceToPaneLeft: + return ShortcutStroke(key: "←", command: true, shift: true, option: true) + case .moveSurfaceToPaneRight: + return ShortcutStroke(key: "→", command: true, shift: true, option: true) + case .moveSurfaceToPaneUp: + return ShortcutStroke(key: "↑", command: true, shift: true, option: true) + case .moveSurfaceToPaneDown: + 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) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index c38a70180a15..b75aed7270ed 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -38,8 +38,20 @@ 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 .moveSurfaceLeft: return String(localized: "shortcut.moveSurfaceLeft.label", defaultValue: "Reorder Surface Left") + case .moveSurfaceRight: return String(localized: "shortcut.moveSurfaceRight.label", defaultValue: "Reorder Surface Right") + case .moveSurfaceToPreviousPane: + return String(localized: "shortcut.moveSurfaceToPreviousPane.label", defaultValue: "Move Surface to Previous Pane") + case .moveSurfaceToNextPane: + return String(localized: "shortcut.moveSurfaceToNextPane.label", defaultValue: "Move Surface to Next Pane") + case .moveSurfaceToPaneLeft: + return String(localized: "shortcut.moveSurfaceToPaneLeft.label", defaultValue: "Move Surface to Pane on Left") + case .moveSurfaceToPaneRight: + return String(localized: "shortcut.moveSurfaceToPaneRight.label", defaultValue: "Move Surface to Pane on Right") + case .moveSurfaceToPaneUp: + return String(localized: "shortcut.moveSurfaceToPaneUp.label", defaultValue: "Move Surface to Pane Above") + case .moveSurfaceToPaneDown: + return String(localized: "shortcut.moveSurfaceToPaneDown.label", defaultValue: "Move Surface to Pane Below") case .selectSurfaceByNumber: return "Select Surface 1…9" case .nextSidebarTab: return "Next Workspace" case .prevSidebarTab: return "Previous Workspace" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index 93a065abe52c..ec2c1a27d24d 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -1,4 +1,60 @@ extension ShortcutAction { + /// Which group this action belongs to in the settings pane. + public var group: Group { + switch self { + case .openSettings, .reloadConfiguration, .showHideAllWindows, .globalSearch, + .newWindow, .closeWindow, .toggleFullScreen, .quit: + return .app + case .toggleSidebar, .newTab, .newBrowserWorkspace, .saveLayoutTemplate, .openFolder, .reopenPreviousSession, .goToWorkspace, + .commandPalette, .commandPaletteNext, .commandPalettePrevious, .sendFeedback, + .showNotifications, .jumpToUnread, .toggleUnread, .markOldestUnreadAndJumpNext, + .focusRightSidebar, .switchRightSidebarToFiles, .switchRightSidebarToFind, + .switchRightSidebarToSessions, .switchRightSidebarToFeed, + .switchRightSidebarToDock, .triggerFlash, .reopenClosedWorkspace: + return .workspace + case .nextSurface, .prevSurface, .moveSurfaceLeft, .moveSurfaceRight, + .moveSurfaceToPreviousPane, .moveSurfaceToNextPane, + .moveSurfaceToPaneLeft, .moveSurfaceToPaneRight, + .moveSurfaceToPaneUp, .moveSurfaceToPaneDown, + .selectSurfaceByNumber, + .nextSidebarTab, .prevSidebarTab, .moveWorkspaceUp, .moveWorkspaceDown, + .focusHistoryBack, .focusHistoryForward, .selectWorkspaceByNumber, + .renameTab, .renameWorkspace, .editWorkspaceDescription, + .markWorkspaceDone, .cycleWorkspaceStatus, .toggleChecklistItemComplete, + .closeTab, .closeOtherTabsInPane, .closeWorkspace, + .newWorkspaceGroup, .groupSelectedWorkspaces, + .toggleFocusedWorkspaceGroupCollapsed, .reopenClosedBrowserPanel, + .newSurface, .toggleTerminalCopyMode, .focusTextBoxInput, + .cycleTextBoxSubmitAction, .attachTextBoxFile, .sendCtrlFToTerminal, + .clearScreenKeepScrollback: + return .navigation + case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown, + .toggleSplitZoom, .equalizeSplits, .splitBrowserRight, .splitBrowserDown, + .toggleRightSidebar, .fileExplorerOpenSelection, .fileExplorerOpenSelectionFinderAlias, + .toggleCanvasLayout, .canvasRevealFocusedPane, .canvasOverview, + .canvasZoomIn, .canvasZoomOut, .canvasZoomReset, .canvasTidy, + .canvasAlignLeft, .canvasAlignRight, .canvasAlignTop, .canvasAlignBottom, + .canvasEqualizeWidths, .canvasEqualizeHeights, + .canvasDistributeHorizontally, .canvasDistributeVertically, + .simulatorHome, .simulatorRotateLeft, .simulatorRotateRight, + .simulatorToggleAppearance, .simulatorToggleSoftwareKeyboard: + return .panes + case .openDiffViewer, .saveFilePreview, .openBrowser, .focusBrowserAddressBar, + .browserBack, .browserForward, .browserReload, .browserHardReload, + .browserZoomIn, .browserZoomOut, .browserZoomReset, + .markdownZoomIn, .markdownZoomOut, .markdownZoomReset, + .find, .findInDirectory, .findNext, .findPrevious, + .hideFind, .useSelectionForFind, .toggleBrowserDeveloperTools, + .showBrowserJavaScriptConsole, .toggleBrowserFocusMode, .toggleReactGrab, + .diffViewerScrollDown, .diffViewerScrollUp, + .diffViewerScrollHalfPageDown, .diffViewerScrollHalfPageUp, + .diffViewerScrollDownEmacs, .diffViewerScrollUpEmacs, + .diffViewerScrollToBottom, .diffViewerScrollToTop, + .diffViewerOpenFileSearch, .diffViewerNextFile, .diffViewerPreviousFile: + return .browser + } + } + /// Logical grouping used for sectioning the shortcuts pane. public enum Group: String, CaseIterable, Sendable, Hashable { /// Application-wide actions. diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 521c2e6fe1e0..2f13d26aa821 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -48,6 +48,18 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case moveSurfaceLeft /// Moves the selected surface one position right. case moveSurfaceRight + /// Moves the selected surface to the previous pane in spatial order. + case moveSurfaceToPreviousPane + /// Moves the selected surface to the next pane in spatial order. + case moveSurfaceToNextPane + /// Moves the selected surface to the pane on the left. + case moveSurfaceToPaneLeft + /// Moves the selected surface to the pane on the right. + case moveSurfaceToPaneRight + /// Moves the selected surface to the pane above. + case moveSurfaceToPaneUp + /// Moves the selected surface to the pane below. + case moveSurfaceToPaneDown case selectSurfaceByNumber case nextSidebarTab case prevSidebarTab @@ -186,54 +198,6 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod } extension ShortcutAction { - /// Which group this action belongs to in the settings pane. - public var group: Group { - switch self { - case .openSettings, .reloadConfiguration, .showHideAllWindows, .globalSearch, - .newWindow, .closeWindow, .toggleFullScreen, .quit: - return .app - case .toggleSidebar, .newTab, .newBrowserWorkspace, .saveLayoutTemplate, .openFolder, .reopenPreviousSession, .goToWorkspace, - .commandPalette, .commandPaletteNext, .commandPalettePrevious, .sendFeedback, - .showNotifications, .jumpToUnread, .toggleUnread, .markOldestUnreadAndJumpNext, - .focusRightSidebar, .switchRightSidebarToFiles, .switchRightSidebarToFind, - .switchRightSidebarToSessions, .switchRightSidebarToFeed, - .switchRightSidebarToDock, .triggerFlash, .reopenClosedWorkspace: - return .workspace - 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, - .reopenClosedBrowserPanel, .newSurface, .toggleTerminalCopyMode, - .focusTextBoxInput, .cycleTextBoxSubmitAction, .attachTextBoxFile, .sendCtrlFToTerminal, - .clearScreenKeepScrollback: - return .navigation - case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown, - .toggleSplitZoom, .equalizeSplits, .splitBrowserRight, .splitBrowserDown, - .toggleRightSidebar, .fileExplorerOpenSelection, .fileExplorerOpenSelectionFinderAlias, - .toggleCanvasLayout, .canvasRevealFocusedPane, .canvasOverview, - .canvasZoomIn, .canvasZoomOut, .canvasZoomReset, .canvasTidy, - .canvasAlignLeft, .canvasAlignRight, .canvasAlignTop, .canvasAlignBottom, - .canvasEqualizeWidths, .canvasEqualizeHeights, - .canvasDistributeHorizontally, .canvasDistributeVertically, - .simulatorHome, .simulatorRotateLeft, .simulatorRotateRight, - .simulatorToggleAppearance, .simulatorToggleSoftwareKeyboard: - return .panes - case .openDiffViewer, .saveFilePreview, .openBrowser, .focusBrowserAddressBar, .browserBack, - .browserForward, .browserReload, .browserHardReload, .browserZoomIn, .browserZoomOut, - .browserZoomReset, .markdownZoomIn, .markdownZoomOut, .markdownZoomReset, - .find, .findInDirectory, .findNext, .findPrevious, - .hideFind, .useSelectionForFind, .toggleBrowserDeveloperTools, - .showBrowserJavaScriptConsole, .toggleBrowserFocusMode, .toggleReactGrab, - .diffViewerScrollDown, .diffViewerScrollUp, - .diffViewerScrollHalfPageDown, .diffViewerScrollHalfPageUp, - .diffViewerScrollDownEmacs, .diffViewerScrollUpEmacs, .diffViewerScrollToBottom, - .diffViewerScrollToTop, .diffViewerOpenFileSearch, - .diffViewerNextFile, .diffViewerPreviousFile: - return .browser - } - } - /// Whether this action binds the whole `1…9` digit range through a /// single stored placeholder. /// diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f92b5cb56e83..b68a638e1eef 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -63803,6 +63803,23 @@ } } }, + "command.surfaceNavigation.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface Navigation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスナビゲーション" + } + } + } + }, "command.switchExtensionSidebar.subtitle": { "extractionState": "manual", "localizations": { @@ -202932,13 +202949,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Move Surface Left" + "value": "Reorder Surface Left" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サーフェスを左へ移動" + "value": "サーフェスを左へ並べ替え" } } } @@ -202949,13 +202966,115 @@ "en": { "stringUnit": { "state": "translated", - "value": "Move Surface Right" + "value": "Reorder Surface Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを右へ並べ替え" + } + } + } + }, + "shortcut.moveSurfaceToNextPane.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface to Next Pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを次のペインへ移動" + } + } + } + }, + "shortcut.moveSurfaceToPaneDown.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface to Pane Below" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを下のペインへ移動" + } + } + } + }, + "shortcut.moveSurfaceToPaneLeft.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface to Pane on Left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを左のペインへ移動" + } + } + } + }, + "shortcut.moveSurfaceToPaneRight.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface to Pane on Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを右のペインへ移動" + } + } + } + }, + "shortcut.moveSurfaceToPaneUp.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface to Pane Above" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを上のペインへ移動" + } + } + } + }, + "shortcut.moveSurfaceToPreviousPane.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Surface to Previous Pane" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サーフェスを右へ移動" + "value": "サーフェスを前のペインへ移動" } } } diff --git a/Sources/AppDelegate+AdjacentNavigationShortcut.swift b/Sources/AppDelegate+AdjacentNavigationShortcut.swift index 561f18ef201a..752006bdce0a 100644 --- a/Sources/AppDelegate+AdjacentNavigationShortcut.swift +++ b/Sources/AppDelegate+AdjacentNavigationShortcut.swift @@ -4,34 +4,91 @@ 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 { + let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager + ?? tabManager if matchConfiguredShortcut(event: event, action: .nextSurface) { if performFocusedDockShortcut(.selectNextSurface, event: event) { return true } - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectNextSurface() + routedTabs?.selectNextSurface() return true } if matchConfiguredShortcut(event: event, action: .prevSurface) { if performFocusedDockShortcut(.selectPreviousSurface, event: event) { return true } - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectPreviousSurface() + routedTabs?.selectPreviousSurface() return true } if matchConfiguredShortcut(event: event, action: .moveSurfaceLeft) { if performFocusedDockShortcut(.moveSurface(offset: -1), event: event) { return true } - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectedWorkspace?.moveSelectedSurface(by: -1) + routedTabs?.selectedWorkspace?.moveSelectedSurface(by: -1) return true } if matchConfiguredShortcut(event: event, action: .moveSurfaceRight) { if performFocusedDockShortcut(.moveSurface(offset: 1), event: event) { return true } - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectedWorkspace?.moveSelectedSurface(by: 1) + routedTabs?.selectedWorkspace?.moveSelectedSurface(by: 1) + return true + } + for movement in SurfacePaneMovement.allCases + where matchesSurfacePaneMovementShortcut(event: event, movement: movement) { + // Repeats may traverse existing panes but must not recursively create splits. + if !performSurfacePaneMovement( + movement, + tabManager: routedTabs, + preferredWindow: event.window, + allowMissingDestinationSplit: !event.isARepeat + ) { + NSSound.beep() + } return true } if matchConfiguredShortcut(event: event, action: .moveWorkspaceUp) { - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: -1) + routedTabs?.moveSelectedWorkspace(by: -1) return true } if matchConfiguredShortcut(event: event, action: .moveWorkspaceDown) { - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: 1) + routedTabs?.moveSelectedWorkspace(by: 1) return true } return false } + + /// Applies the shared Dock-focus gate used by shortcuts, the command + /// palette, and the View menu. + @discardableResult + func performSurfacePaneMovement( + _ movement: SurfacePaneMovement, + tabManager: TabManager?, + preferredWindow: NSWindow?, + allowMissingDestinationSplit: Bool = true + ) -> Bool { + guard focusedDockStoreForShortcut(preferredWindow: preferredWindow) == nil else { + return false + } + return tabManager?.selectedWorkspace?.moveFocusedSurface( + to: movement, + allowMissingDestinationSplit: allowMissingDestinationSplit + ) == true + } + + private func matchesSurfacePaneMovementShortcut( + event: NSEvent, + movement: SurfacePaneMovement + ) -> Bool { + let configuredShortcut = KeyboardShortcutSettings.shortcut( + for: movement.shortcutAction + ) + let configuredKey = + configuredShortcut.secondStroke?.key ?? + configuredShortcut.firstStroke.key + let arrowRoute: (glyph: String, keyCode: UInt16) = switch configuredKey { + case "→": ("→", 124) + case "↑": ("↑", 126) + case "↓": ("↓", 125) + default: ("←", 123) + } + return matchConfiguredDirectionalShortcut( + event: event, + action: movement.shortcutAction, + arrowGlyph: arrowRoute.glyph, + arrowKeyCode: arrowRoute.keyCode + ) + } } diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 02ea1fee538e..be11a632af8d 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -5,6 +5,9 @@ import CmuxSwiftRender extension ContentView { static func commandPaletteShortcutAction(forCommandID commandId: String) -> KeyboardShortcutSettings.Action? { + if let movement = SurfacePaneMovement(commandID: commandId) { + return movement.shortcutAction + } if let rightSidebarModeAction = commandPaletteRightSidebarModeShortcutAction(forCommandID: commandId) { return rightSidebarModeAction } diff --git a/Sources/ContentView+SurfaceNavigationCommands.swift b/Sources/ContentView+SurfaceNavigationCommands.swift new file mode 100644 index 000000000000..36dba72a9431 --- /dev/null +++ b/Sources/ContentView+SurfaceNavigationCommands.swift @@ -0,0 +1,81 @@ +import AppKit +import CmuxCommandPalette + +extension ContentView { + static func commandPaletteSurfaceNavigationContributions() + -> [CommandPaletteCommandContribution] { + func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { + { _ in value } + } + + let subtitle = constant( + String( + localized: "command.surfaceNavigation.subtitle", + defaultValue: "Surface Navigation" + ) + ) + var contributions = [ + CommandPaletteCommandContribution( + commandId: "palette.nextTabInPane", + title: constant( + String( + localized: "command.nextTabInPane.title", + defaultValue: "Next Tab in Pane" + ) + ), + subtitle: subtitle, + keywords: ["next", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ), + CommandPaletteCommandContribution( + commandId: "palette.previousTabInPane", + title: constant( + String( + localized: "command.previousTabInPane.title", + defaultValue: "Previous Tab in Pane" + ) + ), + subtitle: subtitle, + keywords: ["previous", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ), + ] + contributions.append(contentsOf: SurfacePaneMovement.allCases.map { movement in + CommandPaletteCommandContribution( + commandId: movement.commandID, + title: constant(movement.title), + subtitle: subtitle, + keywords: movement.keywords, + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + }) + return contributions + } + + func registerSurfaceNavigationCommandHandlers( + _ registry: inout CommandPaletteHandlerRegistry, + preferredWindow: @escaping () -> NSWindow? + ) { + registry.register(commandId: "palette.nextTabInPane") { + tabManager.selectNextSurface() + } + registry.register(commandId: "palette.previousTabInPane") { + tabManager.selectPreviousSurface() + } + for movement in SurfacePaneMovement.allCases { + registry.register(commandId: movement.commandID) { + guard let preferredWindow = preferredWindow() else { + NSSound.beep() + return + } + if AppDelegate.shared?.performSurfacePaneMovement( + movement, + tabManager: tabManager, + preferredWindow: preferredWindow + ) != true { + NSSound.beep() + } + } + } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 34ffd16266b0..0d3397896862 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7524,22 +7524,7 @@ struct ContentView: View { ) ) contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.nextTabInPane", - title: constant(String(localized: "command.nextTabInPane.title", defaultValue: "Next Tab in Pane")), - subtitle: constant(String(localized: "command.nextTabInPane.subtitle", defaultValue: "Tab Navigation")), - keywords: ["next", "tab", "pane"], - when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } - ) - ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.previousTabInPane", - title: constant(String(localized: "command.previousTabInPane.title", defaultValue: "Previous Tab in Pane")), - subtitle: constant(String(localized: "command.previousTabInPane.subtitle", defaultValue: "Tab Navigation")), - keywords: ["previous", "tab", "pane"], - when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } - ) + contentsOf: Self.commandPaletteSurfaceNavigationContributions() ) contributions.append( @@ -8524,12 +8509,7 @@ struct ContentView: View { panelContext.workspace.markPanelUnread(panelContext.panelId) } } - registry.register(commandId: "palette.nextTabInPane") { - tabManager.selectNextSurface() - } - registry.register(commandId: "palette.previousTabInPane") { - tabManager.selectPreviousSurface() - } + registerSurfaceNavigationCommandHandlers(®istry) { observedWindow } registry.register(commandId: "palette.openWorkspacePullRequests") { DispatchQueue.main.async { if !openWorkspacePullRequestsInConfiguredBrowser() { diff --git a/Sources/ControlSurfaceResumeTarget.swift b/Sources/ControlSurfaceResumeTarget.swift index 8cc06d8121b6..a500da4e3ecb 100644 --- a/Sources/ControlSurfaceResumeTarget.swift +++ b/Sources/ControlSurfaceResumeTarget.swift @@ -293,13 +293,10 @@ extension TerminalController { ) content.apply(to: alert, presentingWindow: nil) - switch alert.runModal() { - case .alertFirstButtonReturn: - return .auto - case .alertSecondButtonReturn: - return .prompt - default: - return .manual + return switch alert.runModal() { + case .alertFirstButtonReturn: .auto + case .alertSecondButtonReturn: .prompt + default: .manual } } diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index 5a3df28c8f90..f4e731ea028c 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -41,11 +41,12 @@ extension DockSplitStore { ) } let persistedPanelIds = Set(panelSnapshots.map(\.id)) - let sourceWorkspaceIdsByPanelId = Dictionary(uniqueKeysWithValues: panelSnapshots.compactMap { - panel -> (UUID, UUID)? in - guard let transfer = detachedSurfaceTransfersByPanelId[panel.id] else { return nil } - return (panel.id, transfer.sessionRestoreWorkspaceId) - }) + let sourceWorkspaceIdsByPanelId: [UUID: UUID] = Dictionary( + uniqueKeysWithValues: panelSnapshots.compactMap { panel -> (UUID, UUID)? in + guard let transfer = detachedSurfaceTransfersByPanelId[panel.id] else { return nil } + return (panel.id, transfer.sessionRestoreWorkspaceId) + } + ) let layout = layoutCodec.pruned( rawLayout, keeping: persistedPanelIds diff --git a/Sources/KeyboardShortcutSettings+ActionMetadata.swift b/Sources/KeyboardShortcutSettings+ActionMetadata.swift new file mode 100644 index 000000000000..df4356bd3d85 --- /dev/null +++ b/Sources/KeyboardShortcutSettings+ActionMetadata.swift @@ -0,0 +1,17 @@ +extension KeyboardShortcutSettings.Action { + var allowsChordShortcut: Bool { + self != .fileExplorerOpenSelection + && self != .fileExplorerOpenSelectionFinderAlias + && self != .cycleTextBoxSubmitAction + } + + func displayedShortcutString(for shortcut: StoredShortcut) -> String { + if shortcut.isUnbound { + return shortcut.displayString + } + if usesNumberedDigitMatching { + return shortcut.numberedDisplayString + } + return shortcut.displayString + } +} diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 4b474006609b..0f6b49ef053c 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -100,6 +100,9 @@ enum KeyboardShortcutSettings { case nextSurface case prevSurface case moveSurfaceLeft, moveSurfaceRight + case moveSurfaceToPreviousPane, moveSurfaceToNextPane + case moveSurfaceToPaneLeft, moveSurfaceToPaneRight + case moveSurfaceToPaneUp, moveSurfaceToPaneDown case selectSurfaceByNumber case nextSidebarTab case prevSidebarTab @@ -234,8 +237,14 @@ 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 .moveSurfaceLeft: return String(localized: "shortcut.moveSurfaceLeft.label", defaultValue: "Reorder Surface Left") + case .moveSurfaceRight: return String(localized: "shortcut.moveSurfaceRight.label", defaultValue: "Reorder Surface Right") + case .moveSurfaceToPreviousPane: return SurfacePaneMovement.previous.title + case .moveSurfaceToNextPane: return SurfacePaneMovement.next.title + case .moveSurfaceToPaneLeft: return SurfacePaneMovement.left.title + case .moveSurfaceToPaneRight: return SurfacePaneMovement.right.title + case .moveSurfaceToPaneUp: return SurfacePaneMovement.up.title + case .moveSurfaceToPaneDown: return SurfacePaneMovement.down.title 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") @@ -498,6 +507,18 @@ enum KeyboardShortcutSettings { 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 .moveSurfaceToPreviousPane: + return StoredShortcut(key: "[", command: true, shift: true, option: false, control: true) + case .moveSurfaceToNextPane: + return StoredShortcut(key: "]", command: true, shift: true, option: false, control: true) + case .moveSurfaceToPaneLeft: + return StoredShortcut(key: "←", command: true, shift: true, option: true, control: false) + case .moveSurfaceToPaneRight: + return StoredShortcut(key: "→", command: true, shift: true, option: true, control: false) + case .moveSurfaceToPaneUp: + return StoredShortcut(key: "↑", command: true, shift: true, option: true, control: false) + case .moveSurfaceToPaneDown: + 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: @@ -626,21 +647,6 @@ enum KeyboardShortcutSettings { return simulatorDefaultShortcut } } - - var allowsChordShortcut: Bool { - self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction - } - - func displayedShortcutString(for shortcut: StoredShortcut) -> String { - if shortcut.isUnbound { - return shortcut.displayString - } - if usesNumberedDigitMatching { - return shortcut.numberedDisplayString - } - return shortcut.displayString - } - func conflicts( with proposedShortcut: StoredShortcut, proposedAction: Action, diff --git a/Sources/SurfacePaneMovement.swift b/Sources/SurfacePaneMovement.swift new file mode 100644 index 000000000000..174efb3e54d2 --- /dev/null +++ b/Sources/SurfacePaneMovement.swift @@ -0,0 +1,98 @@ +import Foundation + +/// A destination for moving the active surface between panes. +enum SurfacePaneMovement: CaseIterable, Hashable, Sendable { + case previous + case next + case left + case right + case up + case down + + var shortcutAction: KeyboardShortcutSettings.Action { + switch self { + case .previous: .moveSurfaceToPreviousPane + case .next: .moveSurfaceToNextPane + case .left: .moveSurfaceToPaneLeft + case .right: .moveSurfaceToPaneRight + case .up: .moveSurfaceToPaneUp + case .down: .moveSurfaceToPaneDown + } + } + + init?(shortcutAction: KeyboardShortcutSettings.Action) { + switch shortcutAction { + case .moveSurfaceToPreviousPane: self = .previous + case .moveSurfaceToNextPane: self = .next + case .moveSurfaceToPaneLeft: self = .left + case .moveSurfaceToPaneRight: self = .right + case .moveSurfaceToPaneUp: self = .up + case .moveSurfaceToPaneDown: self = .down + default: return nil + } + } + + var commandID: String { + switch self { + case .previous: "palette.moveSurfaceToPreviousPane" + case .next: "palette.moveSurfaceToNextPane" + case .left: "palette.moveSurfaceToPaneLeft" + case .right: "palette.moveSurfaceToPaneRight" + case .up: "palette.moveSurfaceToPaneUp" + case .down: "palette.moveSurfaceToPaneDown" + } + } + + init?(commandID: String) { + guard let movement = Self.allCases.first(where: { $0.commandID == commandID }) else { + return nil + } + self = movement + } + + var title: String { + switch self { + case .previous: + String( + localized: "shortcut.moveSurfaceToPreviousPane.label", + defaultValue: "Move Surface to Previous Pane" + ) + case .next: + String( + localized: "shortcut.moveSurfaceToNextPane.label", + defaultValue: "Move Surface to Next Pane" + ) + case .left: + String( + localized: "shortcut.moveSurfaceToPaneLeft.label", + defaultValue: "Move Surface to Pane on Left" + ) + case .right: + String( + localized: "shortcut.moveSurfaceToPaneRight.label", + defaultValue: "Move Surface to Pane on Right" + ) + case .up: + String( + localized: "shortcut.moveSurfaceToPaneUp.label", + defaultValue: "Move Surface to Pane Above" + ) + case .down: + String( + localized: "shortcut.moveSurfaceToPaneDown.label", + defaultValue: "Move Surface to Pane Below" + ) + } + } + + var keywords: [String] { + switch self { + case .previous: ["move", "surface", "tab", "previous", "pane"] + case .next: ["move", "surface", "tab", "next", "pane"] + case .left: ["move", "surface", "tab", "left", "pane"] + case .right: ["move", "surface", "tab", "right", "pane"] + case .up: ["move", "surface", "tab", "up", "above", "upper", "pane"] + case .down: ["move", "surface", "tab", "down", "below", "lower", "pane"] + } + } +} diff --git a/Sources/Workspace+SurfaceNavigation.swift b/Sources/Workspace+SurfaceNavigation.swift index 995de70cd8c5..0a310fd66f04 100644 --- a/Sources/Workspace+SurfaceNavigation.swift +++ b/Sources/Workspace+SurfaceNavigation.swift @@ -1,8 +1,136 @@ +import Bonsplit import CmuxWorkspaces import Foundation /// Surface navigation and sidebar status helpers extracted from `Workspace.swift`, which sits at its file-length budget. extension Workspace { + /// Moves the focused surface into another pane, optionally creating a + /// directional split when no adjacent pane exists. + @discardableResult + func moveFocusedSurface( + to movement: SurfacePaneMovement, + allowMissingDestinationSplit: Bool = true + ) -> Bool { + guard let panelId = focusedPanelId else { return false } + return moveSurface( + panelId: panelId, + to: movement, + allowMissingDestinationSplit: allowMissingDestinationSplit + ) + } + + /// Moves a surface through the same ownership-transfer path used by + /// same-workspace drag and drop. + @discardableResult + func moveSurface( + panelId: UUID, + to movement: SurfacePaneMovement, + allowMissingDestinationSplit: Bool = true + ) -> Bool { + guard layoutMode != .canvas, + !isRemoteTmuxMirror, + panels[panelId] != nil, + let sourcePaneId = paneId(forPanelId: panelId) else { + return false + } + + let destinationPaneId = destinationPane( + from: sourcePaneId, + for: movement + ) + let directionalSplit = allowMissingDestinationSplit + ? directionalSplit(for: movement) + : nil + guard destinationPaneId != nil || directionalSplit != nil else { + return false + } + + let zoomedPaneId = bonsplitController.zoomedPaneId + if zoomedPaneId != nil { + clearSplitZoom() + } + + let didMove: Bool + if let destinationPaneId { + didMove = moveSurface( + panelId: panelId, + toPane: destinationPaneId, + atIndex: insertionIndexAfterSelectedSurface(in: destinationPaneId), + focus: true + ) + } else if let directionalSplit, + let tabId = surfaceIdFromPanelId(panelId), + let newPaneId = bonsplitController.splitPane( + sourcePaneId, + orientation: directionalSplit.orientation, + movingTab: tabId, + insertFirst: directionalSplit.insertFirst + ) { + bonsplitController.focusPane(newPaneId) + bonsplitController.selectTab(tabId) + focusPanel(panelId) + didMove = true + } else { + didMove = false + } + + if !didMove, let zoomedPaneId { + _ = bonsplitController.togglePaneZoom(inPane: zoomedPaneId) + } + return didMove + } + + private func destinationPane( + from sourcePaneId: PaneID, + for movement: SurfacePaneMovement + ) -> PaneID? { + if let direction = directionalSplit(for: movement)?.direction { + return bonsplitController.adjacentPane( + to: sourcePaneId, + direction: direction + ) + } + + let orderedPaneIds = spatiallyOrderedPaneIds + guard orderedPaneIds.count > 1, + let sourceIndex = orderedPaneIds.firstIndex(of: sourcePaneId.id) else { + return nil + } + let offset = movement == .previous ? -1 : 1 + let destinationIndex = ( + sourceIndex + offset + orderedPaneIds.count + ) % orderedPaneIds.count + let destinationID = orderedPaneIds[destinationIndex] + return bonsplitController.allPaneIds.first { $0.id == destinationID } + } + + private func directionalSplit( + for movement: SurfacePaneMovement + ) -> ( + direction: NavigationDirection, + orientation: SplitOrientation, + insertFirst: Bool + )? { + switch movement { + case .left: (.left, .horizontal, true) + case .right: (.right, .horizontal, false) + case .up: (.up, .vertical, true) + case .down: (.down, .vertical, false) + case .previous, .next: nil + } + } + + private func insertionIndexAfterSelectedSurface(in paneId: PaneID) -> Int { + let destinationTabs = bonsplitController.tabs(inPane: paneId) + guard let selectedTabId = bonsplitController.selectedTab(inPane: paneId)?.id, + let selectedIndex = destinationTabs.firstIndex(where: { + $0.id == selectedTabId + }) else { + return destinationTabs.count + } + return selectedIndex + 1 + } + /// Notification unread lookup for sidebar surface indicators. func hasUnreadNotification(panelId: UUID) -> Bool { AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 004b468aff4d..0bc380a3b661 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8838,16 +8838,6 @@ final class Workspace: Identifiable, ObservableObject { return true } - @discardableResult - private func moveSurfaceToAdjacentPane(panelId: UUID, direction: NavigationDirection) -> Bool { - guard panels[panelId] != nil, - let sourcePaneId = paneId(forPanelId: panelId), - let targetPaneId = bonsplitController.adjacentPane(to: sourcePaneId, direction: direction) else { - return false - } - return moveSurface(panelId: panelId, toPane: targetPaneId, focus: true) - } - func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard let sourcePanel = panels[panelId] else { return nil } @@ -12426,10 +12416,10 @@ extension Workspace: BonsplitDelegate { _ = AppDelegate.shared?.moveBonsplitTabToNewWorkspace(tabId: tab.id.uuid, focus: true, focusWindow: false) case .moveToLeftPane: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - _ = moveSurfaceToAdjacentPane(panelId: panelId, direction: .left) + _ = moveSurface(panelId: panelId, to: .left) case .moveToRightPane: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } - _ = moveSurfaceToAdjacentPane(panelId: panelId, direction: .right) + _ = moveSurface(panelId: panelId, to: .right) case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: diff --git a/Sources/cmuxApp+SurfaceNavigationMenu.swift b/Sources/cmuxApp+SurfaceNavigationMenu.swift new file mode 100644 index 000000000000..0b5133b6b079 --- /dev/null +++ b/Sources/cmuxApp+SurfaceNavigationMenu.swift @@ -0,0 +1,59 @@ +import AppKit +import SwiftUI + +extension cmuxApp { + @ViewBuilder + func surfaceNavigationCommandButtons() -> some View { + splitCommandButton( + title: String( + localized: "menu.view.nextSurface", + defaultValue: "Next Surface" + ), + shortcut: menuShortcut(for: .nextSurface) + ) { + activeTabManager.selectNextSurface() + } + splitCommandButton( + title: String( + localized: "menu.view.previousSurface", + defaultValue: "Previous Surface" + ), + shortcut: menuShortcut(for: .prevSurface) + ) { + activeTabManager.selectPreviousSurface() + } + splitCommandButton( + title: String( + localized: "shortcut.moveSurfaceLeft.label", + defaultValue: "Reorder Surface Left" + ), + shortcut: menuShortcut(for: .moveSurfaceLeft) + ) { + activeTabManager.selectedWorkspace?.moveSelectedSurface(by: -1) + } + splitCommandButton( + title: String( + localized: "shortcut.moveSurfaceRight.label", + defaultValue: "Reorder Surface Right" + ), + shortcut: menuShortcut(for: .moveSurfaceRight) + ) { + activeTabManager.selectedWorkspace?.moveSelectedSurface(by: 1) + } + ForEach(SurfacePaneMovement.allCases, id: \.self) { movement in + splitCommandButton( + title: movement.title, + shortcut: menuShortcut(for: movement.shortcutAction) + ) { + let manager = activeTabManager + if AppDelegate.shared?.performSurfacePaneMovement( + movement, + tabManager: manager, + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) != true { + NSSound.beep() + } + } + } + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e744b421a02d..df470176bc77 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -924,18 +924,7 @@ struct cmuxApp: App { } } Divider() - splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: menuShortcut(for: .nextSurface)) { - activeTabManager.selectNextSurface() - } - 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) - } + surfaceNavigationCommandButtons() splitCommandButton(title: String(localized: "menu.view.back", defaultValue: "Back"), shortcut: menuShortcut(for: .browserBack)) { activeTabManager.focusedBrowserPanel?.goBack() } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6646b2165f8c..94825bfe67ba 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -503,6 +503,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 8295A0038295A0038295A003 /* CmuxAlertScrollableDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8295A0048295A0048295A004 /* CmuxAlertScrollableDetailsView.swift */; }; 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 */; }; + 875200000000000000000012 /* cmuxApp+SurfaceNavigationMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000002 /* cmuxApp+SurfaceNavigationMenu.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 */; }; @@ -780,6 +781,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */; }; C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */; }; C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */; }; + 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */; }; C3408A000000000000000005 /* ContentView+ViewCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; @@ -1042,6 +1044,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 */; }; + 875200000000000000000013 /* KeyboardShortcutSettings+ActionMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000003 /* KeyboardShortcutSettings+ActionMetadata.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 */; }; @@ -1856,6 +1859,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B71010000000000000002 /* StartupBreadcrumbLog.swift */; }; C0DE70530000000000000002 /* submit-cmux-profile in Copy CLI */ = {isa = PBXBuildFile; fileRef = C0DE70530000000000000001 /* submit-cmux-profile */; }; D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; + 875200000000000000000014 /* SurfacePaneMovement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000004 /* SurfacePaneMovement.swift */; }; 842300000000000000000007 /* SurfaceResumeAgentBindingGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842300000000000000000008 /* SurfaceResumeAgentBindingGenerationTests.swift */; }; 883700000000000000000003 /* SurfaceResumeApprovalLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883700000000000000000004 /* SurfaceResumeApprovalLookup.swift */; }; 875900000000000000000005 /* SurfaceResumeApprovalSigningSecretCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875900000000000000000006 /* SurfaceResumeApprovalSigningSecretCache.swift */; }; @@ -2880,6 +2884,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8295A0048295A0048295A004 /* CmuxAlertScrollableDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxAlertScrollableDetailsView.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 = ""; }; + 875200000000000000000002 /* cmuxApp+SurfaceNavigationMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+SurfaceNavigationMenu.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 = ""; }; @@ -3091,6 +3096,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarCommandPalette.swift"; sourceTree = ""; }; C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SavedLayoutCommands.swift"; sourceTree = ""; }; C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarSurfaceKind.swift"; sourceTree = ""; }; + 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SurfaceNavigationCommands.swift"; sourceTree = ""; }; C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ViewCommandPalette.swift"; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewIdentifierCopyCommands.swift; sourceTree = ""; }; @@ -3350,6 +3356,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 = ""; }; + 875200000000000000000003 /* KeyboardShortcutSettings+ActionMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettings+ActionMetadata.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 = ""; }; @@ -4147,6 +4154,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D35B71010000000000000002 /* StartupBreadcrumbLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/StartupBreadcrumbLog.swift; sourceTree = ""; }; C0DE70530000000000000001 /* submit-cmux-profile */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/submit-cmux-profile"; sourceTree = SOURCE_ROOT; }; D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; + 875200000000000000000004 /* SurfacePaneMovement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfacePaneMovement.swift; sourceTree = ""; }; 842300000000000000000008 /* SurfaceResumeAgentBindingGenerationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeAgentBindingGenerationTests.swift; sourceTree = ""; }; 883700000000000000000004 /* SurfaceResumeApprovalLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeApprovalLookup.swift; sourceTree = ""; }; 875900000000000000000006 /* SurfaceResumeApprovalSigningSecretCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeApprovalSigningSecretCache.swift; sourceTree = ""; }; @@ -4969,6 +4977,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7C6F5010000000000000001 /* GhosttyTitleChangeSubscription.swift */, A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */, A5001011 /* cmuxApp.swift */, + 875200000000000000000002 /* cmuxApp+SurfaceNavigationMenu.swift */, C51A73000000000000000014 /* CmuxWorkerEntrypoint.swift */, 80410000000000000000000E /* cmuxApp+WorkspaceCommandHelpers.swift */, CA5CADF00000000000000001 /* CanvasDebugMenuButtons.swift */, @@ -5065,6 +5074,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DEA771000000000000000E /* PricingPlansScreen.swift */, C0DEA7710000000000000010 /* ProWelcomeChecklist.swift */, C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */, + 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */, C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */, C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */, D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, @@ -5263,6 +5273,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A50012F0 /* Backport.swift */, BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, + 875200000000000000000003 /* KeyboardShortcutSettings+ActionMetadata.swift */, 80410000000000000000000C /* KeyboardShortcutSettings+ActionVisibility.swift */, F7216002000000000000002 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */, C0DE7B330000000000000002 /* TextBoxAgentDetection.swift */, @@ -5355,6 +5366,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 804100000000000000000002 /* TabManager+AdjacentWorkspaceReordering.swift */, E3B7A400000000000000020B /* Workspace+WorkspaceSurfaceTreeReading.swift */, CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */, + 875200000000000000000004 /* SurfacePaneMovement.swift */, 822900000000000000000002 /* Workspace+TerminalResizeInteraction.swift */, A5001511 /* UITestRecorder.swift */, A5001520 /* PostHogAnalytics.swift */, @@ -7830,6 +7842,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8295A0038295A0038295A003 /* CmuxAlertScrollableDetailsView.swift in Sources */, E3309A07 /* cmuxApp+EqualizeSplitsMenu.swift in Sources */, C4160A010000000000000001 /* cmuxApp+HistoryMenu.swift in Sources */, + 875200000000000000000012 /* cmuxApp+SurfaceNavigationMenu.swift in Sources */, 80410000000000000000000D /* cmuxApp+WorkspaceCommandHelpers.swift in Sources */, A5001001 /* cmuxApp.swift in Sources */, A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */, @@ -7916,6 +7929,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */, C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */, C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */, + 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */, C3408A000000000000000005 /* ContentView+ViewCommandPalette.swift in Sources */, A5001002 /* ContentView.swift in Sources */, C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */, @@ -8090,6 +8104,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C4A570020000000000000001 /* KeyboardShortcutActionContext.swift in Sources */, C34670030000000000000001 /* KeyboardShortcutContext.swift in Sources */, A50012F7 /* KeyboardShortcutRecorder.swift in Sources */, + 875200000000000000000013 /* KeyboardShortcutSettings+ActionMetadata.swift in Sources */, 80410000000000000000000B /* KeyboardShortcutSettings+ActionVisibility.swift in Sources */, F7216002000000000000001 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, @@ -8632,6 +8647,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */, D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, + 875200000000000000000014 /* SurfacePaneMovement.swift in Sources */, 883700000000000000000003 /* SurfaceResumeApprovalLookup.swift in Sources */, 875900000000000000000005 /* SurfaceResumeApprovalSigningSecretCache.swift in Sources */, 883700000000000000000001 /* SurfaceResumeApprovalSigningSecretResolution.swift in Sources */, diff --git a/cmuxTests/DockShortcutRoutingTests.swift b/cmuxTests/DockShortcutRoutingTests.swift index 51686d0813fb..b12ba65fb296 100644 --- a/cmuxTests/DockShortcutRoutingTests.swift +++ b/cmuxTests/DockShortcutRoutingTests.swift @@ -313,6 +313,116 @@ struct DockShortcutRoutingTests { } } + @Test("Move-to-pane shortcut does not mutate the background workspace while Dock is focused") + @MainActor + func moveToPaneDoesNotMutateBackgroundWorkspaceWhileDockFocused() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + let movedPanelId = try #require( + harness.mainWorkspace.focusedPanelId + ) + let sourcePaneId = try #require( + harness.mainWorkspace.paneId(forPanelId: movedPanelId) + ) + _ = try #require( + harness.mainWorkspace.newTerminalSplit( + from: movedPanelId, + orientation: .horizontal, + focus: false + ) + ) + harness.mainWorkspace.focusPanel(movedPanelId) + let dockPanelBefore = harness.dock.focusedPanelId + let shortcut = Self.customShortcut(key: "y") + KeyboardShortcutSettings.setShortcut( + shortcut, + for: .moveSurfaceToPaneRight + ) + + #expect(Self.dispatch(shortcut, in: harness)) + #expect( + harness.mainWorkspace.paneId(forPanelId: movedPanelId) == + sourcePaneId + ) + #expect(harness.mainWorkspace.focusedPanelId == movedPanelId) + #expect(harness.dock.focusedPanelId == dockPanelBefore) + } + } + } + + @Test("Repeated move-to-pane shortcut does not create a missing pane") + @MainActor + func repeatedMoveToPaneDoesNotCreateMissingPane() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + let movedPanelId = try #require(harness.mainWorkspace.focusedPanelId) + let paneIdsBefore = harness.mainWorkspace.bonsplitController.allPaneIds + let panelIdsBefore = Set(harness.mainWorkspace.panels.keys) + let shortcut = Self.customShortcut(key: "y") + KeyboardShortcutSettings.setShortcut( + shortcut, + for: .moveSurfaceToPaneRight + ) + harness.appDelegate.noteMainPanelKeyboardFocusIntent( + workspaceId: harness.mainWorkspace.id, + panelId: movedPanelId, + in: harness.window + ) + + #expect(Self.dispatch(shortcut, in: harness, isARepeat: true)) + #expect(harness.mainWorkspace.bonsplitController.allPaneIds == paneIdsBefore) + #expect(Set(harness.mainWorkspace.panels.keys) == panelIdsBefore) + #expect(harness.mainWorkspace.focusedPanelId == movedPanelId) + } + } + } + + @Test("Repeated move-to-pane shortcut still uses an existing destination") + @MainActor + func repeatedMoveToPaneUsesExistingDestination() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + let movedPanelId = try #require(harness.mainWorkspace.focusedPanelId) + let sourcePaneId = try #require( + harness.mainWorkspace.paneId(forPanelId: movedPanelId) + ) + _ = try #require( + harness.mainWorkspace.newTerminalSurface( + inPane: sourcePaneId, + focus: false + ) + ) + let destinationPanel = try #require( + harness.mainWorkspace.newTerminalSplit( + from: movedPanelId, + orientation: .horizontal, + focus: false + ) + ) + let destinationPaneId = try #require( + harness.mainWorkspace.paneId(forPanelId: destinationPanel.id) + ) + harness.mainWorkspace.focusPanel(movedPanelId) + let shortcut = Self.customShortcut(key: "y") + KeyboardShortcutSettings.setShortcut( + shortcut, + for: .moveSurfaceToPaneRight + ) + harness.appDelegate.noteMainPanelKeyboardFocusIntent( + workspaceId: harness.mainWorkspace.id, + panelId: movedPanelId, + in: harness.window + ) + + #expect(Self.dispatch(shortcut, in: harness, isARepeat: true)) + #expect( + harness.mainWorkspace.paneId(forPanelId: movedPanelId) == + destinationPaneId + ) + } + } + } + @Test("Simulator shortcuts target the focused Dock Simulator") @MainActor func simulatorShortcutTargetsFocusedDock() async throws { @@ -469,8 +579,12 @@ private extension DockShortcutRoutingTests { } @MainActor - static func dispatch(_ shortcut: AppStoredShortcut, in harness: Harness) -> Bool { - guard let event = event(shortcut, in: harness) else { + static func dispatch( + _ shortcut: AppStoredShortcut, + in harness: Harness, + isARepeat: Bool = false + ) -> Bool { + guard let event = event(shortcut, in: harness, isARepeat: isARepeat) else { return false } #if DEBUG @@ -482,7 +596,8 @@ private extension DockShortcutRoutingTests { static func event( _ shortcut: AppStoredShortcut, - in harness: Harness + in harness: Harness, + isARepeat: Bool = false ) -> NSEvent? { guard !shortcut.isUnbound, !shortcut.hasChord, @@ -498,7 +613,7 @@ private extension DockShortcutRoutingTests { context: nil, characters: shortcut.menuItemKeyEquivalent ?? shortcut.key, charactersIgnoringModifiers: shortcut.menuItemKeyEquivalent ?? shortcut.key, - isARepeat: false, + isARepeat: isARepeat, keyCode: keyCode ) } diff --git a/cmuxTests/ReorderShortcutActionTests.swift b/cmuxTests/ReorderShortcutActionTests.swift index d566f144beb1..1e027a611337 100644 --- a/cmuxTests/ReorderShortcutActionTests.swift +++ b/cmuxTests/ReorderShortcutActionTests.swift @@ -31,22 +31,27 @@ struct ReorderShortcutActionTests { 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)) + _ = try #require(workspace.newTerminalSurface(inPane: paneId, focus: false)) + _ = try #require(workspace.newTerminalSurface(inPane: paneId, focus: false)) workspace.focusPanel(firstPanelId) + let initialOrder = panelOrder(in: workspace, paneId: paneId) + try #require(initialOrder.first == firstPanelId) + let remainingPanelIds = initialOrder.filter { $0 != firstPanelId } + try #require(remainingPanelIds.count == 2) #expect(workspace.moveSelectedSurface(by: 1)) - #expect(panelOrder(in: workspace, paneId: paneId) == [secondPanel.id, firstPanelId, thirdPanel.id]) + let middleOrder = [remainingPanelIds[0], firstPanelId, remainingPanelIds[1]] + #expect(panelOrder(in: workspace, paneId: paneId) == middleOrder) #expect(workspace.focusedPanelId == firstPanelId) #expect(workspace.moveSelectedSurface(by: 1)) - let rightEdgeOrder = [secondPanel.id, thirdPanel.id, firstPanelId] + let rightEdgeOrder = remainingPanelIds + [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]) + #expect(panelOrder(in: workspace, paneId: paneId) == middleOrder) } @Test func singleSurfaceReorderIsANoOp() throws { @@ -65,14 +70,21 @@ struct ReorderShortcutActionTests { 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] + let splitOrder = panelOrder(in: workspace, paneId: splitPaneId) + try #require(splitOrder.first == firstPanelId) - workspace.canvasModel.syncPanes(panelIds: originalOrder, focusedPanelId: firstPanelId) + workspace.canvasModel.syncPanes(panelIds: splitOrder, 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 canvasOrder = try #require( + workspace.canvasModel.layout.panelIds(in: canvasPaneId)?.map(\.rawValue) + ) + try #require(canvasOrder.first == firstPanelId) + let remainingCanvasPanelIds = canvasOrder.filter { $0 != firstPanelId } + try #require(remainingCanvasPanelIds.count == 2) let viewport = ReorderCanvasViewportSpy() workspace.canvasModel.viewport = viewport @@ -83,10 +95,10 @@ struct ReorderShortcutActionTests { #expect(viewport.modelDidChangeCount == 1) #expect( workspace.canvasModel.layout.panelIds(in: canvasPaneId)?.map(\.rawValue) == - [secondPanel.id, firstPanelId, thirdPanel.id] + [remainingCanvasPanelIds[0], firstPanelId, remainingCanvasPanelIds[1]] ) #expect(workspace.focusedPanelId == firstPanelId) - #expect(panelOrder(in: workspace, paneId: splitPaneId) == originalOrder) + #expect(panelOrder(in: workspace, paneId: splitPaneId) == splitOrder) } @Test func selectedWorkspaceMovesWithinItsPinTierAndStaysSelected() { @@ -119,10 +131,16 @@ struct ReorderShortcutActionTests { #expect(manager.tabs.map(\.id) == [secondPinned.id, firstPinned.id, secondUnpinned.id, firstUnpinned.id]) } - @Test func reorderActionsArePublicAndHaveAlignedCollisionFreeDefaults() throws { + @Test func movementActionsArePublicAndHaveAlignedCollisionFreeDefaults() throws { let actions: [KeyboardShortcutSettings.Action] = [ .moveSurfaceLeft, .moveSurfaceRight, + .moveSurfaceToPreviousPane, + .moveSurfaceToNextPane, + .moveSurfaceToPaneLeft, + .moveSurfaceToPaneRight, + .moveSurfaceToPaneUp, + .moveSurfaceToPaneDown, .moveWorkspaceUp, .moveWorkspaceDown, ] @@ -148,6 +166,45 @@ struct ReorderShortcutActionTests { #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveWorkspaceUp") == .moveWorkspaceUp) #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveWorkspaceDown") == .moveWorkspaceDown) + for movement in SurfacePaneMovement.allCases { + #expect( + ContentView.commandPaletteShortcutAction( + forCommandID: movement.commandID + ) == movement.shortcutAction + ) + } + + let previousDefault = + KeyboardShortcutSettings.Action.moveSurfaceToPreviousPane.defaultShortcut + #expect(previousDefault.key == "[") + #expect(previousDefault.command) + #expect(previousDefault.shift) + #expect(!previousDefault.option) + #expect(previousDefault.control) + + let nextDefault = + KeyboardShortcutSettings.Action.moveSurfaceToNextPane.defaultShortcut + #expect(nextDefault.key == "]") + #expect(nextDefault.command) + #expect(nextDefault.shift) + #expect(!nextDefault.option) + #expect(nextDefault.control) + let directionalDefaults: [ + KeyboardShortcutSettings.Action: String + ] = [ + .moveSurfaceToPaneLeft: "←", + .moveSurfaceToPaneRight: "→", + .moveSurfaceToPaneUp: "↑", + .moveSurfaceToPaneDown: "↓", + ] + for (action, key) in directionalDefaults { + let shortcut = action.defaultShortcut + #expect(shortcut.key == key) + #expect(shortcut.command) + #expect(shortcut.shift) + #expect(shortcut.option) + #expect(!shortcut.control) + } } private func panelOrder(in workspace: Workspace, paneId: PaneID) -> [UUID] { diff --git a/cmuxTests/WorkspaceAdjacentPaneMoveTests.swift b/cmuxTests/WorkspaceAdjacentPaneMoveTests.swift index 08f7cbdfef4a..d1409054da4f 100644 --- a/cmuxTests/WorkspaceAdjacentPaneMoveTests.swift +++ b/cmuxTests/WorkspaceAdjacentPaneMoveTests.swift @@ -1,4 +1,6 @@ -import XCTest +import Bonsplit +import Foundation +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,15 +9,435 @@ import XCTest #endif @MainActor -final class WorkspaceAdjacentPaneMoveTests: XCTestCase { - func testTabContextMoveToRightPaneMovesSurfaceToAdjacentPane() throws { +@Suite("Move surface between panes", .serialized) +struct WorkspaceAdjacentPaneMoveTests { + @Test func previousAndNextWrapInSpatialPaneOrder() throws { let workspace = Workspace() - let leftPanelId = try XCTUnwrap(workspace.focusedPanelId) - let leftPaneId = try XCTUnwrap(workspace.paneId(forPanelId: leftPanelId)) - let rightPanel = try XCTUnwrap(workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal, focus: false)) - let rightPaneId = try XCTUnwrap(workspace.paneId(forPanelId: rightPanel.id)) - let leftTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(leftPanelId)) - let leftTab = try XCTUnwrap(workspace.bonsplitController.tab(leftTabId)) + let firstPanelId = try #require(workspace.focusedPanelId) + let firstPaneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let topRightPanel = try #require( + workspace.newTerminalSplit( + from: firstPanelId, + orientation: .horizontal, + focus: false + ) + ) + let bottomRightPanel = try #require( + workspace.newTerminalSplit( + from: topRightPanel.id, + orientation: .vertical, + focus: false + ) + ) + let movedPanel = try #require( + workspace.newTerminalSurface(inPane: firstPaneId, focus: false) + ) + let orderedPaneIds = workspace.spatiallyOrderedPaneIds + #expect(orderedPaneIds.count == 3) + let lastPaneUUID = try #require(orderedPaneIds.last) + let lastPaneId = PaneID(id: lastPaneUUID) + + workspace.focusPanel(movedPanel.id) + #expect(workspace.moveFocusedSurface(to: .previous)) + #expect(workspace.paneId(forPanelId: movedPanel.id) == lastPaneId) + + #expect(workspace.moveFocusedSurface(to: .next)) + #expect(workspace.paneId(forPanelId: movedPanel.id) == firstPaneId) + #expect(workspace.panels[topRightPanel.id] != nil) + #expect(workspace.panels[bottomRightPanel.id] != nil) + } + + @Test func directionalMovementUsesPaneAdjacency() throws { + try expectDirectionalMovement( + .right, + orientation: .horizontal, + fromSecondPane: false + ) + try expectDirectionalMovement( + .left, + orientation: .horizontal, + fromSecondPane: true + ) + try expectDirectionalMovement( + .down, + orientation: .vertical, + fromSecondPane: false + ) + try expectDirectionalMovement( + .up, + orientation: .vertical, + fromSecondPane: true + ) + } + + @Test func insertsAfterDestinationSelectionAndFocusesMovedSurface() throws { + let workspace = Workspace() + let movedPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require(workspace.paneId(forPanelId: movedPanelId)) + let sourceRemainder = try #require( + workspace.newTerminalSurface(inPane: sourcePaneId, focus: false) + ) + let destinationFirst = try #require( + workspace.newTerminalSplit( + from: movedPanelId, + orientation: .horizontal, + focus: false + ) + ) + let destinationPaneId = try #require( + workspace.paneId(forPanelId: destinationFirst.id) + ) + let destinationSelected = try #require( + workspace.newTerminalSurface(inPane: destinationPaneId, focus: false) + ) + let destinationLast = try #require( + workspace.newTerminalSurface(inPane: destinationPaneId, focus: false) + ) + workspace.focusPanel(destinationSelected.id) + workspace.focusPanel(movedPanelId) + + #expect(workspace.moveFocusedSurface(to: .right)) + #expect( + panelOrder(in: workspace, paneId: destinationPaneId) == [ + destinationFirst.id, + destinationSelected.id, + movedPanelId, + destinationLast.id, + ] + ) + #expect(workspace.focusedPanelId == movedPanelId) + let movedTabId = try #require( + workspace.surfaceIdFromPanelId(movedPanelId) + ) + #expect( + workspace.bonsplitController.selectedTab(inPane: destinationPaneId)?.id == + movedTabId + ) + #expect(workspace.paneId(forPanelId: sourceRemainder.id) == sourcePaneId) + } + + @Test func movingSoleTerminalCollapsesSourceAndPreservesInstance() throws { + let workspace = Workspace() + let movedPanelId = try #require(workspace.focusedPanelId) + let terminal = try #require(workspace.panels[movedPanelId] as? TerminalPanel) + let sourcePaneId = try #require(workspace.paneId(forPanelId: movedPanelId)) + let destinationPanel = try #require( + workspace.newTerminalSplit( + from: movedPanelId, + orientation: .horizontal, + focus: false + ) + ) + let destinationPaneId = try #require( + workspace.paneId(forPanelId: destinationPanel.id) + ) + workspace.focusPanel(movedPanelId) + + #expect(workspace.moveFocusedSurface(to: .right)) + #expect(workspace.bonsplitController.allPaneIds.count == 1) + #expect(!workspace.bonsplitController.allPaneIds.contains(sourcePaneId)) + #expect(workspace.paneId(forPanelId: movedPanelId) == destinationPaneId) + #expect((workspace.panels[movedPanelId] as? TerminalPanel) === terminal) + } + + @Test func browserAndWebViewKeepIdentityAcrossPaneTransfer() throws { + let workspace = Workspace() + let terminalPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require( + workspace.paneId(forPanelId: terminalPanelId) + ) + let browser = try #require( + workspace.newBrowserSurface( + inPane: sourcePaneId, + focus: false, + creationPolicy: .restoration + ) + ) + let webView = browser.webView + let destinationPanel = try #require( + workspace.newTerminalSplit( + from: terminalPanelId, + orientation: .horizontal, + focus: false + ) + ) + let destinationPaneId = try #require( + workspace.paneId(forPanelId: destinationPanel.id) + ) + workspace.focusPanel(browser.id) + + #expect(workspace.moveFocusedSurface(to: .right)) + #expect(workspace.paneId(forPanelId: browser.id) == destinationPaneId) + #expect((workspace.panels[browser.id] as? BrowserPanel) === browser) + #expect(browser.webView === webView) + } + + @Test(arguments: [ + SurfacePaneMovement.left, + .right, + .up, + .down, + ]) + func missingDirectionalDestinationCreatesEqualSplit( + _ movement: SurfacePaneMovement + ) throws { + let workspace = Workspace() + let movedPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require( + workspace.paneId(forPanelId: movedPanelId) + ) + let movedTerminal = try #require( + workspace.panels[movedPanelId] as? TerminalPanel + ) + let expectation = try #require( + directionalSplitExpectation(for: movement) + ) + + #expect(workspace.moveFocusedSurface(to: movement)) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + + let destinationPaneId = try #require( + workspace.paneId(forPanelId: movedPanelId) + ) + #expect(destinationPaneId != sourcePaneId) + #expect((workspace.panels[movedPanelId] as? TerminalPanel) === movedTerminal) + #expect(workspace.focusedPanelId == movedPanelId) + #expect(workspace.bonsplitController.focusedPaneId == destinationPaneId) + + let replacementPanelIds = panelOrder(in: workspace, paneId: sourcePaneId) + #expect(replacementPanelIds.count == 1) + let replacementPanelId = try #require(replacementPanelIds.first) + #expect(replacementPanelId != movedPanelId) + #expect(workspace.panels[replacementPanelId] is TerminalPanel) + + guard case .split(let split) = workspace.bonsplitController.treeSnapshot() else { + Issue.record("Expected a split for \(movement)") + return + } + #expect(split.orientation == expectation.orientation) + #expect(abs(split.dividerPosition - 0.5) < 0.000_1) + #expect( + paneId(in: split.first) == + (expectation.insertFirst ? destinationPaneId.id : sourcePaneId.id) + ) + #expect( + paneId(in: split.second) == + (expectation.insertFirst ? sourcePaneId.id : destinationPaneId.id) + ) + } + + @Test(arguments: [ + SurfacePaneMovement.left, + .right, + .up, + .down, + ]) + func missingDirectionalDestinationPreservesParentExtent( + _ movement: SurfacePaneMovement + ) throws { + let workspace = Workspace() + let movedPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require( + workspace.paneId(forPanelId: movedPanelId) + ) + let expectation = try #require( + directionalSplitExpectation(for: movement) + ) + let parentOrientation: SplitOrientation = + expectation.orientation == "horizontal" ? .vertical : .horizontal + let untouchedPanel = try #require( + workspace.newTerminalSplit( + from: movedPanelId, + orientation: parentOrientation, + focus: false, + initialDividerPosition: 0.3 + ) + ) + let untouchedPaneId = try #require( + workspace.paneId(forPanelId: untouchedPanel.id) + ) + workspace.focusPanel(movedPanelId) + + #expect(workspace.moveFocusedSurface(to: movement)) + let destinationPaneId = try #require( + workspace.paneId(forPanelId: movedPanelId) + ) + + guard case .split(let root) = workspace.bonsplitController.treeSnapshot() else { + Issue.record("Expected the parent split for \(movement)") + return + } + #expect( + root.orientation == + (parentOrientation == .horizontal ? "horizontal" : "vertical") + ) + #expect(abs(root.dividerPosition - 0.3) < 0.000_1) + + guard case .split(let sourceRegion) = root.first, + case .pane(let untouchedPane) = root.second else { + Issue.record("Expected only the source region to split for \(movement)") + return + } + #expect(sourceRegion.orientation == expectation.orientation) + #expect(abs(sourceRegion.dividerPosition - 0.5) < 0.000_1) + #expect(UUID(uuidString: untouchedPane.id) == untouchedPaneId.id) + #expect( + paneId(in: sourceRegion.first) == + (expectation.insertFirst ? destinationPaneId.id : sourcePaneId.id) + ) + #expect( + paneId(in: sourceRegion.second) == + (expectation.insertFirst ? sourcePaneId.id : destinationPaneId.id) + ) + } + + @Test func missingDestinationPreservesBrowserAndWebViewIdentity() throws { + let workspace = Workspace() + let terminalPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require( + workspace.paneId(forPanelId: terminalPanelId) + ) + let browser = try #require( + workspace.newBrowserSurface( + inPane: sourcePaneId, + focus: true, + creationPolicy: .restoration + ) + ) + let webView = browser.webView + #expect(workspace.closePanel(terminalPanelId, force: true)) + + #expect(workspace.moveFocusedSurface(to: .right)) + let destinationPaneId = try #require( + workspace.paneId(forPanelId: browser.id) + ) + + #expect(destinationPaneId != sourcePaneId) + #expect((workspace.panels[browser.id] as? BrowserPanel) === browser) + #expect(browser.webView === webView) + #expect(workspace.focusedPanelId == browser.id) + #expect(workspace.bonsplitController.focusedPaneId == destinationPaneId) + + let replacementPanelIds = panelOrder(in: workspace, paneId: sourcePaneId) + #expect(replacementPanelIds.count == 1) + let replacementPanelId = try #require(replacementPanelIds.first) + #expect(replacementPanelId != browser.id) + #expect(workspace.panels[replacementPanelId] is TerminalPanel) + } + + @Test func previousAndNextAreNoOpsWithASinglePane() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: panelId)) + let panel = try #require(workspace.panels[panelId] as? TerminalPanel) + + for movement in [SurfacePaneMovement.previous, .next] { + #expect(!workspace.moveFocusedSurface(to: movement)) + } + + #expect(workspace.bonsplitController.allPaneIds == [paneId]) + #expect(panelOrder(in: workspace, paneId: paneId) == [panelId]) + #expect((workspace.panels[panelId] as? TerminalPanel) === panel) + } + + @Test func failedTransferRestoresSplitZoom() throws { + let workspace = Workspace() + let movedPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require( + workspace.paneId(forPanelId: movedPanelId) + ) + _ = try #require( + workspace.newTerminalSplit( + from: movedPanelId, + orientation: .horizontal, + focus: false + ) + ) + workspace.focusPanel(movedPanelId) + #expect(workspace.toggleSplitZoom(panelId: movedPanelId)) + + var configuration = workspace.bonsplitController.configuration + configuration.allowCrossPaneTabMove = false + workspace.bonsplitController.configuration = configuration + + #expect(!workspace.moveFocusedSurface(to: .right)) + #expect(workspace.bonsplitController.zoomedPaneId == sourcePaneId) + #expect(workspace.paneId(forPanelId: movedPanelId) == sourcePaneId) + #expect(workspace.focusedPanelId == movedPanelId) + } + + @Test func failedDirectionalSplitRestoresZoomAndLayout() throws { + let workspace = Workspace() + let movedPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require( + workspace.paneId(forPanelId: movedPanelId) + ) + _ = try #require( + workspace.newTerminalSplit( + from: movedPanelId, + orientation: .vertical, + focus: false + ) + ) + workspace.focusPanel(movedPanelId) + #expect(workspace.toggleSplitZoom(panelId: movedPanelId)) + let treeBeforeMove = workspace.bonsplitController.treeSnapshot() + + var configuration = workspace.bonsplitController.configuration + configuration.allowCrossPaneTabMove = false + workspace.bonsplitController.configuration = configuration + + #expect(!workspace.moveFocusedSurface(to: .right)) + #expect(workspace.bonsplitController.treeSnapshot() == treeBeforeMove) + #expect(workspace.bonsplitController.zoomedPaneId == sourcePaneId) + #expect(workspace.paneId(forPanelId: movedPanelId) == sourcePaneId) + #expect(workspace.focusedPanelId == movedPanelId) + } + + @Test func rejectsCanvasAndRemoteTmuxLayoutsWithoutMutation() throws { + for unsupportedLayout in UnsupportedLayout.allCases { + let workspace = Workspace() + let movedPanelId = try #require(workspace.focusedPanelId) + let sourcePaneId = try #require( + workspace.paneId(forPanelId: movedPanelId) + ) + let destinationPanel = try #require( + workspace.newTerminalSplit( + from: movedPanelId, + orientation: .horizontal, + focus: false + ) + ) + let destinationPaneId = try #require( + workspace.paneId(forPanelId: destinationPanel.id) + ) + workspace.focusPanel(movedPanelId) + unsupportedLayout.apply(to: workspace) + + #expect(!workspace.moveFocusedSurface(to: .right)) + #expect(workspace.paneId(forPanelId: movedPanelId) == sourcePaneId) + #expect( + panelOrder(in: workspace, paneId: destinationPaneId) == + [destinationPanel.id] + ) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + } + } + + @Test func tabContextActionsUseTheSharedTransferPath() throws { + let workspace = Workspace() + let leftPanelId = try #require(workspace.focusedPanelId) + let leftPaneId = try #require(workspace.paneId(forPanelId: leftPanelId)) + let rightPanel = try #require( + workspace.newTerminalSplit( + from: leftPanelId, + orientation: .horizontal, + focus: false + ) + ) + let rightPaneId = try #require(workspace.paneId(forPanelId: rightPanel.id)) + let leftTabId = try #require(workspace.surfaceIdFromPanelId(leftPanelId)) + let leftTab = try #require(workspace.bonsplitController.tab(leftTabId)) workspace.splitTabBar( workspace.bonsplitController, @@ -24,27 +446,71 @@ final class WorkspaceAdjacentPaneMoveTests: XCTestCase { inPane: leftPaneId ) - XCTAssertEqual(workspace.paneId(forPanelId: leftPanelId), rightPaneId) - XCTAssertTrue(workspace.bonsplitController.tabs(inPane: rightPaneId).contains { $0.id == leftTabId }) + #expect(workspace.paneId(forPanelId: leftPanelId) == rightPaneId) } - func testTabContextMoveToLeftPaneMovesSurfaceToAdjacentPane() throws { - let workspace = Workspace() - let leftPanelId = try XCTUnwrap(workspace.focusedPanelId) - let leftPaneId = try XCTUnwrap(workspace.paneId(forPanelId: leftPanelId)) - let rightPanel = try XCTUnwrap(workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal, focus: false)) - let rightPaneId = try XCTUnwrap(workspace.paneId(forPanelId: rightPanel.id)) - let rightTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(rightPanel.id)) - let rightTab = try XCTUnwrap(workspace.bonsplitController.tab(rightTabId)) + private enum UnsupportedLayout: CaseIterable { + case canvas + case remoteTmuxMirror - workspace.splitTabBar( - workspace.bonsplitController, - didRequestTabContextAction: .moveToLeftPane, - for: rightTab, - inPane: rightPaneId + @MainActor + func apply(to workspace: Workspace) { + switch self { + case .canvas: + workspace.setLayoutMode(.canvas) + case .remoteTmuxMirror: + workspace.isRemoteTmuxMirror = true + } + } + } + + private func expectDirectionalMovement( + _ movement: SurfacePaneMovement, + orientation: SplitOrientation, + fromSecondPane: Bool + ) throws { + let workspace = Workspace() + let firstPanelId = try #require(workspace.focusedPanelId) + let firstPaneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let secondPanel = try #require( + workspace.newTerminalSplit( + from: firstPanelId, + orientation: orientation, + focus: false + ) ) + let secondPaneId = try #require( + workspace.paneId(forPanelId: secondPanel.id) + ) + let sourcePanelId = fromSecondPane ? secondPanel.id : firstPanelId + let expectedPaneId = fromSecondPane ? firstPaneId : secondPaneId + workspace.focusPanel(sourcePanelId) + + #expect(workspace.moveFocusedSurface(to: movement)) + #expect(workspace.paneId(forPanelId: sourcePanelId) == expectedPaneId) + #expect(workspace.focusedPanelId == sourcePanelId) + } + + private func panelOrder(in workspace: Workspace, paneId: PaneID) -> [UUID] { + workspace.bonsplitController.tabs(inPane: paneId).compactMap { + workspace.panelIdFromSurfaceId($0.id) + } + } + + private func directionalSplitExpectation( + for movement: SurfacePaneMovement + ) -> (orientation: String, insertFirst: Bool)? { + switch movement { + case .left: ("horizontal", true) + case .right: ("horizontal", false) + case .up: ("vertical", true) + case .down: ("vertical", false) + case .previous, .next: nil + } + } - XCTAssertEqual(workspace.paneId(forPanelId: rightPanel.id), leftPaneId) - XCTAssertTrue(workspace.bonsplitController.tabs(inPane: leftPaneId).contains { $0.id == rightTabId }) + private func paneId(in node: ExternalTreeNode) -> UUID? { + guard case .pane(let pane) = node else { return nil } + return UUID(uuidString: pane.id) } } diff --git a/skills/cmux-settings/references/shortcut-actions.md b/skills/cmux-settings/references/shortcut-actions.md index 01faede84166..a833fbc6983d 100644 --- a/skills/cmux-settings/references/shortcut-actions.md +++ b/skills/cmux-settings/references/shortcut-actions.md @@ -50,6 +50,12 @@ Values for `shortcuts.bindings.`: - `shortcuts.bindings.prevSurface` - `shortcuts.bindings.moveSurfaceLeft` - `shortcuts.bindings.moveSurfaceRight` +- `shortcuts.bindings.moveSurfaceToPreviousPane` +- `shortcuts.bindings.moveSurfaceToNextPane` +- `shortcuts.bindings.moveSurfaceToPaneLeft` +- `shortcuts.bindings.moveSurfaceToPaneRight` +- `shortcuts.bindings.moveSurfaceToPaneUp` +- `shortcuts.bindings.moveSurfaceToPaneDown` - `shortcuts.bindings.selectSurfaceByNumber` - `shortcuts.bindings.newSurface` - `shortcuts.bindings.toggleTerminalCopyMode` diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 40fb636299a1..90ca7d720991 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -260,8 +260,14 @@ 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: "moveSurfaceLeft", combos: [["⌥", "⌘", "⇧", "["]], description: { en: "Reorder surface left", ja: "サーフェスを左へ並べ替え" } }, + { id: "moveSurfaceRight", combos: [["⌥", "⌘", "⇧", "]"]], description: { en: "Reorder surface right", ja: "サーフェスを右へ並べ替え" } }, + { id: "moveSurfaceToPreviousPane", combos: [["⌃", "⌘", "⇧", "["]], description: { en: "Move surface to previous pane", ja: "サーフェスを前のペインへ移動" } }, + { id: "moveSurfaceToNextPane", combos: [["⌃", "⌘", "⇧", "]"]], description: { en: "Move surface to next pane", ja: "サーフェスを次のペインへ移動" } }, + { id: "moveSurfaceToPaneLeft", combos: [["⌥", "⌘", "⇧", "←"]], description: { en: "Move surface to pane on left", ja: "サーフェスを左のペインへ移動" } }, + { id: "moveSurfaceToPaneRight", combos: [["⌥", "⌘", "⇧", "→"]], description: { en: "Move surface to pane on right", ja: "サーフェスを右のペインへ移動" } }, + { id: "moveSurfaceToPaneUp", combos: [["⌥", "⌘", "⇧", "↑"]], description: { en: "Move surface to pane above", ja: "サーフェスを上のペインへ移動" } }, + { id: "moveSurfaceToPaneDown", combos: [["⌥", "⌘", "⇧", "↓"]], description: { en: "Move surface to pane below", 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 c0d896e9381b..b507d47ce05b 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1615,6 +1615,12 @@ "prevSurface", "moveSurfaceLeft", "moveSurfaceRight", + "moveSurfaceToPreviousPane", + "moveSurfaceToNextPane", + "moveSurfaceToPaneLeft", + "moveSurfaceToPaneRight", + "moveSurfaceToPaneUp", + "moveSurfaceToPaneDown", "selectSurfaceByNumber", "nextSidebarTab", "prevSidebarTab",