Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions rootshell-helper/Tests/TmuxSplitEqualizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
17 changes: 17 additions & 0 deletions rootshell/App/AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion rootshell/App/CatalystAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions rootshell/App/UIApplication+CommandFallback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions rootshell/Core/Keybinds/KeybindAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion rootshell/Core/Keybinds/KeybindCommandGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions rootshell/Core/Keybinds/KeybindManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 6 additions & 4 deletions rootshell/Features/Multiplexer/PaneZoomSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ struct PaneZoomSelection<PaneID: Hashable> {
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
}
Expand Down
30 changes: 27 additions & 3 deletions rootshell/Features/Tmux/TmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>) {
requestPaneSelection(windowID: windowID, paneID: paneID, sourcePaneID: nil, expectedPaneIDs: expectedPaneIDs)
}

func requestSwapPane(windowID: Int, sourcePaneID: Int, targetPaneID: Int, expectedPaneIDs: Set<Int>) {
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<Int>) {
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
Expand All @@ -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)")
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions rootshell/Features/Tmux/TmuxPaneZoomCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions rootshell/UI/Shell/MainView+Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions rootshell/UI/Shell/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading