diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 6adcb71fb6e8..9f393cd22bd8 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -111,6 +111,10 @@ 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, .moveSurfaceToNextPane, + .moveSurfaceToPaneLeft, .moveSurfaceToPaneRight, + .moveSurfaceToPaneUp, .moveSurfaceToPaneDown: + return nil 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.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index e7ef8c571f76..a6590e093427 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -48,6 +48,14 @@ 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 + case moveSurfaceToPaneLeft + case moveSurfaceToPaneRight + case moveSurfaceToPaneUp + case moveSurfaceToPaneDown case selectSurfaceByNumber case nextSidebarTab case prevSidebarTab @@ -186,7 +194,10 @@ extension ShortcutAction { .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock, .triggerFlash: return .workspace - case .nextSurface, .prevSurface, .moveSurfaceLeft, .moveSurfaceRight, .selectSurfaceByNumber, + 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, @@ -360,8 +371,14 @@ 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/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 329e9c79650e..ea09d8ece398 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -196267,13 +196267,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Move Surface Left" + "value": "Reorder Surface Left" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サーフェスを左へ移動" + "value": "サーフェスを左へ並べ替え" } } } @@ -196284,13 +196284,115 @@ "en": { "stringUnit": { "state": "translated", - "value": "Move Surface Right" + "value": "Reorder Surface Right" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サーフェスを右へ移動" + "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": "サーフェスを前のペインへ移動" } } } diff --git a/Sources/AppDelegate+AdjacentNavigationShortcut.swift b/Sources/AppDelegate+AdjacentNavigationShortcut.swift index 561f18ef201a..0164628a5ead 100644 --- a/Sources/AppDelegate+AdjacentNavigationShortcut.swift +++ b/Sources/AppDelegate+AdjacentNavigationShortcut.swift @@ -1,37 +1,89 @@ import AppKit +import Bonsplit 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 routedTabManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager + ?? tabManager if matchConfiguredShortcut(event: event, action: .nextSurface) { if performFocusedDockShortcut(.selectNextSurface, event: event) { return true } - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectNextSurface() + routedTabManager?.selectNextSurface() return true } if matchConfiguredShortcut(event: event, action: .prevSurface) { if performFocusedDockShortcut(.selectPreviousSurface, event: event) { return true } - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.selectPreviousSurface() + routedTabManager?.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) + routedTabManager?.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) + routedTabManager?.selectedWorkspace?.moveSelectedSurface(by: 1) return true } + if matchConfiguredDirectionalShortcut( + event: event, + action: .moveSurfaceToPreviousPane, + arrowGlyph: "←", + arrowKeyCode: 123 + ) { + performSurfacePaneMoveShortcut(event: event) { + routedTabManager?.moveSelectedSurfaceToPane(offset: -1) == true + } + return true + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .moveSurfaceToNextPane, + arrowGlyph: "→", + arrowKeyCode: 124 + ) { + performSurfacePaneMoveShortcut(event: event) { + routedTabManager?.moveSelectedSurfaceToPane(offset: 1) == true + } + return true + } + let directionalPaneMoveActions: [(KeyboardShortcutSettings.Action, NavigationDirection, String, UInt16)] = [ + (.moveSurfaceToPaneLeft, .left, "←", 123), + (.moveSurfaceToPaneRight, .right, "→", 124), + (.moveSurfaceToPaneUp, .up, "↑", 126), + (.moveSurfaceToPaneDown, .down, "↓", 125), + ] + for (action, direction, arrowGlyph, arrowKeyCode) in directionalPaneMoveActions { + if matchConfiguredDirectionalShortcut( + event: event, + action: action, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) { + performSurfacePaneMoveShortcut(event: event) { + routedTabManager?.moveSelectedSurfaceToAdjacentPane(direction) == true + } + return true + } + } if matchConfiguredShortcut(event: event, action: .moveWorkspaceUp) { - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: -1) + routedTabManager?.moveSelectedWorkspace(by: -1) return true } if matchConfiguredShortcut(event: event, action: .moveWorkspaceDown) { - (preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager)?.moveSelectedWorkspace(by: 1) + routedTabManager?.moveSelectedWorkspace(by: 1) return true } return false } + + private func performSurfacePaneMoveShortcut(event: NSEvent, operation: () -> Bool) { + guard focusedDockStoreForShortcut(preferredWindow: event.window) == nil, + operation() else { + NSSound.beep() + return + } + } } diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 7e5ed04a540d..180809e9ebbd 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -58,6 +58,18 @@ extension ContentView { return .nextSurface case "palette.previousTabInPane": return .prevSurface + case "palette.moveSurfaceToPreviousPane": + return .moveSurfaceToPreviousPane + case "palette.moveSurfaceToNextPane": + return .moveSurfaceToNextPane + case "palette.moveSurfaceToPaneLeft": + return .moveSurfaceToPaneLeft + case "palette.moveSurfaceToPaneRight": + return .moveSurfaceToPaneRight + case "palette.moveSurfaceToPaneUp": + return .moveSurfaceToPaneUp + case "palette.moveSurfaceToPaneDown": + return .moveSurfaceToPaneDown case "palette.browserToggleDevTools": return .toggleBrowserDeveloperTools case "palette.browserConsole": diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9c1b2abcc142..ba348cf89292 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7476,6 +7476,52 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } ) ) + let paneMoveSubtitle = constant( + String(localized: "shortcutDiscovery.section.navigation", defaultValue: "Navigation") + ) + let paneMoveCommands: [(id: String, title: String, keywords: [String])] = [ + ( + "palette.moveSurfaceToPreviousPane", + String(localized: "shortcut.moveSurfaceToPreviousPane.label", defaultValue: "Move Surface to Previous Pane"), + ["move", "surface", "tab", "previous", "pane"] + ), + ( + "palette.moveSurfaceToNextPane", + String(localized: "shortcut.moveSurfaceToNextPane.label", defaultValue: "Move Surface to Next Pane"), + ["move", "surface", "tab", "next", "pane"] + ), + ( + "palette.moveSurfaceToPaneLeft", + String(localized: "shortcut.moveSurfaceToPaneLeft.label", defaultValue: "Move Surface to Pane on Left"), + ["move", "surface", "tab", "left", "pane"] + ), + ( + "palette.moveSurfaceToPaneRight", + String(localized: "shortcut.moveSurfaceToPaneRight.label", defaultValue: "Move Surface to Pane on Right"), + ["move", "surface", "tab", "right", "pane"] + ), + ( + "palette.moveSurfaceToPaneUp", + String(localized: "shortcut.moveSurfaceToPaneUp.label", defaultValue: "Move Surface to Pane Above"), + ["move", "surface", "tab", "up", "above", "upper", "pane"] + ), + ( + "palette.moveSurfaceToPaneDown", + String(localized: "shortcut.moveSurfaceToPaneDown.label", defaultValue: "Move Surface to Pane Below"), + ["move", "surface", "tab", "down", "below", "lower", "pane"] + ), + ] + for command in paneMoveCommands { + contributions.append( + CommandPaletteCommandContribution( + commandId: command.id, + title: constant(command.title), + subtitle: paneMoveSubtitle, + keywords: command.keywords, + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + } contributions.append( CommandPaletteCommandContribution( @@ -8457,6 +8503,42 @@ struct ContentView: View { registry.register(commandId: "palette.previousTabInPane") { tabManager.selectPreviousSurface() } + registry.register(commandId: "palette.moveSurfaceToPreviousPane") { + guard tabManager.moveSelectedSurfaceToPane(offset: -1) else { + NSSound.beep() + return + } + } + registry.register(commandId: "palette.moveSurfaceToNextPane") { + guard tabManager.moveSelectedSurfaceToPane(offset: 1) else { + NSSound.beep() + return + } + } + registry.register(commandId: "palette.moveSurfaceToPaneLeft") { + guard tabManager.moveSelectedSurfaceToAdjacentPane(.left) else { + NSSound.beep() + return + } + } + registry.register(commandId: "palette.moveSurfaceToPaneRight") { + guard tabManager.moveSelectedSurfaceToAdjacentPane(.right) else { + NSSound.beep() + return + } + } + registry.register(commandId: "palette.moveSurfaceToPaneUp") { + guard tabManager.moveSelectedSurfaceToAdjacentPane(.up) else { + NSSound.beep() + return + } + } + registry.register(commandId: "palette.moveSurfaceToPaneDown") { + guard tabManager.moveSelectedSurfaceToAdjacentPane(.down) else { + NSSound.beep() + return + } + } registry.register(commandId: "palette.openWorkspacePullRequests") { DispatchQueue.main.async { if !openWorkspacePullRequestsInConfiguredBrowser() { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 36181e58abc3..ff608ba690cd 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 @@ -231,8 +234,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 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 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") @@ -489,6 +498,10 @@ 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, .moveSurfaceToNextPane, + .moveSurfaceToPaneLeft, .moveSurfaceToPaneRight, + .moveSurfaceToPaneUp, .moveSurfaceToPaneDown: + return .unbound case .selectSurfaceByNumber: return StoredShortcut(key: "1", command: false, shift: false, option: false, control: true) case .newSurface: diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 680e580c141f..d40668193cab 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3699,6 +3699,16 @@ class TabManager: ObservableObject { selectedWorkspace?.selectPreviousSurface() } + @discardableResult + func moveSelectedSurfaceToAdjacentPane(_ direction: NavigationDirection) -> Bool { + selectedWorkspace?.moveSelectedSurfaceToAdjacentPane(direction) == true + } + + @discardableResult + func moveSelectedSurfaceToPane(offset: Int) -> Bool { + selectedWorkspace?.moveSelectedSurfaceToPane(offset: offset) == true + } + /// Select a surface by index in the currently focused pane of the selected workspace func selectSurface(at index: Int) { selectedWorkspace?.selectSurface(at: index) diff --git a/Sources/Workspace+SurfaceNavigation.swift b/Sources/Workspace+SurfaceNavigation.swift index 16a183282f48..c94e7028f75d 100644 --- a/Sources/Workspace+SurfaceNavigation.swift +++ b/Sources/Workspace+SurfaceNavigation.swift @@ -1,8 +1,64 @@ +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 an existing neighboring bonsplit pane. + /// + /// This intentionally delegates the mutation to `moveSurface` so keyboard, + /// command-palette, menu, and local tab-context pane actions share the same + /// ownership transfer, source-pane cleanup, and focus restoration path. + @discardableResult + func moveSelectedSurfaceToAdjacentPane(_ direction: NavigationDirection) -> Bool { + guard let panelId = focusedPanelId else { return false } + return moveSurfaceToAdjacentPane(panelId: panelId, direction: direction) + } + + /// Moves the focused surface to the previous or next pane in the split + /// tree's stable spatial order (top-to-bottom, then left-to-right). + @discardableResult + func moveSelectedSurfaceToPane(offset: Int) -> Bool { + guard offset != 0, + layoutMode != .canvas, + !isRemoteTmuxMirror, + let panelId = focusedPanelId, + let sourcePaneId = paneId(forPanelId: panelId) else { + return false + } + + let orderedPaneIds = spatiallyOrderedPaneIds + guard let sourceIndex = orderedPaneIds.firstIndex(of: sourcePaneId.id) else { + return false + } + guard orderedPaneIds.count > 1 else { return false } + let paneCount = orderedPaneIds.count + let destinationIndex = (sourceIndex + offset % paneCount + paneCount) % paneCount + guard let destinationPaneId = bonsplitController.allPaneIds.first(where: { + $0.id == orderedPaneIds[destinationIndex] + }), + destinationPaneId != sourcePaneId else { + return false + } + + clearSplitZoom() + return moveSurface( + panelId: panelId, + toPane: destinationPaneId, + atIndex: insertionIndexAfterSelectedTab(in: destinationPaneId), + focus: true + ) + } + + func insertionIndexAfterSelectedTab(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 f0dfeb989308..4da1bc9c7031 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8924,13 +8924,21 @@ final class Workspace: Identifiable, ObservableObject { } @discardableResult - private func moveSurfaceToAdjacentPane(panelId: UUID, direction: NavigationDirection) -> Bool { - guard panels[panelId] != nil, + func moveSurfaceToAdjacentPane(panelId: UUID, direction: NavigationDirection) -> Bool { + guard layoutMode != .canvas, + !isRemoteTmuxMirror, + 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) + clearSplitZoom() + return moveSurface( + panelId: panelId, + toPane: targetPaneId, + atIndex: insertionIndexAfterSelectedTab(in: targetPaneId), + focus: true + ) } func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0407b009ee4d..cb3ec9e2d1d4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -926,12 +926,42 @@ 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)) { + 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: "Move Surface Right"), shortcut: menuShortcut(for: .moveSurfaceRight)) { + splitCommandButton(title: String(localized: "shortcut.moveSurfaceRight.label", defaultValue: "Reorder Surface Right"), shortcut: menuShortcut(for: .moveSurfaceRight)) { activeTabManager.selectedWorkspace?.moveSelectedSurface(by: 1) } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceToPreviousPane.label", defaultValue: "Move Surface to Previous Pane"), shortcut: menuShortcut(for: .moveSurfaceToPreviousPane)) { + if !activeTabManager.moveSelectedSurfaceToPane(offset: -1) { + NSSound.beep() + } + } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceToNextPane.label", defaultValue: "Move Surface to Next Pane"), shortcut: menuShortcut(for: .moveSurfaceToNextPane)) { + if !activeTabManager.moveSelectedSurfaceToPane(offset: 1) { + NSSound.beep() + } + } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceToPaneLeft.label", defaultValue: "Move Surface to Pane on Left"), shortcut: menuShortcut(for: .moveSurfaceToPaneLeft)) { + if !activeTabManager.moveSelectedSurfaceToAdjacentPane(.left) { + NSSound.beep() + } + } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceToPaneRight.label", defaultValue: "Move Surface to Pane on Right"), shortcut: menuShortcut(for: .moveSurfaceToPaneRight)) { + if !activeTabManager.moveSelectedSurfaceToAdjacentPane(.right) { + NSSound.beep() + } + } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceToPaneUp.label", defaultValue: "Move Surface to Pane Above"), shortcut: menuShortcut(for: .moveSurfaceToPaneUp)) { + if !activeTabManager.moveSelectedSurfaceToAdjacentPane(.up) { + NSSound.beep() + } + } + splitCommandButton(title: String(localized: "shortcut.moveSurfaceToPaneDown.label", defaultValue: "Move Surface to Pane Below"), shortcut: menuShortcut(for: .moveSurfaceToPaneDown)) { + if !activeTabManager.moveSelectedSurfaceToAdjacentPane(.down) { + NSSound.beep() + } + } 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 099ff3d62817..d0f8720840d7 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1145,6 +1145,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */; }; 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */; }; C75740020000000000000001 /* MouseDownMenuItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75740020000000000000002 /* MouseDownMenuItemView.swift */; }; + 8DC3530647F94C07A3FD9561 /* MoveSurfaceBetweenPanesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285C2ACD13A743B7B81EE9DE /* MoveSurfaceBetweenPanesTests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; D77690000000000000000005 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000006 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift */; }; 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */; }; @@ -3320,6 +3321,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceObserverSignposts.swift; sourceTree = ""; }; CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBridgeInputCapture.swift; sourceTree = ""; }; C75740020000000000000002 /* MouseDownMenuItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/MouseDownMenuItemView.swift; sourceTree = ""; }; + 285C2ACD13A743B7B81EE9DE /* MoveSurfaceBetweenPanesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveSurfaceBetweenPanesTests.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; D77690000000000000000006 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsWorkspaceHeadlineUITests.swift; sourceTree = ""; }; 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeNotificationDeliveryHooks.swift; sourceTree = ""; }; @@ -6281,6 +6283,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */, D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */, 804100000000000000000004 /* ReorderShortcutActionTests.swift */, + 285C2ACD13A743B7B81EE9DE /* MoveSurfaceBetweenPanesTests.swift */, D7AB3605C10DEF0000000002 /* WorkspaceCloseTabsContextMenuTests.swift */, 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */, D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */, @@ -8938,6 +8941,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F5A700000000000000000001 /* MobileTaskFilesystemJobQuotaTests.swift in Sources */, F6448F377504F33956E7E03B /* MobileWorkspaceListFidelityTests.swift in Sources */, 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */, + 8DC3530647F94C07A3FD9561 /* MoveSurfaceBetweenPanesTests.swift in Sources */, 596100000000000000000001 /* NativeNotificationFallbackCommandTests.swift in Sources */, A5FB1400 /* NewWorkspaceMenuModelTests.swift in Sources */, 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */, diff --git a/cmuxTests/MoveSurfaceBetweenPanesTests.swift b/cmuxTests/MoveSurfaceBetweenPanesTests.swift new file mode 100644 index 000000000000..b51ae86362b3 --- /dev/null +++ b/cmuxTests/MoveSurfaceBetweenPanesTests.swift @@ -0,0 +1,226 @@ +import AppKit +import Bonsplit +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Move selected surface between panes", .serialized) +struct MoveSurfaceBetweenPanesTests { + @Test func movesOnlyTheSelectedSurfaceIntoTheAdjacentPaneAndKeepsItFocused() throws { + let manager = TabManager() + let workspace = try #require(manager.selectedWorkspace) + let selectedPanelId = try #require(workspace.focusedPanelId) + let selectedPanel = try #require(workspace.terminalPanel(for: selectedPanelId)) + let sourcePaneId = try #require(workspace.paneId(forPanelId: selectedPanelId)) + let remainingPanel = try #require(workspace.newTerminalSurface(inPane: sourcePaneId, focus: false)) + let destinationPanel = try #require( + workspace.newTerminalSplit(from: selectedPanelId, orientation: .horizontal) + ) + let destinationPaneId = try #require(workspace.paneId(forPanelId: destinationPanel.id)) + let trailingPanel = try #require( + workspace.newTerminalSurface(inPane: destinationPaneId, focus: false) + ) + workspace.focusPanel(selectedPanelId) + + #expect(manager.moveSelectedSurfaceToAdjacentPane(.right)) + #expect(workspace.paneId(forPanelId: selectedPanelId) == destinationPaneId) + #expect(workspace.terminalPanel(for: selectedPanelId) === selectedPanel) + #expect(workspace.paneId(forPanelId: remainingPanel.id) == sourcePaneId) + #expect(workspace.focusedPanelId == selectedPanelId) + #expect(workspace.bonsplitController.focusedPaneId == destinationPaneId) + let destinationPanelOrder = workspace.bonsplitController.tabs(inPane: destinationPaneId).compactMap { + workspace.panelIdFromSurfaceId($0.id) + } + #expect(destinationPanelOrder == [destinationPanel.id, selectedPanelId, trailingPanel.id]) + } + + @Test func movingTheOnlySurfaceOutOfAPaneCollapsesTheEmptySourcePane() throws { + let workspace = Workspace() + let selectedPanelId = try #require(workspace.focusedPanelId) + let destinationPanel = try #require( + workspace.newTerminalSplit(from: selectedPanelId, orientation: .horizontal) + ) + let destinationPaneId = try #require(workspace.paneId(forPanelId: destinationPanel.id)) + workspace.focusPanel(selectedPanelId) + #expect(workspace.bonsplitController.allPaneIds.count == 2) + + #expect(workspace.moveSelectedSurfaceToAdjacentPane(.right)) + #expect(workspace.bonsplitController.allPaneIds.count == 1) + #expect(workspace.paneId(forPanelId: selectedPanelId) == destinationPaneId) + #expect(workspace.paneId(forPanelId: destinationPanel.id) == destinationPaneId) + #expect(workspace.focusedPanelId == selectedPanelId) + } + + @Test func movesBrowserSurfacesThroughTheSameTransferPath() throws { + let workspace = Workspace() + let terminalPanelId = try #require(workspace.focusedPanelId) + let browserPanel = try #require( + workspace.newBrowserSplit(from: terminalPanelId, orientation: .horizontal) + ) + let terminalPaneId = try #require(workspace.paneId(forPanelId: terminalPanelId)) + workspace.focusPanel(browserPanel.id) + + #expect(workspace.moveSelectedSurfaceToAdjacentPane(.left)) + #expect(workspace.paneId(forPanelId: browserPanel.id) == terminalPaneId) + #expect(workspace.browserPanel(for: browserPanel.id) === browserPanel) + #expect(workspace.focusedPanelId == browserPanel.id) + } + + @Test func movesVerticallyThroughTheDirectionalAdjacencyResolver() throws { + let workspace = Workspace() + let upperPanelId = try #require(workspace.focusedPanelId) + let lowerPanel = try #require( + workspace.newTerminalSplit(from: upperPanelId, orientation: .vertical) + ) + let lowerPaneId = try #require(workspace.paneId(forPanelId: lowerPanel.id)) + _ = try #require(workspace.newTerminalSurface(inPane: lowerPaneId, focus: false)) + let upperPaneId = try #require(workspace.paneId(forPanelId: upperPanelId)) + workspace.focusPanel(lowerPanel.id) + + #expect(workspace.moveSelectedSurfaceToAdjacentPane(.up)) + #expect(workspace.paneId(forPanelId: lowerPanel.id) == upperPaneId) + #expect(workspace.moveSelectedSurfaceToAdjacentPane(.down)) + #expect(workspace.paneId(forPanelId: lowerPanel.id) == lowerPaneId) + } + + @Test func previousAndNextFollowTheSpatialPaneOrder() throws { + let workspace = Workspace() + let firstPanelId = try #require(workspace.focusedPanelId) + let firstPaneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + _ = try #require(workspace.newTerminalSurface(inPane: firstPaneId, focus: false)) + let secondPanel = try #require(workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal)) + let secondPaneId = try #require(workspace.paneId(forPanelId: secondPanel.id)) + _ = try #require(workspace.newTerminalSurface(inPane: secondPaneId, focus: false)) + _ = try #require(workspace.newTerminalSplit(from: secondPanel.id, orientation: .vertical)) + let orderedPaneIds = workspace.spatiallyOrderedPaneIds + #expect(orderedPaneIds.count == 3) + + workspace.focusPanel(secondPanel.id) + #expect(workspace.moveSelectedSurfaceToPane(offset: 1)) + #expect(workspace.paneId(forPanelId: secondPanel.id)?.id == orderedPaneIds[2]) + #expect(workspace.focusedPanelId == secondPanel.id) + + #expect(workspace.moveSelectedSurfaceToPane(offset: -1)) + #expect(workspace.paneId(forPanelId: secondPanel.id)?.id == orderedPaneIds[1]) + #expect(workspace.focusedPanelId == secondPanel.id) + + workspace.focusPanel(firstPanelId) + #expect(workspace.moveSelectedSurfaceToPane(offset: -1)) + #expect(workspace.paneId(forPanelId: firstPanelId)?.id == orderedPaneIds[2]) + #expect(workspace.moveSelectedSurfaceToPane(offset: 1)) + #expect(workspace.paneId(forPanelId: firstPanelId)?.id == orderedPaneIds[0]) + } + + @Test func missingDestinationIsANoOp() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: panelId)) + + #expect(!workspace.moveSelectedSurfaceToAdjacentPane(.left)) + #expect(!workspace.moveSelectedSurfaceToPane(offset: -1)) + #expect(workspace.paneId(forPanelId: panelId) == paneId) + #expect(workspace.focusedPanelId == panelId) + } + + @Test func canvasLayoutDoesNotMutateTheBonsplitTree() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let destinationPanel = try #require(workspace.newTerminalSplit(from: panelId, orientation: .horizontal)) + let originalPaneId = try #require(workspace.paneId(forPanelId: panelId)) + workspace.focusPanel(panelId) + workspace.setLayoutMode(.canvas) + + #expect(!workspace.moveSelectedSurfaceToAdjacentPane(.right)) + #expect(workspace.paneId(forPanelId: panelId) == originalPaneId) + #expect(workspace.paneId(forPanelId: destinationPanel.id) != originalPaneId) + } + + @Test func remoteTmuxMirrorDoesNotMutateTheLocalPaneTree() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + _ = try #require(workspace.newTerminalSplit(from: panelId, orientation: .horizontal)) + let originalPaneId = try #require(workspace.paneId(forPanelId: panelId)) + workspace.focusPanel(panelId) + workspace.isRemoteTmuxMirror = true + + #expect(!workspace.moveSelectedSurfaceToAdjacentPane(.right)) + #expect(!workspace.moveSelectedSurfaceToPane(offset: 1)) + #expect(workspace.paneId(forPanelId: panelId) == originalPaneId) + } + + @Test func shortcutAndCommandPaletteMetadataIsCompleteAndUnambiguous() throws { + let paneMoveActions: [KeyboardShortcutSettings.Action] = [ + .moveSurfaceToPreviousPane, + .moveSurfaceToNextPane, + .moveSurfaceToPaneLeft, + .moveSurfaceToPaneRight, + .moveSurfaceToPaneUp, + .moveSurfaceToPaneDown, + ] + + for action in paneMoveActions { + #expect(KeyboardShortcutSettings.publicShortcutActions.contains(action)) + #expect(KeyboardShortcutSettings.settingsVisibleActions.contains(action)) + let settingsAction = try #require(ShortcutAction(rawValue: action.rawValue)) + #expect(settingsAction.displayName == action.label) + #expect(settingsAction.defaultStroke == nil) + #expect(action.defaultShortcut == .unbound) + } + + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveSurfaceToPreviousPane") == .moveSurfaceToPreviousPane) + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveSurfaceToNextPane") == .moveSurfaceToNextPane) + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveSurfaceToPaneLeft") == .moveSurfaceToPaneLeft) + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveSurfaceToPaneRight") == .moveSurfaceToPaneRight) + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveSurfaceToPaneUp") == .moveSurfaceToPaneUp) + #expect(ContentView.commandPaletteShortcutAction(forCommandID: "palette.moveSurfaceToPaneDown") == .moveSurfaceToPaneDown) + } + + @Test func optionArrowMovementUsesPhysicalDirectionalRouting() throws { + let appDelegate = try #require(AppDelegate.shared) + let action = KeyboardShortcutSettings.Action.moveSurfaceToPaneLeft + let defaults = UserDefaults.standard + let originalShortcutValue = defaults.object(forKey: action.defaultsKey) + let originalSettingsFileStore = KeyboardShortcutSettings.installIsolatedTestFileStore( + prefix: "cmux-move-surface-pane-shortcut" + ) + defer { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + if let originalShortcutValue { + defaults.set(originalShortcutValue, forKey: action.defaultsKey) + } else { + defaults.removeObject(forKey: action.defaultsKey) + } + appDelegate.debugResetShortcutRoutingStateForTesting() + } + let shortcut = StoredShortcut( + key: "←", + command: false, + shift: false, + option: true, + control: false + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: action) + appDelegate.debugResetShortcutRoutingStateForTesting() + let event = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.option], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 123 + )) + + #expect(appDelegate.handleAdjacentNavigationShortcut(event: event)) + } +}