diff --git a/rootshell-helper/Tests/TmuxSplitEqualizationTests.swift b/rootshell-helper/Tests/TmuxSplitEqualizationTests.swift index f7a5a1b30..77bde1ffb 100644 --- a/rootshell-helper/Tests/TmuxSplitEqualizationTests.swift +++ b/rootshell-helper/Tests/TmuxSplitEqualizationTests.swift @@ -6,6 +6,48 @@ import XCTest /// where Process is available. No user's tmux server or configuration is used. @MainActor final class TmuxSplitEqualizationTests: XCTestCase { + func testSwapPickerPreservesSourceFocusZoomAndReplyAlignment() throws { + for zoomed in [false, true] { + let server = try Server() + defer { server.stop() } + try server.cli(["split-window", "-h", "-t", "%0"]) + try server.cli(["split-window", "-h", "-t", "%1"]) + let control = try server.attach() + defer { control.stop() } + try control.command("select-pane -t @0.%0") + let original = try control.command("display-message -p -t @0 '#{window_layout}'") + if zoomed { try control.command("resize-pane -Z -t @0.%0") } + let swap = try TmuxPaneZoomCommand.swapCommand(windowID: 0, sourcePaneID: 0, targetPaneID: 2) + try control.command(swap) + let changed = try XCTUnwrap(TmuxLayoutNode.parseServerLayout( + control.command("display-message -p -t @0 '#{window_layout}'"))) + XCTAssertEqual(changed.paneIDs, [2, 1, 0]) + XCTAssertEqual(try control.command("display-message -p -t @0 '#{window_zoomed_flag}:#{pane_id}'"), + "\(zoomed ? 1 : 0):%0") + XCTAssertEqual(try control.command("display-message -p swap-reply-marker"), "swap-reply-marker") + try control.command(swap) + XCTAssertEqual(try control.command("display-message -p -t @0 '#{window_layout}'"), original) + } + } + + func testSwapPickerCannotFollowEitherPaneToAnotherWindow() throws { + for movedPane in [0, 2] { + let server = try Server() + defer { server.stop() } + try server.cli(["split-window", "-h", "-t", "%0"]) + try server.cli(["split-window", "-h", "-t", "%1"]) + try server.cli(["new-window", "-d"]) + try server.cli(["join-pane", "-s", "@0.%\(movedPane)", "-t", "@1"]) + let control = try server.attach() + defer { control.stop() } + let before = try control.command("list-windows -F '#{window_id}:#{window_layout}'") + let command = try TmuxPaneZoomCommand.swapCommand(windowID: 0, sourcePaneID: 0, targetPaneID: 2) + XCTAssertThrowsError(try control.command(command)) + XCTAssertEqual(try control.command("list-windows -F '#{window_id}:#{window_layout}'"), before) + XCTAssertEqual(try control.command("display-message -p stale-swap-reply-marker"), "stale-swap-reply-marker") + } + } + func testPanePickerSelectsAndEnsuresZoomWithoutShiftingControlReplies() async throws { let server = try Server() defer { server.stop() } diff --git a/rootshell/App/AppCommands.swift b/rootshell/App/AppCommands.swift index b9e93b4f6..4c112b82c 100644 --- a/rootshell/App/AppCommands.swift +++ b/rootshell/App/AppCommands.swift @@ -19,7 +19,15 @@ private struct CanChoosePaneToZoomKey: FocusedValueKey { typealias Value = Bool } +private struct CanChoosePaneToSwapKey: FocusedValueKey { + typealias Value = Bool +} + extension FocusedValues { + var canChoosePaneToSwap: Bool? { + get { self[CanChoosePaneToSwapKey.self] } + set { self[CanChoosePaneToSwapKey.self] = newValue } + } var canChoosePaneToZoom: Bool? { get { self[CanChoosePaneToZoomKey.self] } set { self[CanChoosePaneToZoomKey.self] = newValue } @@ -410,6 +418,7 @@ struct AppViewCommands: Commands { struct TerminalCommands: Commands { @ObservedObject var shortcutState: MenuShortcutState @FocusedValue(\.canChoosePaneToZoom) private var canChoosePaneToZoom + @FocusedValue(\.canChoosePaneToSwap) private var canChoosePaneToSwap var body: some Commands { CommandMenu("Terminal") { @@ -494,6 +503,14 @@ struct TerminalCommands: Commands { .modifier(DynamicShortcut(action: .choose_pane_to_zoom, shortcuts: shortcutState.shortcuts)) .disabled(canChoosePaneToZoom != true) + Button("Choose Pane to Swap") { + UIApplication.shared.sendMenuAction( + #selector(Ghostty.TerminalView.menuChoosePaneToSwap(_:)), from: nil + ) + } + .modifier(DynamicShortcut(action: .choose_pane_to_swap, shortcuts: shortcutState.shortcuts)) + .disabled(canChoosePaneToSwap != true) + Divider() // Scroll commands diff --git a/rootshell/App/CatalystAppDelegate.swift b/rootshell/App/CatalystAppDelegate.swift index f63242efb..60c1ff130 100644 --- a/rootshell/App/CatalystAppDelegate.swift +++ b/rootshell/App/CatalystAppDelegate.swift @@ -230,6 +230,10 @@ extension UIApplication { sendMenuAction(#selector(Ghostty.TerminalView.menuChoosePaneToZoom(_:)), from: sender) } + @objc func ghostty_choosePaneToSwap(_ sender: Any?) { + sendMenuAction(#selector(Ghostty.TerminalView.menuChoosePaneToSwap(_:)), from: sender) + } + @objc func ghostty_toggleTabBar(_ sender: Any?) { sendAction(#selector(Ghostty.TerminalView.menuToggleTabBar(_:)), to: nil, from: sender, for: nil) } @@ -1318,7 +1322,9 @@ class CatalystAppDelegate: AppDelegate { let splitManageGroup = UIMenu(title: "", options: .displayInline, children: [ toggleZoom, equalize, UICommand(title: String(localized: "Choose Pane to Zoom"), - action: #selector(UIApplication.ghostty_choosePaneToZoom(_:))) + action: #selector(UIApplication.ghostty_choosePaneToZoom(_:))), + UICommand(title: String(localized: "Choose Pane to Swap"), + action: #selector(UIApplication.ghostty_choosePaneToSwap(_:))) ]) // Scroll commands diff --git a/rootshell/App/UIApplication+CommandFallback.swift b/rootshell/App/UIApplication+CommandFallback.swift index 0ce603e80..d1c91fdea 100644 --- a/rootshell/App/UIApplication+CommandFallback.swift +++ b/rootshell/App/UIApplication+CommandFallback.swift @@ -215,6 +215,10 @@ extension UIApplication { ghostty_postNotification(.choosePaneToZoom) } + @objc func menuChoosePaneToSwap(_ sender: Any?) { + ghostty_postNotification(.choosePaneToSwap) + } + @objc func menuOpenSettings(_ sender: Any?) { ghostty_postNotification( .openSettings, diff --git a/rootshell/Core/Keybinds/KeybindAction.swift b/rootshell/Core/Keybinds/KeybindAction.swift index 852aedf2a..f3bce3d7d 100644 --- a/rootshell/Core/Keybinds/KeybindAction.swift +++ b/rootshell/Core/Keybinds/KeybindAction.swift @@ -137,6 +137,8 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { /// Show numbered targets for zooming a tmux or herdr control-mode pane. /// Keep the original raw value so saved shortcut overrides continue to work. case choose_pane_to_zoom = "choose_tmux_pane_to_zoom" + /// Swap the focused tmux control-mode pane with a numbered target. + case choose_pane_to_swap = "choose_tmux_pane_to_swap" // Shell Operations /// Open settings @@ -317,7 +319,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .split_right, .split_down, .navigate_split_left, .navigate_split_right, .navigate_split_up, .navigate_split_down, - .toggle_split_zoom, .equalize_splits, .choose_pane_to_zoom: + .toggle_split_zoom, .equalize_splits, .choose_pane_to_zoom, .choose_pane_to_swap: return .splits case .increase_font_size, .decrease_font_size, .reset_font_size, .start_search, @@ -390,6 +392,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .toggle_split_zoom: return String(localized: "Toggle Split Zoom", comment: "Keybind action") case .equalize_splits: return String(localized: "Equalize Splits", comment: "Keybind action") case .choose_pane_to_zoom: return String(localized: "Choose Pane to Zoom", comment: "Keybind action") + case .choose_pane_to_swap: return String(localized: "Choose Pane to Swap", comment: "Keybind action") case .toggle_visor: return String(localized: "Toggle Visor") case .toggle_quick_settings: return String(localized: "Quick Settings") @@ -483,6 +486,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .toggle_split_zoom: return .toggleSplitZoom case .equalize_splits: return .equalizeSplits case .choose_pane_to_zoom: return .choosePaneToZoom + case .choose_pane_to_swap: return .choosePaneToSwap case .open_settings: return .openSettings case .toggle_visor: return .toggleVisorOverlay @@ -627,7 +631,8 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { .select_tab_4, .select_tab_5, .select_tab_6, .select_tab_7, .select_tab_8, .select_tab_9, .split_right, .split_down, .navigate_split_left, .navigate_split_right, .navigate_split_up, .navigate_split_down, - .toggle_split_zoom, .equalize_splits, .choose_pane_to_zoom, .open_settings, .toggle_quick_settings, .open_in_folder, + .toggle_split_zoom, .equalize_splits, .choose_pane_to_zoom, .choose_pane_to_swap, + .open_settings, .toggle_quick_settings, .open_in_folder, .toggle_file_manager, .browse_hosts, .browse_profiles, .toggle_ai_agent, .toggle_voice_agent, .toggle_tab_bar, .toggle_group_mode, .toggle_transparency, .toggle_titlebar, .toggle_auto_redact, diff --git a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift index 07d730f37..f9d4ff25f 100644 --- a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift +++ b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift @@ -126,7 +126,8 @@ final class KeybindCommandGenerator: ObservableObject { .select_tab_4, .select_tab_5, .select_tab_6, .select_tab_7, .select_tab_8, .select_tab_9, .split_right, .split_down, .navigate_split_left, .navigate_split_right, .navigate_split_up, .navigate_split_down, - .toggle_split_zoom, .equalize_splits, .choose_pane_to_zoom, .open_settings, .toggle_quick_settings, .open_in_folder, + .toggle_split_zoom, .equalize_splits, .choose_pane_to_zoom, .choose_pane_to_swap, + .open_settings, .toggle_quick_settings, .open_in_folder, .toggle_file_manager, .browse_hosts, .browse_profiles, .toggle_ai_agent, .toggle_voice_agent, .toggle_tab_bar, .toggle_group_mode, .toggle_tab_switcher, .toggle_tab_expose, .previous_group, .next_group, .show_tmux_sessions, .discover_sessions, diff --git a/rootshell/Core/Keybinds/KeybindManager.swift b/rootshell/Core/Keybinds/KeybindManager.swift index bdffa59aa..38eff9e46 100644 --- a/rootshell/Core/Keybinds/KeybindManager.swift +++ b/rootshell/Core/Keybinds/KeybindManager.swift @@ -130,6 +130,7 @@ final class KeybindManager: ObservableObject { Keybind(key: .enter, modifiers: [.command, .shift], action: .toggle_split_zoom), Keybind(key: .e, modifiers: [.command, .shift], action: .equalize_splits), Keybind(key: .p, modifiers: [.command, .option], action: .choose_pane_to_zoom), + Keybind(key: .s, modifiers: [.command, .option], action: .choose_pane_to_swap), // View Keybind(key: .equal, modifiers: .command, action: .increase_font_size), diff --git a/rootshell/Features/Multiplexer/PaneZoomSelection.swift b/rootshell/Features/Multiplexer/PaneZoomSelection.swift index 30bd1c003..49670fe2b 100644 --- a/rootshell/Features/Multiplexer/PaneZoomSelection.swift +++ b/rootshell/Features/Multiplexer/PaneZoomSelection.swift @@ -14,11 +14,13 @@ struct PaneZoomSelection { private(set) var prefix = "" private(set) var result: Result = .pending - init?(paneIDs: [PaneID]) { + init?(paneIDs: [PaneID], excludingPaneID: PaneID? = nil) { guard paneIDs.count > 1, Set(paneIDs).count == paneIDs.count else { return nil } - self.paneIDs = paneIDs - let width = String(paneIDs.count).count - labels = (1...paneIDs.count).map { + if let excludingPaneID, !paneIDs.contains(excludingPaneID) { return nil } + let candidates = paneIDs.filter { $0 != excludingPaneID } + self.paneIDs = candidates + let width = String(candidates.count).count + labels = (1...candidates.count).map { let number = String($0) return String(repeating: "0", count: width - number.count) + number } diff --git a/rootshell/Features/Tmux/TmuxController.swift b/rootshell/Features/Tmux/TmuxController.swift index 06d073bbb..1c5911d32 100644 --- a/rootshell/Features/Tmux/TmuxController.swift +++ b/rootshell/Features/Tmux/TmuxController.swift @@ -1316,11 +1316,24 @@ final class TmuxController { /// Commit a picker selection, using stable pane IDs from its frozen layout. func requestZoomPane(windowID: Int, paneID: Int, expectedPaneIDs: Set) { + requestPaneSelection(windowID: windowID, paneID: paneID, sourcePaneID: nil, expectedPaneIDs: expectedPaneIDs) + } + + func requestSwapPane(windowID: Int, sourcePaneID: Int, targetPaneID: Int, expectedPaneIDs: Set) { + guard sourcePaneID != targetPaneID else { return } + requestPaneSelection(windowID: windowID, paneID: targetPaneID, sourcePaneID: sourcePaneID, + expectedPaneIDs: expectedPaneIDs) + } + + private func requestPaneSelection(windowID: Int, paneID: Int, sourcePaneID: Int?, expectedPaneIDs: Set) { guard isActive, !paneZoomSelectionWindows.contains(windowID), let tab = windowTabs[windowID], !tab.paneMove.isPending, let layout = appliedLayout(for: windowID), Set(layout.paneIDs) == expectedPaneIDs, expectedPaneIDs.contains(paneID), - let pane = paneViews[paneID], pane.tmuxPaneBinding?.windowId == windowID + expectedPaneIDs.contains(sourcePaneID ?? paneID), + paneViews[paneID]?.tmuxPaneBinding?.windowId == windowID, + let pane = paneViews[sourcePaneID ?? paneID], pane.tmuxPaneBinding?.windowId == windowID, + sourcePaneID == nil || tab.focusedTerminal === pane else { return } paneZoomSelectionWindows.insert(windowID) Task { @MainActor [weak self, weak tab, weak pane] in @@ -1333,22 +1346,33 @@ final class TmuxController { (self.modelContainingTab(id: tab.id) ?? self.tabsModel).selectedTabID == tab.id else { return } do { - try await TmuxPaneZoomCommand.zoom(windowID: windowID, paneID: paneID) { command in + @MainActor func send(_ command: String) async throws -> String { guard self.isActive, self.windowTabs[windowID] === tab, !tab.paneMove.isPending, self.appliedLayout(for: windowID)?.hasSameTopology(as: layout) == true, pane.tmuxPaneBinding?.windowId == windowID, + self.paneViews[paneID]?.tmuxPaneBinding?.windowId == windowID, + sourcePaneID == nil || tab.focusedTerminal === pane, (self.modelContainingTab(id: tab.id) ?? self.tabsModel).selectedTabID == tab.id else { throw TmuxPaneZoomCommand.Failure.layoutChanged } return try await self.sendCommandWithReply(command) } + if let sourcePaneID { + let command = try TmuxPaneZoomCommand.swapCommand(windowID: windowID, + sourcePaneID: sourcePaneID, + targetPaneID: paneID) + _ = try await send(command) + } else { + try await TmuxPaneZoomCommand.zoom(windowID: windowID, paneID: paneID, send: send) + } guard self.isActive, self.windowTabs[windowID] === tab, pane.tmuxPaneBinding?.windowId == windowID, + sourcePaneID == nil || tab.focusedTerminal === pane, (self.modelContainingTab(id: tab.id) ?? self.tabsModel).selectedTabID == tab.id else { return } self.focusPane(pane, in: tab) } catch { - TmuxDebugLogger.shared.event("LAYOUT", "pane zoom selection failed: \(error)") + TmuxDebugLogger.shared.event("LAYOUT", "pane selection failed: \(error)") } } } diff --git a/rootshell/Features/Tmux/TmuxPaneZoomCommand.swift b/rootshell/Features/Tmux/TmuxPaneZoomCommand.swift index 1e41c73d4..5b494a4fd 100644 --- a/rootshell/Features/Tmux/TmuxPaneZoomCommand.swift +++ b/rootshell/Features/Tmux/TmuxPaneZoomCommand.swift @@ -4,6 +4,14 @@ import Foundation enum TmuxPaneZoomCommand { enum Failure: Error { case invalidTarget, invalidReply, layoutChanged } + /// One native command preserves the active pane and existing zoom. Qualify + /// both targets so neither can be followed into a different window. + static func swapCommand(windowID: Int, sourcePaneID: Int, targetPaneID: Int) throws -> String { + guard windowID >= 0, sourcePaneID >= 0, targetPaneID >= 0, + sourcePaneID != targetPaneID else { throw Failure.invalidTarget } + return "swap-pane -d -Z -s @\(windowID).%\(sourcePaneID) -t @\(windowID).%\(targetPaneID)" + } + /// Preserve existing zoom when switching, then query the server before /// deciding whether to zoom. Each send has exactly one control-mode reply: /// if-shell / compound commands would shift the gateway's reply FIFO. diff --git a/rootshell/UI/Shell/MainView+Notifications.swift b/rootshell/UI/Shell/MainView+Notifications.swift index 1f72ead55..7890a2666 100644 --- a/rootshell/UI/Shell/MainView+Notifications.swift +++ b/rootshell/UI/Shell/MainView+Notifications.swift @@ -242,6 +242,15 @@ extension MainView { host?.showPaneZoomPicker() } + observerBag.observeOnMainActor(.choosePaneToSwap) { [self] notification in + guard self.shouldHandleNotification(notification), !isAnySheetPresented, + terminals.indices.contains(selectedTabIndex) else { return } + let tab = terminals[selectedTabIndex] + let host = tab.focusedPane?.enclosingSplitHost + ?? tab.splitTree.terminalLeaves.compactMap(\.enclosingSplitHost).first + host?.showPaneSwapPicker() + } + observerBag.observeOnMainActor(.focusSplit) { [self] notification in guard let paneView = notification.object as? SplitPaneView else { return } guard terminals.indices.contains(selectedTabIndex) else { return } diff --git a/rootshell/UI/Shell/MainView.swift b/rootshell/UI/Shell/MainView.swift index e18d42a88..039a61e79 100644 --- a/rootshell/UI/Shell/MainView.swift +++ b/rootshell/UI/Shell/MainView.swift @@ -706,6 +706,8 @@ struct MainView: View { return applyLifecycleHandlers(alertContent) .iPadVisor(ghosttyApp: ghosttyApp, windowID: windowId, modalPresented: isAnySheetPresented) .focusedSceneValue(\.canChoosePaneToZoom, canChooseSelectedPaneToZoom) + .focusedSceneValue(\.canChoosePaneToSwap, + canChooseSelectedPaneToZoom && terminals[selectedTabIndex].isTmuxWindow) } private var canChooseSelectedPaneToZoom: Bool { diff --git a/rootshell/UI/Terminal/PaneZoomPickerView.swift b/rootshell/UI/Terminal/PaneZoomPickerView.swift index 8cd1bc680..4fba3eee9 100644 --- a/rootshell/UI/Terminal/PaneZoomPickerView.swift +++ b/rootshell/UI/Terminal/PaneZoomPickerView.swift @@ -3,17 +3,22 @@ import UIKit /// Takes keyboard focus while choosing. It never forwards input to a terminal. /// The host supplies full-tree rectangles even when the server is currently zoomed. final class PaneZoomPickerView: UIView, UIKeyInput { + enum Action { case zoom, swap } + let action: Action private var selection: PaneZoomSelection private let buttons: [UIButton] private let shortcuts: [KeyTrigger] private let instruction = UILabel() + private let sourceLabel = UILabel() private var heldKeys = Set() private var pendingResult: PaneZoomSelection.Result = .pending private var lastInput: (text: String, modified: Bool, time: TimeInterval)? private var finishScheduled = false var onFinish: ((UUID?) -> Void)? - init(selection: PaneZoomSelection, titles: [String], preview: Bool, shortcuts: [KeyTrigger]) { + init(selection: PaneZoomSelection, titles: [String], preview: Bool, shortcuts: [KeyTrigger], + action: Action = .zoom, sourceTitle: String? = nil) { + self.action = action self.selection = selection self.shortcuts = shortcuts buttons = selection.labels.enumerated().map { index, label in @@ -30,7 +35,10 @@ final class PaneZoomPickerView: UIView, UIKeyInput { return attributes } button.configuration = config - button.accessibilityLabel = String(localized: "Zoom pane \(label): \(titles[index])") + switch action { + case .zoom: button.accessibilityLabel = String(localized: "Zoom pane \(label): \(titles[index])") + case .swap: button.accessibilityLabel = String(localized: "Swap with pane \(label): \(titles[index])") + } return button } super.init(frame: .zero) @@ -40,7 +48,19 @@ final class PaneZoomPickerView: UIView, UIKeyInput { button.addTarget(self, action: #selector(choose(_:)), for: .touchUpInside) addSubview(button) } - instruction.text = String(localized: "Type a pane number to zoom · Any other key cancels") + if let sourceTitle { + sourceLabel.text = String(localized: "Current pane: \(sourceTitle)") + sourceLabel.font = .preferredFont(forTextStyle: .headline) + sourceLabel.textColor = .label + sourceLabel.backgroundColor = .secondarySystemBackground.withAlphaComponent(0.6) + sourceLabel.textAlignment = .center + sourceLabel.numberOfLines = 0 + addSubview(sourceLabel) + } + switch action { + case .zoom: instruction.text = String(localized: "Type a pane number to zoom · Any other key cancels") + case .swap: instruction.text = String(localized: "Type a pane number to swap · Any other key cancels") + } instruction.font = .preferredFont(forTextStyle: .caption1) instruction.textColor = .label instruction.backgroundColor = .systemBackground @@ -67,7 +87,8 @@ final class PaneZoomPickerView: UIView, UIKeyInput { override var inputView: UIView? { UIView(frame: .zero) } var hasText: Bool { false } - func arrange(in frames: [CGRect]) { + func arrange(in frames: [CGRect], sourceFrame: CGRect? = nil) { + sourceLabel.frame = sourceFrame?.insetBy(dx: 6, dy: 6) ?? .zero for (button, frame) in zip(buttons, frames) { button.frame = frame.insetBy(dx: 6, dy: 6) } diff --git a/rootshell/UI/Terminal/TerminalSplitTreeView.swift b/rootshell/UI/Terminal/TerminalSplitTreeView.swift index 8c1682d44..50d052c17 100644 --- a/rootshell/UI/Terminal/TerminalSplitTreeView.swift +++ b/rootshell/UI/Terminal/TerminalSplitTreeView.swift @@ -70,6 +70,7 @@ final class SplitTreeHostingView: UIView { private lazy var paneRearrangement = SplitPaneRearrangementController(host: self) private var paneZoomPicker: PaneZoomPickerView? private var paneZoomPickerPanes: [Ghostty.TerminalView] = [] + private var paneSwapSourceViewID: UUID? private var sceneDeactivationObserver: NSObjectProtocol? private var paneZoomPresentationGeneration = 0 @@ -90,6 +91,40 @@ final class SplitTreeHostingView: UIView { return paneZoomRequest(for: panes) != nil } + var canChoosePaneToSwap: Bool { + guard isActiveTab, let panes = tree?.terminalLeaves, panes.count > 1, + panes.count == tree?.count else { return false } + return paneSwapRequest(for: panes) != nil + } + + private func canChoosePane(for action: PaneZoomPickerView.Action) -> Bool { + switch action { + case .zoom: return canChoosePaneToZoom + case .swap: return canChoosePaneToSwap + } + } + + /// Swapping is tmux-only for now. Capture both server IDs and the original + /// source view so a delayed selection cannot swap a newly focused pane. + private func paneSwapRequest(for panes: [Ghostty.TerminalView]) -> ((UUID) -> Void)? { + guard let source = focusedPane?.asTerminal, panes.contains(where: { $0 === source }), + let binding = source.tmuxPaneBinding, + let controller = TmuxController.controller(forOwnerSurface: binding.parentSurface), + controller.isActive, panes.allSatisfy({ + $0.tmuxPaneBinding?.parentUUID == binding.parentUUID && + $0.tmuxPaneBinding?.windowId == binding.windowId + }) else { return nil } + let ids = Dictionary(uniqueKeysWithValues: panes.compactMap { pane in + pane.tmuxPaneBinding.map { (pane.uuid, $0.paneId) } + }) + return { [weak controller, weak source, weak self] viewID in + guard let self, let source, self.focusedPane === source, + let targetID = ids[viewID], targetID != binding.paneId else { return } + controller?.requestSwapPane(windowID: binding.windowId, sourcePaneID: binding.paneId, + targetPaneID: targetID, expectedPaneIDs: Set(ids.values)) + } + } + /// Capture server IDs separately from the view IDs used by the picker. /// herdr can rebind an existing terminal view when a pane moves. private func paneZoomRequest(for panes: [Ghostty.TerminalView]) -> ((UUID) -> Void)? { @@ -129,6 +164,14 @@ final class SplitTreeHostingView: UIView { } func showPaneZoomPicker() { + showPanePicker(action: .zoom) + } + + func showPaneSwapPicker() { + showPanePicker(action: .swap) + } + + private func showPanePicker(action: PaneZoomPickerView.Action) { // The menu rail and UIKeyCommand can both deliver the same shortcut. // Repeated presentation is idempotent; the picker handles cancellation. guard paneZoomPicker == nil else { return } @@ -142,36 +185,52 @@ final class SplitTreeHostingView: UIView { // Resolve eligibility again after it has returned to the terminal scene. DispatchQueue.main.async { [weak self] in self?.presentPaneZoomPickerAfterKeyRelease(generation: generation, - keyboard: keyboard, buttons: openingButtons) + keyboard: keyboard, buttons: openingButtons, action: action) } } private func presentPaneZoomPickerAfterKeyRelease(generation: Int, keyboard: GCKeyboardInput?, - buttons: [GCControllerButtonInput]) { + buttons: [GCControllerButtonInput], action: PaneZoomPickerView.Action) { guard paneZoomPresentationGeneration == generation, isActiveTab, window != nil else { return } if let keyboard, GCKeyboard.coalesced?.keyboardInput === keyboard, buttons.contains(where: \.isPressed) { // Do not hand a still-held opening chord (including Option's text // delivery) to a view whose non-digit input intentionally cancels. DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in - self?.presentPaneZoomPickerAfterKeyRelease(generation: generation, keyboard: keyboard, buttons: buttons) + self?.presentPaneZoomPickerAfterKeyRelease(generation: generation, keyboard: keyboard, buttons: buttons, action: action) } return } - presentPaneZoomPicker() + presentPanePicker(action: action) } - private func presentPaneZoomPicker() { - guard paneZoomPicker == nil, canChoosePaneToZoom, windowCanPresentPaneZoomPicker, - let panes = tree?.terminalLeaves, - let requestZoom = paneZoomRequest(for: panes), - let selection = PaneZoomSelection(paneIDs: panes.map(\.uuid)) + private func presentPanePicker(action: PaneZoomPickerView.Action) { + guard paneZoomPicker == nil, canChoosePane(for: action), windowCanPresentPaneZoomPicker, + let panes = tree?.terminalLeaves else { return } + let request: ((UUID) -> Void)? + let source: Ghostty.TerminalView? + switch action { + case .zoom: + request = paneZoomRequest(for: panes) + source = nil + case .swap: + request = paneSwapRequest(for: panes) + source = focusedPane?.asTerminal + } + guard let request, + let selection = PaneZoomSelection(paneIDs: panes.map(\.uuid), excludingPaneID: source?.uuid) + else { return } + let candidates = panes.filter { $0 !== source } cancelPaneDrag() let picker = PaneZoomPickerView(selection: selection, - titles: panes.map { $0.presentation.title }, + titles: candidates.map { $0.presentation.title }, preview: tree?.zoomed != nil, - shortcuts: KeybindManager.shared.activeBindings.compactMap { $0.sequence.first }) + shortcuts: KeybindManager.shared.activeBindings.compactMap { $0.sequence.first }, + action: action, sourceTitle: source?.presentation.title) + // Keep every pane in the keyboard-ownership group, including the + // unnumbered source; it must not reclaim focus while picking a target. paneZoomPickerPanes = panes + paneSwapSourceViewID = source?.uuid paneZoomPicker = picker MenuShortcutState.shared.beginRecordingCapture() KeyboardTracker.shared.beginOverlayKeyboardPreservation(owner: self, window: window) @@ -181,8 +240,8 @@ final class SplitTreeHostingView: UIView { guard let self, self.paneZoomPicker === picker else { return } let restoreFocus = picker?.isFirstResponder == true self.dismissPaneZoomPicker(restoreFocus: restoreFocus) - guard let paneID, self.canChoosePaneToZoom else { return } - requestZoom(paneID) + guard let paneID, self.canChoosePane(for: action) else { return } + request(paneID) } addSubview(picker) layoutPaneZoomPicker() @@ -192,7 +251,7 @@ final class SplitTreeHostingView: UIView { private func acquirePaneZoomPickerFocus(_ picker: PaneZoomPickerView, attempt: Int = 0) { guard paneZoomPicker === picker else { return } - guard canChoosePaneToZoom, windowCanPresentPaneZoomPicker else { + guard canChoosePane(for: picker.action), windowCanPresentPaneZoomPicker else { dismissPaneZoomPicker(restoreFocus: false) return } @@ -212,6 +271,7 @@ final class SplitTreeHostingView: UIView { paneZoomPicker = nil let panes = paneZoomPickerPanes paneZoomPickerPanes = [] + paneSwapSourceViewID = nil picker.onFinish = nil picker.removeFromSuperview() DispatchQueue.main.async { MenuShortcutState.shared.endRecordingCapture() } @@ -236,7 +296,11 @@ final class SplitTreeHostingView: UIView { dismissPaneZoomPicker() return } - picker.arrange(in: frames) + let sourceIndex = paneZoomPickerPanes.firstIndex { $0.uuid == paneSwapSourceViewID } + let targetFrames = frames.enumerated().compactMap { index, frame in + index == sourceIndex ? nil : frame + } + picker.arrange(in: targetFrames, sourceFrame: sourceIndex.map { frames[$0] }) bringSubviewToFront(picker) } @@ -1590,6 +1654,7 @@ extension Notification.Name { static let toggleSplitZoom = Notification.Name("com.rootshell.toggleSplitZoom") static let equalizeSplits = Notification.Name("com.rootshell.equalizeSplits") static let choosePaneToZoom = Notification.Name("com.rootshell.choosePaneToZoom") + static let choosePaneToSwap = Notification.Name("com.rootshell.choosePaneToSwap") static let focusSplit = Notification.Name("com.rootshell.focusSplit") static let resizeSplit = Notification.Name("com.rootshell.resizeSplit") static let newTab = Notification.Name("com.rootshell.newTab") diff --git a/rootshell/UI/Terminal/TerminalView+Gestures.swift b/rootshell/UI/Terminal/TerminalView+Gestures.swift index 5694914e0..6f0adf966 100644 --- a/rootshell/UI/Terminal/TerminalView+Gestures.swift +++ b/rootshell/UI/Terminal/TerminalView+Gestures.swift @@ -1467,6 +1467,9 @@ extension Ghostty.TerminalView { } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(menuChoosePaneToSwap(_:)) { + return enclosingSplitHost?.canChoosePaneToSwap == true + } if action == #selector(menuChoosePaneToZoom(_:)) { return enclosingSplitHost?.canChoosePaneToZoom == true } @@ -2876,6 +2879,10 @@ extension Ghostty.TerminalView: UIContextMenuInteractionDelegate { image: UIImage(systemName: "number.square")) { [weak self] _ in self?.enclosingSplitHost?.showPaneZoomPicker() }) + items.append(UIAction(title: String(localized: "Choose Pane to Swap"), + image: UIImage(systemName: "arrow.left.arrow.right")) { [weak self] _ in + self?.enclosingSplitHost?.showPaneSwapPicker() + }) let zoom = UIAction( title: isZoomed ? String(localized: "Unzoom Pane") diff --git a/rootshell/UI/Terminal/TerminalView+Keyboard.swift b/rootshell/UI/Terminal/TerminalView+Keyboard.swift index 484eac33b..8c1eba0ca 100644 --- a/rootshell/UI/Terminal/TerminalView+Keyboard.swift +++ b/rootshell/UI/Terminal/TerminalView+Keyboard.swift @@ -2387,6 +2387,11 @@ extension Ghostty.TerminalView { NotificationCenter.default.post(name: .choosePaneToZoom, object: self) } + @objc func menuChoosePaneToSwap(_ sender: Any?) { + noteModTapCommand(sender as? UIKeyCommand) + NotificationCenter.default.post(name: .choosePaneToSwap, object: self) + } + @objc func menuOpenSettings(_ sender: Any?) { noteModTapCommand(sender as? UIKeyCommand) NotificationCenter.default.post(name: .openSettings, object: self) diff --git a/rootshellTests/PaneZoomPickerViewTests.swift b/rootshellTests/PaneZoomPickerViewTests.swift index 387ddbc38..06eee30e8 100644 --- a/rootshellTests/PaneZoomPickerViewTests.swift +++ b/rootshellTests/PaneZoomPickerViewTests.swift @@ -30,85 +30,122 @@ final class PaneZoomPickerViewTests: XCTestCase { override var key: UIKey? { hardwareKey } } - private func picker() throws -> (PaneZoomPickerView, UUID) { + private func picker(action: PaneZoomPickerView.Action = .zoom) throws -> (PaneZoomPickerView, UUID) { let selected = UUID() - let model = try XCTUnwrap(PaneZoomSelection(paneIDs: [selected, UUID()])) - return (PaneZoomPickerView(selection: model, titles: ["One", "Two"], preview: false, - shortcuts: [KeyTrigger(key: .w, modifiers: .command)]), selected) + let ids = action == .swap ? [UUID(), selected] : [selected, UUID()] + let model = try XCTUnwrap(PaneZoomSelection(paneIDs: ids, excludingPaneID: action == .swap ? ids[0] : nil)) + return (PaneZoomPickerView(selection: model, titles: model.labels, preview: false, + shortcuts: [KeyTrigger(key: .w, modifiers: .command)], + action: action, sourceTitle: action == .swap ? "Source" : nil), selected) } func testMountedPickerCanAcquireKeyboardAndFinishSelection() async throws { - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) - window.rootViewController = UIViewController() - window.makeKeyAndVisible() - defer { window.isHidden = true } - let previous = UITextField(frame: window.bounds) - window.rootViewController?.view.addSubview(previous) - XCTAssertTrue(previous.becomeFirstResponder(), "The test window must support keyboard focus") - let (picker, selected) = try picker() - window.rootViewController?.view.addSubview(picker) - picker.frame = window.bounds - picker.arrange(in: [CGRect(x: 0, y: 0, width: 400, height: 600), - CGRect(x: 400, y: 0, width: 400, height: 600)]) - XCTAssertTrue(picker.becomeFirstResponder(), "A menu must be able to hand the keyboard to the picker") - XCTAssertTrue(picker.isFirstResponder) - let finished = expectation(description: "Selected the first pane") - picker.onFinish = { paneID in - XCTAssertEqual(paneID, selected) - finished.fulfill() + for action in [PaneZoomPickerView.Action.zoom, .swap] { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + defer { window.isHidden = true } + let previous = UITextField(frame: window.bounds) + window.rootViewController?.view.addSubview(previous) + XCTAssertTrue(previous.becomeFirstResponder(), "The test window must support keyboard focus") + let (picker, selected) = try picker(action: action) + window.rootViewController?.view.addSubview(picker) + picker.frame = window.bounds + let left = CGRect(x: 0, y: 0, width: 400, height: 600) + let right = CGRect(x: 400, y: 0, width: 400, height: 600) + picker.arrange(in: action == .swap ? [right] : [left, right], + sourceFrame: action == .swap ? left : nil) + XCTAssertTrue(picker.becomeFirstResponder(), "A menu must be able to hand the keyboard to the picker") + XCTAssertTrue(picker.isFirstResponder) + let finished = expectation(description: "Selected the first pane") + picker.onFinish = { paneID in + XCTAssertEqual(paneID, selected) + finished.fulfill() + } + picker.insertText("1") + await fulfillment(of: [finished], timeout: 1) + picker.removeFromSuperview() } - picker.insertText("1") - await fulfillment(of: [finished], timeout: 1) - picker.removeFromSuperview() } func testShortcutCancelsWithoutDispatchingItsAppAction() async throws { - let (picker, _) = try picker() - let command = try XCTUnwrap(picker.keyCommands?.first { $0.input == "w" && $0.modifierFlags == .command }) - let action = try XCTUnwrap(command.action) - let finished = expectation(description: "Cancelled by Command-W") - picker.onFinish = { paneID in - XCTAssertNil(paneID) - finished.fulfill() + for action in [PaneZoomPickerView.Action.zoom, .swap] { + let (picker, _) = try picker(action: action) + let command = try XCTUnwrap(picker.keyCommands?.first { $0.input == "w" && $0.modifierFlags == .command }) + let action = try XCTUnwrap(command.action) + let finished = expectation(description: "Cancelled by Command-W") + picker.onFinish = { paneID in + XCTAssertNil(paneID) + finished.fulfill() + } + XCTAssertTrue(picker.canPerformAction(action, withSender: command)) + picker.perform(action, with: command) + await fulfillment(of: [finished], timeout: 1) } - XCTAssertTrue(picker.canPerformAction(action, withSender: command)) - picker.perform(action, with: command) - await fulfillment(of: [finished], timeout: 1) } func testOpeningModifiersAndUnownedCancellationDoNotDismissPicker() async throws { - let (picker, selected) = try picker() - let finished = expectation(description: "A digit still selects after opening-chord handoff") - picker.onFinish = { paneID in - XCTAssertEqual(paneID, selected) - finished.fulfill() + for action in [PaneZoomPickerView.Action.zoom, .swap] { + let (picker, selected) = try picker(action: action) + let finished = expectation(description: "A digit still selects after opening-chord handoff") + picker.onFinish = { paneID in + XCTAssertEqual(paneID, selected) + finished.fulfill() + } + let command = HardwarePress(HardwareKey(.keyboardLeftGUI, text: "", modifiers: .command)) + let option = HardwarePress(HardwareKey(.keyboardLeftAlt, text: "", modifiers: [.command, .alternate])) + picker.pressesBegan([command, option], with: nil) + let opener = HardwarePress(HardwareKey(action == .swap ? .keyboardS : .keyboardP, + text: action == .swap ? "s" : "p", + modifiers: [.command, .alternate])) + picker.pressesCancelled([opener], with: nil) + picker.pressesEnded([command, option], with: nil) + picker.insertText("1") + await fulfillment(of: [finished], timeout: 1) + } + } + + func testCancelKeyIsHeldUntilReleaseInBothModes() async throws { + for action in [PaneZoomPickerView.Action.zoom, .swap] { + for key in [HardwareKey(.keyboardEscape, text: "\u{1b}"), HardwareKey(.keyboardX, text: "x")] { + let (picker, _) = try picker(action: action) + let finished = expectation(description: "Cancelled after key release") + var cancellations = 0 + picker.onFinish = { paneID in + XCTAssertNil(paneID) + cancellations += 1 + finished.fulfill() + } + let press = HardwarePress(key) + picker.pressesBegan([press], with: nil) + picker.pressesBegan([press], with: nil) + await Task.yield() + XCTAssertEqual(cancellations, 0) + picker.pressesEnded([press], with: nil) + await fulfillment(of: [finished], timeout: 1) + XCTAssertEqual(cancellations, 1) + } } - let command = HardwarePress(HardwareKey(.keyboardLeftGUI, text: "", modifiers: .command)) - let option = HardwarePress(HardwareKey(.keyboardLeftAlt, text: "", modifiers: [.command, .alternate])) - picker.pressesBegan([command, option], with: nil) - let opener = HardwarePress(HardwareKey(.keyboardP, text: "p", modifiers: [.command, .alternate])) - picker.pressesCancelled([opener], with: nil) - picker.pressesEnded([command, option], with: nil) - picker.insertText("1") - await fulfillment(of: [finished], timeout: 1) } func testResigningCancelsExactlyOnce() throws { - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) - window.rootViewController = UIViewController() - window.makeKeyAndVisible() - defer { window.isHidden = true } - let (picker, _) = try picker() - window.rootViewController?.view.addSubview(picker) - XCTAssertTrue(picker.becomeFirstResponder()) - var cancellations = 0 - picker.onFinish = { paneID in - XCTAssertNil(paneID) - cancellations += 1 + for action in [PaneZoomPickerView.Action.zoom, .swap] { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + defer { window.isHidden = true } + let (picker, _) = try picker(action: action) + window.rootViewController?.view.addSubview(picker) + XCTAssertTrue(picker.becomeFirstResponder()) + var cancellations = 0 + picker.onFinish = { paneID in + XCTAssertNil(paneID) + cancellations += 1 + } + XCTAssertTrue(picker.resignFirstResponder()) + picker.removeFromSuperview() + XCTAssertEqual(cancellations, 1) } - XCTAssertTrue(picker.resignFirstResponder()) - picker.removeFromSuperview() - XCTAssertEqual(cancellations, 1) } } #endif diff --git a/rootshellTests/PaneZoomSelectionTests.swift b/rootshellTests/PaneZoomSelectionTests.swift index d95007031..f7136ded8 100644 --- a/rootshellTests/PaneZoomSelectionTests.swift +++ b/rootshellTests/PaneZoomSelectionTests.swift @@ -1,6 +1,50 @@ import XCTest final class PaneZoomSelectionTests: XCTestCase { + func testSwapExcludesSourceAndKeepsDisplayOrder() throws { + var selection = try XCTUnwrap(PaneZoomSelection(paneIDs: [42, 7, 91], excludingPaneID: 7)) + XCTAssertEqual(selection.paneIDs, [42, 91]) + XCTAssertEqual(selection.labels, ["1", "2"]) + XCTAssertEqual(selection.consume("2"), .selected(91)) + } + + func testTwoPaneSwapHasOneSelectableTarget() throws { + var selection = try XCTUnwrap(PaneZoomSelection(paneIDs: [42, 7], excludingPaneID: 42)) + XCTAssertEqual(selection.labels, ["1"]) + XCTAssertEqual(selection.consume("1"), .selected(7)) + } + + func testMissingSwapSourceIsRejected() { + XCTAssertNil(PaneZoomSelection(paneIDs: [42, 7], excludingPaneID: 91)) + } + + func testSwapUsesSameCancellationRulesIncludingEscape() throws { + for input in ["\u{1b}", "x", "0", "2", "\r", "\t", "12"] { + var selection = try XCTUnwrap(PaneZoomSelection(paneIDs: [42, 7], excludingPaneID: 42)) + XCTAssertEqual(selection.consume(input), .cancelled) + XCTAssertEqual(selection.consume("1"), .cancelled) + } + var modified = try XCTUnwrap(PaneZoomSelection(paneIDs: [42, 7], excludingPaneID: 42)) + XCTAssertEqual(modified.consume("1", modified: true), .cancelled) + } + + func testSwapPaddingUsesTargetCountNotTotalPaneCount() throws { + let nine = try XCTUnwrap(PaneZoomSelection(paneIDs: Array(0..<10), excludingPaneID: 3)) + XCTAssertEqual(nine.labels, (1...9).map(String.init)) + var ten = try XCTUnwrap(PaneZoomSelection(paneIDs: Array(0..<11), excludingPaneID: 3)) + XCTAssertEqual(ten.labels.first, "01") + XCTAssertEqual(ten.consume("1"), .pending) + XCTAssertEqual(ten.consume("0"), .selected(10)) + } + + func testSwapCommandPreservesFocusZoomAndScopesBothTargets() throws { + XCTAssertEqual(try TmuxPaneZoomCommand.swapCommand(windowID: 4, sourcePaneID: 17, targetPaneID: 29), + "swap-pane -d -Z -s @4.%17 -t @4.%29") + for (window, source, target) in [(-1, 1, 2), (0, -1, 2), (0, 1, -1), (0, 1, 1)] { + XCTAssertThrowsError(try TmuxPaneZoomCommand.swapCommand(windowID: window, sourcePaneID: source, targetPaneID: target)) + } + } + func testRejectsSinglePaneAndDuplicateIDs() { for ids in [[], [1], [1, 1]] { XCTAssertNil(PaneZoomSelection(paneIDs: ids))