From fc39984a8b379043b3087f2bfa0bb937df1023e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Fabr=C3=A9gat?= Date: Mon, 21 Sep 2026 22:35:35 -0700 Subject: [PATCH] Add swap action to the shared tmux pane picker Build on the zoom picker from PR #497. Reuse its input handling and lifecycle, exclude the focused pane from swap targets, and preserve source focus and zoom with window-qualified native swap commands. Default to Command-Option-S because Command-Option-M minimizes all application windows on macOS. Add model and real control-mode tests. --- .../Tests/TmuxSplitEqualizationTests.swift | 46 +++++- rootshell.xcodeproj/project.pbxproj | 12 +- rootshell/App/AppCommands.swift | 20 ++- rootshell/App/CatalystAppDelegate.swift | 8 +- .../App/UIApplication+CommandFallback.swift | 4 + rootshell/Core/Keybinds/KeybindAction.swift | 8 +- .../Keybinds/KeybindCommandGenerator.swift | 2 +- rootshell/Core/Keybinds/KeybindManager.swift | 1 + rootshell/Features/Tmux/TmuxController.swift | 40 ++++- ...election.swift => TmuxPaneSelection.swift} | 21 ++- .../UI/Shell/MainView+Notifications.swift | 6 + rootshell/UI/Shell/MainView.swift | 2 +- .../UI/Terminal/TerminalSplitTreeView.swift | 100 +++++++----- .../UI/Terminal/TerminalView+Gestures.swift | 8 +- .../UI/Terminal/TerminalView+Keyboard.swift | 5 + ...kerView.swift => TmuxPanePickerView.swift} | 32 +++- rootshellTests/TmuxPaneSelectionTests.swift | 144 ++++++++++++++++++ .../TmuxPaneZoomSelectionTests.swift | 100 ------------ 18 files changed, 382 insertions(+), 177 deletions(-) rename rootshell/Features/Tmux/{TmuxPaneZoomSelection.swift => TmuxPaneSelection.swift} (71%) rename rootshell/UI/Terminal/{TmuxPaneZoomPickerView.swift => TmuxPanePickerView.swift} (83%) create mode 100644 rootshellTests/TmuxPaneSelectionTests.swift delete mode 100644 rootshellTests/TmuxPaneZoomSelectionTests.swift diff --git a/rootshell-helper/Tests/TmuxSplitEqualizationTests.swift b/rootshell-helper/Tests/TmuxSplitEqualizationTests.swift index 1b15798bb..7e4de1e94 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 TmuxPaneSelection.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 TmuxPaneSelection.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() } @@ -16,7 +58,7 @@ final class TmuxSplitEqualizationTests: XCTestCase { let layout = try control.command("display-message -p -t @0 '#{window_layout}'") // Initially unzoomed, then a different zoomed pane, then the same pane. for paneID in [0, 2, 2, 1] { - try await TmuxPaneZoomSelection.zoom(windowID: 0, paneID: paneID) { command in + try await TmuxPaneSelection.zoom(windowID: 0, paneID: paneID) { command in try control.command(command) } XCTAssertEqual(try control.command("display-message -p -t @0 '#{window_zoomed_flag}:#{pane_id}'"), "1:%\(paneID)") @@ -28,7 +70,7 @@ final class TmuxSplitEqualizationTests: XCTestCase { // A stable ID moved to a different window must not be followed there. try server.cli(["join-pane", "-s", "@0.%2", "-t", "@1"]) do { - try await TmuxPaneZoomSelection.zoom(windowID: 0, paneID: 2) { try control.command($0) } + try await TmuxPaneSelection.zoom(windowID: 0, paneID: 2) { try control.command($0) } XCTFail("A moved pane must not be followed into another window") } catch { /* tmux rejects the window-qualified stale target */ } XCTAssertEqual(try control.command("display-message -p -t @1 '#{window_zoomed_flag}'"), "0") diff --git a/rootshell.xcodeproj/project.pbxproj b/rootshell.xcodeproj/project.pbxproj index f0c999d41..ffa55716e 100644 --- a/rootshell.xcodeproj/project.pbxproj +++ b/rootshell.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 47AF04970000000000000C01 /* TmuxPaneZoomSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04970000000000000B01 /* TmuxPaneZoomSelection.swift */; }; - 47AF04970000000000000A01 /* TmuxPaneZoomSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04970000000000000B01 /* TmuxPaneZoomSelection.swift */; }; + 47AF04970000000000000C01 /* TmuxPaneSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04970000000000000B01 /* TmuxPaneSelection.swift */; }; + 47AF04970000000000000A01 /* TmuxPaneSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04970000000000000B01 /* TmuxPaneSelection.swift */; }; 47AF04870000000000000A01 /* PaneCloseConfirmationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04870000000000000B01 /* PaneCloseConfirmationPolicy.swift */; }; 47AF04890000000000000A01 /* TmuxDividerResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04890000000000000B01 /* TmuxDividerResize.swift */; }; 47AF04890000000000000A02 /* TmuxDividerResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04890000000000000B01 /* TmuxDividerResize.swift */; }; @@ -521,7 +521,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 47AF04970000000000000B01 /* TmuxPaneZoomSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Features/Tmux/TmuxPaneZoomSelection.swift; sourceTree = SOURCE_ROOT; }; + 47AF04970000000000000B01 /* TmuxPaneSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Features/Tmux/TmuxPaneSelection.swift; sourceTree = SOURCE_ROOT; }; 47AF04870000000000000B01 /* PaneCloseConfirmationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Core/Terminal/PaneCloseConfirmationPolicy.swift; sourceTree = SOURCE_ROOT; }; 47AF04890000000000000B01 /* TmuxDividerResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Features/Tmux/TmuxDividerResize.swift; sourceTree = SOURCE_ROOT; }; 473118BF2FFC2B1500FB3C83 /* rootshellvpn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = rootshellvpn.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1221,7 +1221,7 @@ 47AF04750000000000000B02 /* rootshell/Features/Tmux/TmuxSplitEqualizer.swift */, 47AF04750000000000000B01 /* rootshell/Features/Tmux/TmuxLayoutNode.swift */, 47AF04890000000000000B01 /* TmuxDividerResize.swift */, - 47AF04970000000000000B01 /* TmuxPaneZoomSelection.swift */, + 47AF04970000000000000B01 /* TmuxPaneSelection.swift */, 47AF0F1D0000000000000A04 /* rootshell/Features/OpenInFolder/DirectoryListingProbe.swift */, 47AF0F1D0000000000000A05 /* rootshell/Features/OpenInFolder/DirectoryListingCache.swift */, 47AF0F1D0000000000000A06 /* rootshell/Features/OpenInFolder/PathCompletion.swift */, @@ -2167,7 +2167,7 @@ 47AF04750000000000000C02 /* rootshell/Features/Tmux/TmuxSplitEqualizer.swift in Sources */, 47AF04750000000000000C01 /* rootshell/Features/Tmux/TmuxLayoutNode.swift in Sources */, 47AF04890000000000000A02 /* TmuxDividerResize.swift in Sources */, - 47AF04970000000000000C01 /* TmuxPaneZoomSelection.swift in Sources */, + 47AF04970000000000000C01 /* TmuxPaneSelection.swift in Sources */, 47AEC0010000000000000005 /* rootshell-helper/Sources/LocaleHelper.swift in Sources */, 47AEC0010000000000000004 /* rootshell-helper/Sources/EnvironmentBuilder.swift in Sources */, 47AEC0010000000000000002 /* rootshell/Core/Persistence/LocalMultiplexerAttachment.swift in Sources */, @@ -2185,7 +2185,7 @@ 47AF04750000000000000A02 /* rootshell/Features/Tmux/TmuxSplitEqualizer.swift in Sources */, 47AF04750000000000000A01 /* rootshell/Features/Tmux/TmuxLayoutNode.swift in Sources */, 47AF04890000000000000A01 /* TmuxDividerResize.swift in Sources */, - 47AF04970000000000000A01 /* TmuxPaneZoomSelection.swift in Sources */, + 47AF04970000000000000A01 /* TmuxPaneSelection.swift in Sources */, 47AF04710000000000000003 /* rootshell/Core/Keybinds/KeyTrigger.swift in Sources */, 47AF04710000000000000005 /* rootshell/Core/Keybinds/HardwareKeyboardText.swift in Sources */, 47AF04710000000000000001 /* rootshell/Core/Keybinds/ModTapState.swift in Sources */, diff --git a/rootshell/App/AppCommands.swift b/rootshell/App/AppCommands.swift index fba14ca2b..7954af5e3 100644 --- a/rootshell/App/AppCommands.swift +++ b/rootshell/App/AppCommands.swift @@ -15,14 +15,14 @@ import Combine // MARK: - Keyboard Shortcut State -private struct CanChooseTmuxPaneToZoomKey: FocusedValueKey { +private struct CanChooseTmuxPaneKey: FocusedValueKey { typealias Value = Bool } extension FocusedValues { - var canChooseTmuxPaneToZoom: Bool? { - get { self[CanChooseTmuxPaneToZoomKey.self] } - set { self[CanChooseTmuxPaneToZoomKey.self] = newValue } + var canChooseTmuxPane: Bool? { + get { self[CanChooseTmuxPaneKey.self] } + set { self[CanChooseTmuxPaneKey.self] = newValue } } } @@ -401,7 +401,7 @@ struct AppViewCommands: Commands { struct TerminalCommands: Commands { @ObservedObject var shortcutState: MenuShortcutState - @FocusedValue(\.canChooseTmuxPaneToZoom) private var canChoosePaneToZoom + @FocusedValue(\.canChooseTmuxPane) private var canChoosePane var body: some Commands { CommandMenu("Terminal") { @@ -484,7 +484,15 @@ struct TerminalCommands: Commands { ) } .modifier(DynamicShortcut(action: .choose_tmux_pane_to_zoom, shortcuts: shortcutState.shortcuts)) - .disabled(canChoosePaneToZoom != true) + .disabled(canChoosePane != true) + + Button("Choose Pane to Swap") { + UIApplication.shared.sendMenuAction( + #selector(Ghostty.TerminalView.menuChooseTmuxPaneToSwap(_:)), from: nil + ) + } + .modifier(DynamicShortcut(action: .choose_tmux_pane_to_swap, shortcuts: shortcutState.shortcuts)) + .disabled(canChoosePane != true) Divider() diff --git a/rootshell/App/CatalystAppDelegate.swift b/rootshell/App/CatalystAppDelegate.swift index 8ab7242f0..626abe4c2 100644 --- a/rootshell/App/CatalystAppDelegate.swift +++ b/rootshell/App/CatalystAppDelegate.swift @@ -230,6 +230,10 @@ extension UIApplication { sendAction(#selector(Ghostty.TerminalView.menuChooseTmuxPaneToZoom(_:)), to: nil, from: sender, for: nil) } + @objc func ghostty_chooseTmuxPaneToSwap(_ sender: Any?) { + sendAction(#selector(Ghostty.TerminalView.menuChooseTmuxPaneToSwap(_:)), to: nil, from: sender, for: nil) + } + @objc func ghostty_toggleTabBar(_ sender: Any?) { sendAction(#selector(Ghostty.TerminalView.menuToggleTabBar(_:)), to: nil, from: sender, for: nil) } @@ -1312,7 +1316,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_chooseTmuxPaneToZoom(_:))) + action: #selector(UIApplication.ghostty_chooseTmuxPaneToZoom(_:))), + UICommand(title: String(localized: "Choose Pane to Swap"), + action: #selector(UIApplication.ghostty_chooseTmuxPaneToSwap(_:))) ]) // Scroll commands diff --git a/rootshell/App/UIApplication+CommandFallback.swift b/rootshell/App/UIApplication+CommandFallback.swift index 543de58ff..81b17a2ca 100644 --- a/rootshell/App/UIApplication+CommandFallback.swift +++ b/rootshell/App/UIApplication+CommandFallback.swift @@ -211,6 +211,10 @@ extension UIApplication { ghostty_postNotification(.chooseTmuxPaneToZoom) } + @objc func menuChooseTmuxPaneToSwap(_ sender: Any?) { + ghostty_postNotification(.chooseTmuxPaneToSwap) + } + @objc func menuOpenSettings(_ sender: Any?) { ghostty_postNotification( .openSettings, diff --git a/rootshell/Core/Keybinds/KeybindAction.swift b/rootshell/Core/Keybinds/KeybindAction.swift index af007845d..28c51897d 100644 --- a/rootshell/Core/Keybinds/KeybindAction.swift +++ b/rootshell/Core/Keybinds/KeybindAction.swift @@ -136,6 +136,8 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case equalize_splits = "equalize_splits" /// Show numbered targets for zooming a tmux control-mode pane. case choose_tmux_pane_to_zoom = "choose_tmux_pane_to_zoom" + /// Show numbered targets for swapping the focused tmux control-mode pane. + case choose_tmux_pane_to_swap = "choose_tmux_pane_to_swap" // Shell Operations /// Open settings @@ -314,7 +316,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_tmux_pane_to_zoom: + .toggle_split_zoom, .equalize_splits, .choose_tmux_pane_to_zoom, .choose_tmux_pane_to_swap: return .splits case .increase_font_size, .decrease_font_size, .reset_font_size, .start_search, @@ -387,6 +389,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_tmux_pane_to_zoom: return String(localized: "Choose Pane to Zoom", comment: "Keybind action") + case .choose_tmux_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") @@ -479,6 +482,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .toggle_split_zoom: return .toggleSplitZoom case .equalize_splits: return .equalizeSplits case .choose_tmux_pane_to_zoom: return .chooseTmuxPaneToZoom + case .choose_tmux_pane_to_swap: return .chooseTmuxPaneToSwap case .open_settings: return .openSettings case .toggle_visor: return .toggleVisorOverlay @@ -622,7 +626,7 @@ 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_tmux_pane_to_zoom, .open_settings, .toggle_quick_settings, .open_in_folder, .browse_hosts, + .toggle_split_zoom, .equalize_splits, .choose_tmux_pane_to_zoom, .choose_tmux_pane_to_swap, .open_settings, .toggle_quick_settings, .open_in_folder, .browse_hosts, .browse_profiles, .toggle_ai_agent, .toggle_voice_agent, .toggle_tab_bar, .toggle_group_mode, .toggle_transparency, .toggle_titlebar, .toggle_auto_redact, .toggle_background_effect, .toggle_tab_switcher, .toggle_tab_expose, .show_tmux_sessions, diff --git a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift index 935d04b3b..85a2fd9fb 100644 --- a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift +++ b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift @@ -126,7 +126,7 @@ 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_tmux_pane_to_zoom, .open_settings, .toggle_quick_settings, .open_in_folder, .browse_hosts, + .toggle_split_zoom, .equalize_splits, .choose_tmux_pane_to_zoom, .choose_tmux_pane_to_swap, .open_settings, .toggle_quick_settings, .open_in_folder, .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, .detach_other_clients, diff --git a/rootshell/Core/Keybinds/KeybindManager.swift b/rootshell/Core/Keybinds/KeybindManager.swift index f58759745..b53ec2083 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_tmux_pane_to_zoom), + Keybind(key: .s, modifiers: [.command, .option], action: .choose_tmux_pane_to_swap), // View Keybind(key: .equal, modifiers: .command, action: .increase_font_size), diff --git a/rootshell/Features/Tmux/TmuxController.swift b/rootshell/Features/Tmux/TmuxController.swift index 5d877c987..676b54adf 100644 --- a/rootshell/Features/Tmux/TmuxController.swift +++ b/rootshell/Features/Tmux/TmuxController.swift @@ -431,7 +431,7 @@ final class TmuxController { private var lastAppliedTopologyOps: [TmuxReconcileOp]? private var skippedDuplicateReconciles = 0 private var equalizingWindows: Set = [] - private var paneZoomSelectionWindows: Set = [] + private var paneSelectionWindows: Set = [] // MARK: - Recovery watchdog (always-on) @@ -1316,16 +1316,29 @@ final class TmuxController { /// Commit a picker selection, using stable pane IDs from its frozen layout. func requestZoomPane(windowID: Int, paneID: Int, expectedPaneIDs: Set) { - guard isActive, !paneZoomSelectionWindows.contains(windowID), + 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, !paneSelectionWindows.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) + paneSelectionWindows.insert(windowID) Task { @MainActor [weak self, weak tab, weak pane] in guard let self else { return } - defer { self.paneZoomSelectionWindows.remove(windowID) } + defer { self.paneSelectionWindows.remove(windowID) } guard let tab, let pane, self.isActive, self.windowTabs[windowID] === tab, !tab.paneMove.isPending, @@ -1333,22 +1346,33 @@ final class TmuxController { (self.modelContainingTab(id: tab.id) ?? self.tabsModel).selectedTabID == tab.id else { return } do { - try await TmuxPaneZoomSelection.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 TmuxPaneZoomSelection.Failure.layoutChanged } + else { throw TmuxPaneSelection.Failure.layoutChanged } return try await self.sendCommandWithReply(command) } + if let sourcePaneID { + let command = try TmuxPaneSelection.swapCommand(windowID: windowID, + sourcePaneID: sourcePaneID, + targetPaneID: paneID) + _ = try await send(command) + } else { + try await TmuxPaneSelection.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/TmuxPaneZoomSelection.swift b/rootshell/Features/Tmux/TmuxPaneSelection.swift similarity index 71% rename from rootshell/Features/Tmux/TmuxPaneZoomSelection.swift rename to rootshell/Features/Tmux/TmuxPaneSelection.swift index 2bfcdfff4..73bcdd0b4 100644 --- a/rootshell/Features/Tmux/TmuxPaneZoomSelection.swift +++ b/rootshell/Features/Tmux/TmuxPaneSelection.swift @@ -2,7 +2,8 @@ import Foundation /// A frozen, display-ordered mapping. Numbers are picker labels, not mutable /// tmux pane indices. Fixed-width labels avoid ambiguity for 10+ panes. -struct TmuxPaneZoomSelection { +struct TmuxPaneSelection { + enum Action: Equatable { case zoom, swap } enum Result: Equatable { case pending case selected(Int) @@ -14,12 +15,14 @@ struct TmuxPaneZoomSelection { private(set) var prefix = "" private(set) var result: Result = .pending - init?(paneIDs: [Int]) { + init?(paneIDs: [Int], excludingPaneID: Int? = nil) { guard paneIDs.count > 1, Set(paneIDs).count == paneIDs.count, paneIDs.allSatisfy({ $0 >= 0 }) 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 } @@ -43,6 +46,14 @@ struct TmuxPaneZoomSelection { enum Failure: Error { case invalidTarget, invalidReply, layoutChanged } + /// One native command, preserving the active pane and any existing zoom. + /// Window-qualified IDs must not follow a pane moved into another 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 2ecaed688..6ffb4d185 100644 --- a/rootshell/UI/Shell/MainView+Notifications.swift +++ b/rootshell/UI/Shell/MainView+Notifications.swift @@ -237,6 +237,12 @@ extension MainView { terminals[selectedTabIndex].focusedPane?.enclosingSplitHost?.showTmuxPaneZoomPicker() } + observerBag.observeOnMainActor(.chooseTmuxPaneToSwap) { [self] notification in + guard self.shouldHandleNotification(notification), !isAnySheetPresented, + terminals.indices.contains(selectedTabIndex) else { return } + terminals[selectedTabIndex].focusedPane?.enclosingSplitHost?.showTmuxPaneSwapPicker() + } + 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 1aecc6abc..37a33027c 100644 --- a/rootshell/UI/Shell/MainView.swift +++ b/rootshell/UI/Shell/MainView.swift @@ -692,7 +692,7 @@ struct MainView: View { let alertContent = applyAlertModifiers(overlayContent) return applyLifecycleHandlers(alertContent) .iPadVisor(ghosttyApp: ghosttyApp, windowID: windowId, modalPresented: isAnySheetPresented) - .focusedSceneValue(\.canChooseTmuxPaneToZoom, + .focusedSceneValue(\.canChooseTmuxPane, !isAnySheetPresented && terminals.indices.contains(selectedTabIndex) && terminals[selectedTabIndex].isTmuxWindow && terminals[selectedTabIndex].splitTree.count > 1) diff --git a/rootshell/UI/Terminal/TerminalSplitTreeView.swift b/rootshell/UI/Terminal/TerminalSplitTreeView.swift index 051dc5f81..584634441 100644 --- a/rootshell/UI/Terminal/TerminalSplitTreeView.swift +++ b/rootshell/UI/Terminal/TerminalSplitTreeView.swift @@ -67,11 +67,12 @@ final class SplitTreeHostingView: UIView { var onMove: ((SplitPaneView, SplitPaneView, PaneDropZone) -> Void)? var allowsPaneRearrangement = true private lazy var paneRearrangement = SplitPaneRearrangementController(host: self) - private var paneZoomPicker: TmuxPaneZoomPickerView? - private var paneZoomPickerPanes: [Ghostty.TerminalView] = [] + private var panePicker: TmuxPanePickerView? + private var panePickerPanes: [Ghostty.TerminalView] = [] + private weak var panePickerSource: Ghostty.TerminalView? private var sceneDeactivationObserver: NSObjectProtocol? - var canChooseTmuxPaneToZoom: Bool { + var canChooseTmuxPane: Bool { guard isActiveTab, let panes = tree?.terminalLeaves, panes.count > 1, panes.count == tree?.count, let binding = panes.first?.tmuxPaneBinding, @@ -84,41 +85,64 @@ final class SplitTreeHostingView: UIView { } func showTmuxPaneZoomPicker() { - if paneZoomPicker != nil { dismissPaneZoomPicker(); return } - guard canChooseTmuxPaneToZoom, window?.isKeyWindow == true, + showTmuxPanePicker(action: .zoom) + } + + func showTmuxPaneSwapPicker() { + showTmuxPanePicker(action: .swap) + } + + private func showTmuxPanePicker(action: TmuxPaneSelection.Action) { + if panePicker != nil { dismissPanePicker(); return } + guard canChooseTmuxPane, window?.isKeyWindow == true, let panes = tree?.terminalLeaves, - let binding = panes.first?.tmuxPaneBinding, - let selection = TmuxPaneZoomSelection(paneIDs: panes.compactMap { $0.tmuxPaneBinding?.paneId }) + let binding = panes.first?.tmuxPaneBinding else { return } + let source = action == .swap ? focusedPane?.asTerminal : nil + if action == .swap { + guard let source, panes.contains(where: { $0 === source }) else { return } + } + let allPaneIDs = panes.compactMap { $0.tmuxPaneBinding?.paneId } + let sourcePaneID = source?.tmuxPaneBinding?.paneId + guard let selection = TmuxPaneSelection(paneIDs: allPaneIDs, excludingPaneID: sourcePaneID) else { return } + let candidates = panes.filter { $0 !== source } cancelPaneDrag() - let picker = TmuxPaneZoomPickerView(selection: selection, - titles: panes.map { $0.presentation.title }, - preview: tree?.zoomed != nil) - paneZoomPickerPanes = panes - paneZoomPicker = picker + let picker = TmuxPanePickerView(selection: selection, + titles: candidates.map { $0.presentation.title }, + preview: tree?.zoomed != nil, action: action, + sourceTitle: source?.presentation.title) + panePickerPanes = candidates + panePickerSource = source + panePicker = picker MenuShortcutState.shared.beginRecordingCapture() KeyboardTracker.shared.beginOverlayKeyboardPreservation(owner: self, window: window) focusRestorationGeneration &+= 1 picker.onFinish = { [weak self, weak picker] paneID in - guard let self, self.paneZoomPicker === picker else { return } + guard let self, self.panePicker === picker else { return } let restoreFocus = picker?.isFirstResponder == true - self.dismissPaneZoomPicker(restoreFocus: restoreFocus) - guard let paneID, self.canChooseTmuxPaneToZoom, + self.dismissPanePicker(restoreFocus: restoreFocus) + guard let paneID, self.canChooseTmuxPane, let controller = TmuxController.controller(forOwnerSurface: binding.parentSurface) else { return } - controller.requestZoomPane(windowID: binding.windowId, paneID: paneID, - expectedPaneIDs: Set(selection.paneIDs)) + if let sourcePaneID { + controller.requestSwapPane(windowID: binding.windowId, sourcePaneID: sourcePaneID, + targetPaneID: paneID, expectedPaneIDs: Set(allPaneIDs)) + } else { + controller.requestZoomPane(windowID: binding.windowId, paneID: paneID, + expectedPaneIDs: Set(allPaneIDs)) + } } addSubview(picker) - layoutPaneZoomPicker() - if !picker.becomeFirstResponder() { dismissPaneZoomPicker() } + layoutPanePicker() + if !picker.becomeFirstResponder() { dismissPanePicker() } UIAccessibility.post(notification: .screenChanged, argument: picker) } - private func dismissPaneZoomPicker(restoreFocus: Bool = true) { - guard let picker = paneZoomPicker else { return } - paneZoomPicker = nil - paneZoomPickerPanes = [] + private func dismissPanePicker(restoreFocus: Bool = true) { + guard let picker = panePicker else { return } + panePicker = nil + panePickerPanes = [] + panePickerSource = nil picker.onFinish = nil picker.removeFromSuperview() DispatchQueue.main.async { MenuShortcutState.shared.endRecordingCapture() } @@ -128,18 +152,21 @@ final class SplitTreeHostingView: UIView { KeyboardTracker.shared.endOverlayKeyboardPreservation(owner: self) } - private func layoutPaneZoomPicker() { - guard let picker = paneZoomPicker, let root = tree?.root else { return } + private func layoutPanePicker() { + guard let picker = panePicker, let root = tree?.root else { return } picker.frame = bounds let layoutBounds = tmuxDeadMargin() ?? bounds - let frames = paneZoomPickerPanes.compactMap { + let frames = panePickerPanes.compactMap { slotFrame(for: $0, node: root, in: layoutBounds, layoutBounds: layoutBounds) } - guard frames.count == paneZoomPickerPanes.count else { - dismissPaneZoomPicker() + guard frames.count == panePickerPanes.count else { + dismissPanePicker() return } - picker.arrange(in: frames) + let sourceFrame = panePickerSource.flatMap { + slotFrame(for: $0, node: root, in: layoutBounds, layoutBounds: layoutBounds) + } + picker.arrange(in: frames, sourceFrame: sourceFrame) bringSubviewToFront(picker) } @@ -161,7 +188,7 @@ final class SplitTreeHostingView: UIView { var isActiveTab: Bool = false { didSet { guard isActiveTab != oldValue else { return } - if !isActiveTab { dismissPaneZoomPicker(restoreFocus: false) } + if !isActiveTab { dismissPanePicker(restoreFocus: false) } setNeedsLayout() refreshProgressBarRouting() } @@ -232,7 +259,7 @@ final class SplitTreeHostingView: UIView { MainActor.assumeIsolated { guard let self, let scene = notification.object as? UIScene, scene === self.window?.windowScene else { return } - self.dismissPaneZoomPicker(restoreFocus: false) + self.dismissPanePicker(restoreFocus: false) } } @@ -292,7 +319,7 @@ final class SplitTreeHostingView: UIView { // tree or focus change may relayout every attached pane. let treeChanged = self.tree?.root != tree.root || self.tree?.zoomed != tree.zoomed let focusChanged = self.focusedPane !== focusedPane - if treeChanged || focusChanged { dismissPaneZoomPicker(restoreFocus: false) } + if treeChanged || focusChanged { dismissPanePicker(restoreFocus: false) } if treeChanged { hasCompletedHerdrLayout = false } self.tree = tree self.focusedPane = focusedPane @@ -321,7 +348,7 @@ final class SplitTreeHostingView: UIView { /// dismantle runs before the new host adopts them, and a pane checked out for /// full screen lives under the takeover container. func detachAllPanes() { - dismissPaneZoomPicker(restoreFocus: false) + dismissPanePicker(restoreFocus: false) hasCompletedHerdrLayout = false herdrMetricsRefresh.cancel() needsFocusRestoration = false @@ -406,7 +433,7 @@ final class SplitTreeHostingView: UIView { // Overlay chrome follows actual pane frames, including tmux's dead margin. paneRearrangement.update(tree: tree, enabled: isActiveTab && allowsPaneRearrangement && onMove != nil) paneRearrangement.layout() - layoutPaneZoomPicker() + layoutPanePicker() restoreFocusAfterLayoutIfNeeded() } @@ -415,14 +442,14 @@ final class SplitTreeHostingView: UIView { /// UIKit finishes removing the old hierarchy. Reassert it after layout, /// from the surviving host, without sending another tmux select command. private func restoreFocusAfterLayoutIfNeeded() { - guard needsFocusRestoration, paneZoomPicker == nil else { return } + guard needsFocusRestoration, panePicker == nil else { return } needsFocusRestoration = false guard isActiveTab, let pane = focusedPane, pane.isLogicallyFocused else { return } let generation = focusRestorationGeneration DispatchQueue.main.async { [weak self, weak pane] in guard let self, let pane, self.focusRestorationGeneration == generation, - self.paneZoomPicker == nil, + self.panePicker == nil, self.isActiveTab, self.window?.isKeyWindow == true, self.focusedPane === pane, pane.isLogicallyFocused, !pane.isDetachedForFullScreen, @@ -1492,6 +1519,7 @@ extension Notification.Name { static let toggleSplitZoom = Notification.Name("com.rootshell.toggleSplitZoom") static let equalizeSplits = Notification.Name("com.rootshell.equalizeSplits") static let chooseTmuxPaneToZoom = Notification.Name("com.rootshell.chooseTmuxPaneToZoom") + static let chooseTmuxPaneToSwap = Notification.Name("com.rootshell.chooseTmuxPaneToSwap") 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 8b59780b4..398c1d00b 100644 --- a/rootshell/UI/Terminal/TerminalView+Gestures.swift +++ b/rootshell/UI/Terminal/TerminalView+Gestures.swift @@ -1467,8 +1467,8 @@ extension Ghostty.TerminalView { } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if action == #selector(menuChooseTmuxPaneToZoom(_:)) { - return enclosingSplitHost?.canChooseTmuxPaneToZoom == true + if action == #selector(menuChooseTmuxPaneToZoom(_:)) || action == #selector(menuChooseTmuxPaneToSwap(_:)) { + return enclosingSplitHost?.canChooseTmuxPane == true } if herdrController?.showsGatewayStatus == true, [#selector(copy(_:)), #selector(paste(_:)), #selector(selectAll(_:))].contains(action) { @@ -2867,6 +2867,10 @@ extension Ghostty.TerminalView: UIContextMenuInteractionDelegate { image: UIImage(systemName: "number.square")) { [weak self] _ in self?.enclosingSplitHost?.showTmuxPaneZoomPicker() }) + items.append(UIAction(title: String(localized: "Choose Pane to Swap"), + image: UIImage(systemName: "arrow.left.arrow.right")) { [weak self] _ in + self?.enclosingSplitHost?.showTmuxPaneSwapPicker() + }) 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 50f32ed33..236dc41a4 100644 --- a/rootshell/UI/Terminal/TerminalView+Keyboard.swift +++ b/rootshell/UI/Terminal/TerminalView+Keyboard.swift @@ -2387,6 +2387,11 @@ extension Ghostty.TerminalView { enclosingSplitHost?.showTmuxPaneZoomPicker() } + @objc func menuChooseTmuxPaneToSwap(_ sender: Any?) { + noteModTapCommand(sender as? UIKeyCommand) + enclosingSplitHost?.showTmuxPaneSwapPicker() + } + @objc func menuOpenSettings(_ sender: Any?) { noteModTapCommand(sender as? UIKeyCommand) NotificationCenter.default.post(name: .openSettings, object: self) diff --git a/rootshell/UI/Terminal/TmuxPaneZoomPickerView.swift b/rootshell/UI/Terminal/TmuxPanePickerView.swift similarity index 83% rename from rootshell/UI/Terminal/TmuxPaneZoomPickerView.swift rename to rootshell/UI/Terminal/TmuxPanePickerView.swift index a764b23eb..3e339bda9 100644 --- a/rootshell/UI/Terminal/TmuxPaneZoomPickerView.swift +++ b/rootshell/UI/Terminal/TmuxPanePickerView.swift @@ -2,17 +2,19 @@ import UIKit /// Takes keyboard focus while choosing. It never forwards input to a terminal. /// The host supplies full-tree rectangles even when tmux is currently zoomed. -final class TmuxPaneZoomPickerView: UIView, UIKeyInput { - private var selection: TmuxPaneZoomSelection +final class TmuxPanePickerView: UIView, UIKeyInput { + private var selection: TmuxPaneSelection private let buttons: [UIButton] private let instruction = UILabel() + private let sourceLabel = UILabel() private var heldKeys = Set() - private var pendingResult: TmuxPaneZoomSelection.Result = .pending + private var pendingResult: TmuxPaneSelection.Result = .pending private var lastInput: (text: String, modified: Bool, time: TimeInterval)? private var finishScheduled = false var onFinish: ((Int?) -> Void)? - init(selection: TmuxPaneZoomSelection, titles: [String], preview: Bool) { + init(selection: TmuxPaneSelection, titles: [String], preview: Bool, + action: TmuxPaneSelection.Action, sourceTitle: String?) { self.selection = selection buttons = selection.labels.enumerated().map { index, label in let button = UIButton(type: .system) @@ -28,7 +30,10 @@ final class TmuxPaneZoomPickerView: 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) @@ -38,7 +43,19 @@ final class TmuxPaneZoomPickerView: 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 @@ -64,7 +81,8 @@ final class TmuxPaneZoomPickerView: 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?) { + 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/rootshellTests/TmuxPaneSelectionTests.swift b/rootshellTests/TmuxPaneSelectionTests.swift new file mode 100644 index 000000000..1fc20e162 --- /dev/null +++ b/rootshellTests/TmuxPaneSelectionTests.swift @@ -0,0 +1,144 @@ +import XCTest + +final class TmuxPaneSelectionTests: XCTestCase { + func testSwapExcludesSourceAndKeepsDisplayOrder() throws { + var selection = try XCTUnwrap(TmuxPaneSelection(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(TmuxPaneSelection(paneIDs: [42, 7], excludingPaneID: 42)) + XCTAssertEqual(selection.labels, ["1"]) + XCTAssertEqual(selection.consume("1"), .selected(7)) + } + + func testMissingSwapSourceIsRejected() { + XCTAssertNil(TmuxPaneSelection(paneIDs: [42, 7], excludingPaneID: 91)) + } + + func testSwapUsesSameCancellationRulesIncludingEscape() throws { + for input in ["\u{1b}", "x", "0", "2", "\r", "\t", "12"] { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: [42, 7], excludingPaneID: 42)) + XCTAssertEqual(selection.consume(input), .cancelled) + XCTAssertEqual(selection.consume("1"), .cancelled) + } + var modified = try XCTUnwrap(TmuxPaneSelection(paneIDs: [42, 7], excludingPaneID: 42)) + XCTAssertEqual(modified.consume("1", modified: true), .cancelled) + } + + func testSwapPaddingUsesTargetCountNotTotalPaneCount() throws { + let nine = try XCTUnwrap(TmuxPaneSelection(paneIDs: Array(0..<10), excludingPaneID: 3)) + XCTAssertEqual(nine.labels, (1...9).map(String.init)) + var ten = try XCTUnwrap(TmuxPaneSelection(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 TmuxPaneSelection.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 TmuxPaneSelection.swapCommand(windowID: window, sourcePaneID: source, targetPaneID: target)) + } + } + + func testRejectsSinglePaneDuplicateAndInvalidIDs() { + for ids in [[], [1], [1, 1], [-1, 2]] { + XCTAssertNil(TmuxPaneSelection(paneIDs: ids)) + } + } + + func testLabelsSelectStableIDsInDisplayOrder() throws { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: [42, 7, 91])) + XCTAssertEqual(selection.labels, ["1", "2", "3"]) + XCTAssertEqual(selection.consume("2"), .selected(7)) + } + + func testTenPanesUseUnambiguousPaddedLabels() throws { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: Array(20..<30))) + XCTAssertEqual(selection.labels.first, "01") + XCTAssertEqual(selection.labels.last, "10") + XCTAssertEqual(selection.consume("1"), .pending) + XCTAssertEqual(selection.consume("0"), .selected(29)) + } + + func testLeadingZeroSelectsFirstPane() throws { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: Array(20..<30))) + XCTAssertEqual(selection.consume("0"), .pending) + XCTAssertEqual(selection.consume("1"), .selected(20)) + } + + func testHundredPanesRemainUnambiguous() throws { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: Array(0..<100))) + XCTAssertEqual(selection.labels.first, "001") + XCTAssertEqual(selection.consume("1"), .pending) + XCTAssertEqual(selection.consume("0"), .pending) + XCTAssertEqual(selection.consume("0"), .selected(99)) + } + + func testOtherInputCancelsIncludingPasteAndModifiedDigits() throws { + for input in ["", "a", "\u{1b}", "\r", "\t", "٣", "12", "12", "0", "9"] { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: [8, 9])) + XCTAssertEqual(selection.consume(input), .cancelled, input) + } + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: [8, 9])) + XCTAssertEqual(selection.consume("1", modified: true), .cancelled) + } + + func testInvalidPrefixCancelsImmediately() throws { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: Array(0..<12))) + XCTAssertEqual(selection.consume("2"), .cancelled) + } + + func testFinishedSelectionCannotChange() throws { + var selection = try XCTUnwrap(TmuxPaneSelection(paneIDs: [8, 9])) + XCTAssertEqual(selection.consume("1"), .selected(8)) + XCTAssertEqual(selection.consume("2"), .selected(8)) + var cancelled = try XCTUnwrap(TmuxPaneSelection(paneIDs: [8, 9])) + XCTAssertEqual(cancelled.consume("x"), .cancelled) + XCTAssertEqual(cancelled.consume("1"), .cancelled) + } + + @MainActor + func testZoomQueriesServerAndScopesEveryTarget() async throws { + for alreadyZoomed in [false, true] { + var commands: [String] = [] + try await TmuxPaneSelection.zoom(windowID: 4, paneID: 17) { command in + commands.append(command) + return command.hasPrefix("display-message") ? (alreadyZoomed ? "1\n" : "0\n") : "" + } + var expected = ["select-pane -Z -t @4.%17", "display-message -p -t @4.%17 '#{window_zoomed_flag}'"] + if !alreadyZoomed { expected.append("resize-pane -Z -t @4.%17") } + XCTAssertEqual(commands, expected) + } + } + + @MainActor + func testMalformedReplyDoesNotToggleZoom() async { + var commands: [String] = [] + do { + try await TmuxPaneSelection.zoom(windowID: 4, paneID: 17) { command in + commands.append(command) + return "bad reply" + } + XCTFail("Must reject malformed zoom state") + } catch { } + XCTAssertEqual(commands.count, 2) + } + + @MainActor + func testInvalidTargetSendsNothing() async { + for (window, pane) in [(-1, 17), (4, -1)] { + do { + try await TmuxPaneSelection.zoom(windowID: window, paneID: pane) { _ in + XCTFail("Invalid target must not send a command") + return "" + } + XCTFail("Must reject invalid target") + } catch { } + } + } +} diff --git a/rootshellTests/TmuxPaneZoomSelectionTests.swift b/rootshellTests/TmuxPaneZoomSelectionTests.swift deleted file mode 100644 index ef4db583f..000000000 --- a/rootshellTests/TmuxPaneZoomSelectionTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -import XCTest - -final class TmuxPaneZoomSelectionTests: XCTestCase { - func testRejectsSinglePaneDuplicateAndInvalidIDs() { - for ids in [[], [1], [1, 1], [-1, 2]] { - XCTAssertNil(TmuxPaneZoomSelection(paneIDs: ids)) - } - } - - func testLabelsSelectStableIDsInDisplayOrder() throws { - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: [42, 7, 91])) - XCTAssertEqual(selection.labels, ["1", "2", "3"]) - XCTAssertEqual(selection.consume("2"), .selected(7)) - } - - func testTenPanesUseUnambiguousPaddedLabels() throws { - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: Array(20..<30))) - XCTAssertEqual(selection.labels.first, "01") - XCTAssertEqual(selection.labels.last, "10") - XCTAssertEqual(selection.consume("1"), .pending) - XCTAssertEqual(selection.consume("0"), .selected(29)) - } - - func testLeadingZeroSelectsFirstPane() throws { - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: Array(20..<30))) - XCTAssertEqual(selection.consume("0"), .pending) - XCTAssertEqual(selection.consume("1"), .selected(20)) - } - - func testHundredPanesRemainUnambiguous() throws { - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: Array(0..<100))) - XCTAssertEqual(selection.labels.first, "001") - XCTAssertEqual(selection.consume("1"), .pending) - XCTAssertEqual(selection.consume("0"), .pending) - XCTAssertEqual(selection.consume("0"), .selected(99)) - } - - func testOtherInputCancelsIncludingPasteAndModifiedDigits() throws { - for input in ["", "a", "\u{1b}", "\r", "\t", "٣", "12", "12", "0", "9"] { - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: [8, 9])) - XCTAssertEqual(selection.consume(input), .cancelled, input) - } - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: [8, 9])) - XCTAssertEqual(selection.consume("1", modified: true), .cancelled) - } - - func testInvalidPrefixCancelsImmediately() throws { - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: Array(0..<12))) - XCTAssertEqual(selection.consume("2"), .cancelled) - } - - func testFinishedSelectionCannotChange() throws { - var selection = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: [8, 9])) - XCTAssertEqual(selection.consume("1"), .selected(8)) - XCTAssertEqual(selection.consume("2"), .selected(8)) - var cancelled = try XCTUnwrap(TmuxPaneZoomSelection(paneIDs: [8, 9])) - XCTAssertEqual(cancelled.consume("x"), .cancelled) - XCTAssertEqual(cancelled.consume("1"), .cancelled) - } - - @MainActor - func testZoomQueriesServerAndScopesEveryTarget() async throws { - for alreadyZoomed in [false, true] { - var commands: [String] = [] - try await TmuxPaneZoomSelection.zoom(windowID: 4, paneID: 17) { command in - commands.append(command) - return command.hasPrefix("display-message") ? (alreadyZoomed ? "1\n" : "0\n") : "" - } - var expected = ["select-pane -Z -t @4.%17", "display-message -p -t @4.%17 '#{window_zoomed_flag}'"] - if !alreadyZoomed { expected.append("resize-pane -Z -t @4.%17") } - XCTAssertEqual(commands, expected) - } - } - - @MainActor - func testMalformedReplyDoesNotToggleZoom() async { - var commands: [String] = [] - do { - try await TmuxPaneZoomSelection.zoom(windowID: 4, paneID: 17) { command in - commands.append(command) - return "bad reply" - } - XCTFail("Must reject malformed zoom state") - } catch { } - XCTAssertEqual(commands.count, 2) - } - - @MainActor - func testInvalidTargetSendsNothing() async { - for (window, pane) in [(-1, 17), (4, -1)] { - do { - try await TmuxPaneZoomSelection.zoom(windowID: window, paneID: pane) { _ in - XCTFail("Invalid target must not send a command") - return "" - } - XCTFail("Must reject invalid target") - } catch { } - } - } -}