diff --git a/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift b/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift index 9fe045f4a..add91e365 100644 --- a/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift +++ b/rootshell-helper/Tests/LocalMultiplexerRecoveryTests.swift @@ -183,7 +183,11 @@ final class LocalMultiplexerRecoveryTests: XCTestCase { XCTAssertTrue(LocalMultiplexerRecovery.isAvailable(saved)) let target = LocalHerdrControlTarget(sessionName: "ignored", attachment: saved) let observed = LocalMultiplexerRecovery.inspectHerdrControl(target, records: LocalMultiplexerRecovery.processes(), deadline: Date().addingTimeInterval(3)) - XCTAssertEqual(observed, saved) + // Recovery prefers the installed rootshell client, while retaining the + // verified server/socket/session identity from the original client. + var expected = saved + expected.executable = saved.launchExecutable + XCTAssertEqual(observed, expected) // A raw-capable server accepts two fresh pipe clients with the same // boot identity. EOF closes each bridge; the original server survives. let status = try XCTUnwrap(LocalMultiplexerRecovery.run(executable, ["status", "--json"], diff --git a/rootshell.xcodeproj/project.pbxproj b/rootshell.xcodeproj/project.pbxproj index 5553d9ded..fbabd69e1 100644 --- a/rootshell.xcodeproj/project.pbxproj +++ b/rootshell.xcodeproj/project.pbxproj @@ -280,6 +280,7 @@ F95F66F45A2A58F968B83428 /* VimRuntime.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 47A51A922EEDC704000D76E0 /* VimRuntime.bundle */; platformFilters = (ios, xros, ); }; F9641A2175775B2B2284EFF2 /* AppIconRadicalDracula.icon in Resources */ = {isa = PBXBuildFile; fileRef = 478D16DE2F9FC42B007B574B /* AppIconRadicalDracula.icon */; }; FA804455DA96B007ACED884C /* network_ios in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, xros, ); productRef = 47NWPKG12FF10000AABB0001 /* network_ios */; }; + 47AF04300000000000000A01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -650,6 +651,7 @@ 47WASM00000000000000001 /* Wasm */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Wasm; path = Resources/Wasm; sourceTree = ""; }; 992A6D1173F54B70A4EDBA3E /* joe */ = {isa = PBXFileReference; lastKnownFileType = folder; name = joe; path = Resources/joe; sourceTree = ""; }; DD1249A5776E91521A4BEA8A /* rootshell.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = rootshell.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Features/Multiplexer/MuxSessionTarget.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -1223,6 +1225,7 @@ 47ABC10D2FF50000AABB000D /* rootshell-helper/Sources/PTYManagerImpl.m */, 47ABC10E2FF50000AABB000E /* rootshell-helper/Sources/ProcessSpawner.m */, 47AEC0020000000000000010 /* rootshell/Core/Shell/LoginShellCommand.swift */, + 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */, 47AEC0010000000000000010 /* rootshell/Core/Persistence/LocalMultiplexerAttachment.swift */, 47AF04710000000000000004 /* rootshell/Core/Keybinds/KeyTrigger.swift */, 47AF04710000000000000006 /* rootshell/Core/Keybinds/HardwareKeyboardText.swift */, @@ -2251,6 +2254,7 @@ 47AF04960000000000000A01 /* rootshell/Core/Terminal/RemoteSessionBackgroundGracePolicy.swift in Sources */, 47AF04850000000000000A01 /* rootshell/Core/Terminal/TerminalScrollbarAvailabilityPolicy.swift in Sources */, 47AEC0020000000000000003 /* rootshell/Core/Shell/LoginShellCommand.swift in Sources */, + 47AF04300000000000000A01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift in Sources */, 47AF0F1D0000000000000B01 /* rootshell/Core/Shell/InitialDirectoryCommand.swift in Sources */, 47F11E0000000000000000B1 /* rootshell/Features/FileManager/Transfers/FileTransferLogic.swift in Sources */, 47F11E0000000000000000B2 /* rootshell/Features/FileManager/Model/FileListSelection.swift in Sources */, diff --git a/rootshell/App/AppCommands.swift b/rootshell/App/AppCommands.swift index 337894752..8a03f7f56 100644 --- a/rootshell/App/AppCommands.swift +++ b/rootshell/App/AppCommands.swift @@ -724,6 +724,14 @@ struct WindowCommands: Commands { } .modifier(DynamicShortcut(action: .discover_sessions, shortcuts: shortcutState.shortcuts)) + Button("Detach Session") { + UIApplication.shared.sendAction( + #selector(Ghostty.TerminalView.menuDetachSession(_:)), + to: nil, from: nil, for: nil + ) + } + .modifier(DynamicShortcut(action: .detach_session, shortcuts: shortcutState.shortcuts)) + Button("Detach Other Clients") { UIApplication.shared.sendMenuAction( #selector(Ghostty.TerminalView.menuDetachOtherClients(_:)), diff --git a/rootshell/App/CatalystAppDelegate.swift b/rootshell/App/CatalystAppDelegate.swift index e8626ac7c..dd148a067 100644 --- a/rootshell/App/CatalystAppDelegate.swift +++ b/rootshell/App/CatalystAppDelegate.swift @@ -357,6 +357,10 @@ extension UIApplication { sendAction(#selector(Ghostty.TerminalView.menuDiscoverSessions(_:)), to: nil, from: sender, for: nil) } + @objc func ghostty_detachSession(_ sender: Any?) { + sendAction(#selector(Ghostty.TerminalView.menuDetachSession(_:)), to: nil, from: sender, for: nil) + } + @objc func ghostty_detachOtherClients(_ sender: Any?) { sendAction(#selector(Ghostty.TerminalView.menuDetachOtherClients(_:)), to: nil, from: sender, for: nil) } @@ -1627,6 +1631,11 @@ class CatalystAppDelegate: AppDelegate { modifierFlags: [.command, .control] ) + let detachSession = UICommand( + title: String(localized: "Detach Session"), + action: #selector(UIApplication.ghostty_detachSession(_:)) + ) + let detachOtherClients = UIKeyCommand( title: String(localized: "Detach Other Clients"), action: #selector(UIApplication.ghostty_detachOtherClients(_:)), @@ -1657,7 +1666,7 @@ class CatalystAppDelegate: AppDelegate { let navGroup = UIMenu(title: "", options: .displayInline, children: [ toggleTabSwitcher, toggleTabExpose, previousTab, nextTab, previousGroup, nextGroup, tmuxSessions, - discoverSessions, detachOtherClients + discoverSessions, detachSession, detachOtherClients ]) // Tab selection (1-9), each with its own action (see ghostty_selectTabN). diff --git a/rootshell/App/UIApplication+CommandFallback.swift b/rootshell/App/UIApplication+CommandFallback.swift index d1c91fdea..93361c14f 100644 --- a/rootshell/App/UIApplication+CommandFallback.swift +++ b/rootshell/App/UIApplication+CommandFallback.swift @@ -327,6 +327,10 @@ extension UIApplication { ghostty_postNotification(.discoverSessions) } + @objc func menuDetachSession(_ sender: Any?) { + ghostty_postNotification(.detachSession) + } + @objc func menuDetachOtherClients(_ sender: Any?) { ghostty_postNotification(.detachOtherClients) } diff --git a/rootshell/Core/Keybinds/KeybindAction.swift b/rootshell/Core/Keybinds/KeybindAction.swift index f3bce3d7d..0a4e918a4 100644 --- a/rootshell/Core/Keybinds/KeybindAction.swift +++ b/rootshell/Core/Keybinds/KeybindAction.swift @@ -102,6 +102,8 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case show_tmux_sessions = "show_tmux_sessions" /// Re-run multiplexer session discovery on the focused terminal case discover_sessions = "discover_sessions" + /// Detach the current tab from its multiplexer (tmux / zellij / herdr / zmx) + case detach_session = "detach_session" /// Detach all OTHER tmux clients from the current gateway /// (`detach-client -a`); on a herdr tab, take the whole session instead case detach_other_clients = "detach_other_clients" @@ -312,7 +314,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .new_local_shell, .new_tab, .new_window, .close_tab, .duplicate_ssh_tab, .previous_tab, .next_tab, .show_tmux_sessions, .discover_sessions, - .detach_other_clients, .toggle_tab_switcher, + .detach_session, .detach_other_clients, .toggle_tab_switcher, .toggle_tab_expose, .previous_group, .next_group, .select_tab_1, .select_tab_2, .select_tab_3, .select_tab_4, .select_tab_5, .select_tab_6, .select_tab_7, .select_tab_8, .select_tab_9: return .tabs @@ -371,6 +373,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .next_tab: return String(localized: "Next Tab", comment: "Keybind action") case .show_tmux_sessions: return String(localized: "Sessions & Workspaces", comment: "Keybind action") case .discover_sessions: return String(localized: "Discover Sessions", comment: "Keybind action") + case .detach_session: return String(localized: "Detach Session", comment: "Keybind action: leave multiplexer, keep session") case .detach_other_clients: return String(localized: "Detach Other Clients", comment: "Keybind action") case .select_tab_1: return String(localized: "Select Tab 1", comment: "Keybind action") @@ -475,6 +478,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { case .next_tab: return .nextTab case .show_tmux_sessions: return .showTmuxSessions case .discover_sessions: return .discoverSessions + case .detach_session: return .detachSession case .detach_other_clients: return .detachOtherClients case .select_tab_1, .select_tab_2, .select_tab_3, .select_tab_4, .select_tab_5, .select_tab_6, .select_tab_7, .select_tab_8, .select_tab_9: @@ -637,7 +641,7 @@ enum KeybindAction: String, CaseIterable, Codable, Identifiable, Hashable { .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, - .discover_sessions, .detach_other_clients, + .discover_sessions, .detach_session, .detach_other_clients, .increase_font_size, .decrease_font_size, .reset_font_size, .start_search: return true diff --git a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift index f9d4ff25f..72c5ac40d 100644 --- a/rootshell/Core/Keybinds/KeybindCommandGenerator.swift +++ b/rootshell/Core/Keybinds/KeybindCommandGenerator.swift @@ -131,7 +131,7 @@ final class KeybindCommandGenerator: ObservableObject { .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, - .detach_other_clients, + .detach_session, .detach_other_clients, .toggle_transparency, .toggle_titlebar, .toggle_auto_redact, .toggle_background_effect, .toggle_compose, .toggle_full_screen, .toggle_mouse_capture, .cycle_input_source, .increase_font_size, .decrease_font_size, diff --git a/rootshell/Core/Persistence/SerializableConnectionConfig.swift b/rootshell/Core/Persistence/SerializableConnectionConfig.swift index 38f87e39a..3e29a9021 100644 --- a/rootshell/Core/Persistence/SerializableConnectionConfig.swift +++ b/rootshell/Core/Persistence/SerializableConnectionConfig.swift @@ -172,6 +172,8 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { /// Per-profile multiplexer session name. Optional for backward compat — /// older serialized sessions decode as nil and use the global default. let multiplexerSessionName: String? + /// Captured attachment target belongs to tab restoration, not profiles. + let muxResumeTarget: MuxSessionTarget? /// Folder the pane was opened in (Open in Folder). Optional for /// backward compat; nil restores in the login directory. let initialDirectory: String? @@ -211,6 +213,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { self.launchCommandMode = config.launchCommandMode self.terminalType = config.terminalType self.multiplexerSessionName = config.multiplexerSessionName + self.muxResumeTarget = config.muxResumeTarget self.initialDirectory = config.initialDirectory // Convert auth method, stripping passwords @@ -296,6 +299,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { config.launchCommandMode = launchCommandMode ?? .afterConnect config.terminalType = terminalType config.multiplexerSessionName = multiplexerSessionName + config.muxResumeTarget = muxResumeTarget config.initialDirectory = initialDirectory if let jump = jumpHost { diff --git a/rootshell/Core/Terminal/TerminalSessionController.swift b/rootshell/Core/Terminal/TerminalSessionController.swift index 57a686b58..71c89e653 100644 --- a/rootshell/Core/Terminal/TerminalSessionController.swift +++ b/rootshell/Core/Terminal/TerminalSessionController.swift @@ -1101,7 +1101,7 @@ final class TerminalSessionController { /// For resumable sessions (Trzsz/Mosh, and local sessions with an active /// embedded session) the `reason` drives whether we tell the server to /// close: `.sceneTeardown` keeps the server-side session alive so resume - /// can pick it back up; `.userClose`/`.transferOut` terminate it. + /// can pick it back up; `.userClose`/`.muxDetach`/`.transferOut` terminate it. func teardown(reason: Ghostty.TerminalView.CleanupReason) { localSessionCreateGeneration &+= 1 responsePipeline.cancel() @@ -1109,7 +1109,7 @@ final class TerminalSessionController { if let trzszSession = session as? TrzszSession { switch reason { - case .userClose: + case .userClose, .muxDetach: trzszSession.terminate() case .sceneTeardown: trzszSession.stopForReconnect() @@ -1118,7 +1118,7 @@ final class TerminalSessionController { } } else if let moshSession = session as? MoshSession { switch reason { - case .userClose, .transferOut: + case .userClose, .muxDetach, .transferOut: // Mosh has no peer-attach concept, so a transferOut on a // mosh session would be a logic bug; treat it as a user // close so we don't leave a zombie server session. diff --git a/rootshell/Features/Herdr/HerdrController.swift b/rootshell/Features/Herdr/HerdrController.swift index 07f216b9f..0ff41dda0 100644 --- a/rootshell/Features/Herdr/HerdrController.swift +++ b/rootshell/Features/Herdr/HerdrController.swift @@ -793,9 +793,19 @@ final class HerdrController { /// User-initiated detach: ends control mode, optionally closing the /// gateway tab too. The herdr session keeps running on the host. - func detach(closeGateway: Bool) { + func detach(closeGateway: Bool, announce: Bool = true) { let gatewayView = gateway let windowId = hostWindowId + // Same choke point as tmux `requestGracefulDetach`: menu, ESC, and + // Detach Session all show the reconnect banner. + if announce, isActive || !didEnd { + MuxSessionDetach.notifyControlModeDetached( + type: .herdr, + sessionName: sessionName, + windowId: windowId, + terminal: gatewayView + ) + } stop() guard closeGateway, let gatewayView else { return } // Same routing a dying tab uses: the .closeSplit observer resolves diff --git a/rootshell/Features/Multiplexer/MuxDetachBannerState.swift b/rootshell/Features/Multiplexer/MuxDetachBannerState.swift new file mode 100644 index 000000000..4573eeffd --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxDetachBannerState.swift @@ -0,0 +1,14 @@ +// +// MuxDetachBannerState.swift +// rootshell +// +// Transient banner after mux detach (or when focusing an already-live +// attachment) with an optional Reconnect action. +// + +import Foundation + +struct MuxDetachBannerState: Equatable { + let message: String + let offer: MuxSessionResume.ReconnectOffer? +} diff --git a/rootshell/Features/Multiplexer/MuxSessionDetach.swift b/rootshell/Features/Multiplexer/MuxSessionDetach.swift new file mode 100644 index 000000000..d68f0903d --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxSessionDetach.swift @@ -0,0 +1,417 @@ +// +// MuxSessionDetach.swift +// rootshell +// +// Unified "leave the multiplexer, keep the session" detach for every +// supported multiplexer. tmux -CC uses the existing graceful control-mode +// detach; raw / passthrough attachments type each multiplexer’s native +// detach chord into the pane PTY (same idea as iTerm2’s detach: leave +// cleanly and reattach later via session discovery). +// + +import Foundation +import UIKit +import os + +/// Classifies and performs a session detach for a terminal pane or tmux +/// control-mode gateway. +@MainActor +enum MuxSessionDetach { + /// Posted on `.closeSplit` so MainView tears the pane down with + /// `.muxDetach`. Close Tab also leaves zmx running (native zmx semantics). + static let leaveMuxSessionUserInfoKey = "leaveMuxSession" + + /// How this attachment should leave the multiplexer. + enum Kind: Equatable { + /// Graceful `detach-client` through the tmux -CC viewer. + case tmuxControlMode + /// `HerdrController.detach` — ends control mode, session stays up. + case herdrControlMode + /// Native key chord typed into the pane (raw / passthrough). + case keySequence(MultiplexerType) + } + + struct Attachment: Equatable { + let kind: Kind + let sessionName: String? + let displayName: String + var tmuxSocket: TmuxSocketIdentity? = .defaultServer + var tmuxSocketSelector: TmuxSocketIdentity? + + var resumeTarget: MuxSessionTarget? { + switch kind { + case .tmuxControlMode: + return MuxSessionTarget(type: .tmux, sessionName: sessionName, controlMode: true, tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector) + case .herdrControlMode: + return MuxSessionTarget(type: .herdr, sessionName: sessionName ?? "default", controlMode: true) + case .keySequence(let type): + return MuxSessionTarget(type: type, sessionName: sessionName, tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector) + } + } + } + + /// Result of attempting a detach. + enum Outcome: Equatable { + case detached(Attachment) + case none + } + + /// Native detach chords. Prefix-based tools need a short settle so the + /// multiplexer sees the follow-up key as a command, not literal input. + private static let prefixSettleDelay: TimeInterval = 0.08 + + /// After the detach chord, wait for the remote client to exit before + /// tearing down the local tab (mirrors MultiplexerExposeFeed’s zmx timing). + private static func postDetachSettleDelay(for type: MultiplexerType) -> TimeInterval { + switch type { + case .zmx: + // zmx’s detach (Ctrl-\) is asynchronous; closing too early HUP’s the + // still-attached client instead of a clean leave. + return 0.45 + case .tmux, .zellij, .herdr: + return 0.2 + } + } + + /// Inspect a terminal for a detachable multiplexer attachment. + static func attachment(on terminal: Ghostty.TerminalView) -> Attachment? { + if terminal.tmuxController?.isActive == true || terminal.isTmuxGatewaySurfaceActive { + let name = terminal.tmuxController?.currentSessionName + return Attachment( + kind: .tmuxControlMode, + sessionName: name, + displayName: displayName(for: .tmux, sessionName: name), + tmuxSocket: terminal.tmuxController?.resumeSocket, + tmuxSocketSelector: terminal.tmuxController?.configuredResumeSocket + ) + } + if let controller = HerdrController.controller(for: terminal), controller.isActive { + let name = controller.sessionName + return Attachment( + kind: .herdrControlMode, + sessionName: name, + displayName: displayName(for: .herdr, sessionName: name) + ) + } + if let binding = terminal.passthroughMultiplexer { + return Attachment( + kind: .keySequence(binding.type), + sessionName: binding.sessionName, + displayName: displayName(for: binding.type, sessionName: binding.sessionName), + tmuxSocket: binding.tmuxSocket, + tmuxSocketSelector: binding.tmuxSocketSelector + ) + } + if let binding = terminal.rawMultiplexer { + return Attachment( + kind: .keySequence(binding.type), + sessionName: binding.sessionName, + displayName: displayName(for: binding.type, sessionName: binding.sessionName), + tmuxSocket: binding.tmuxSocket, + tmuxSocketSelector: binding.tmuxSocketSelector + ) + } + return nil + } + + /// Resolve a detach target for a tab: prefer the tab’s own panes, then a + /// tmux -CC controller reachable from a window tab (so Detach works even + /// when the gateway tab is auto-hidden). + static func attachment( + for tab: TabModel, + tmuxController: (TabModel) -> TmuxController? + ) -> Attachment? { + for view in tab.splitTree.terminalLeaves { + if let attachment = attachment(on: view), + attachment.kind != .tmuxControlMode, + attachment.kind != .herdrControlMode { + return attachment + } + } + if let controller = HerdrController.controller(forAnyTab: tab), controller.isActive { + let name = controller.sessionName + return Attachment( + kind: .herdrControlMode, + sessionName: name, + displayName: displayName(for: .herdr, sessionName: name) + ) + } + if let controller = tmuxController(tab), controller.isActive { + let name = controller.currentSessionName + return Attachment( + kind: .tmuxControlMode, + sessionName: name, + displayName: displayName(for: .tmux, sessionName: name), + tmuxSocket: controller.resumeSocket, + tmuxSocketSelector: controller.configuredResumeSocket + ) + } + for view in tab.splitTree.terminalLeaves { + if let attachment = attachment(on: view) { + return attachment + } + } + return nil + } + + /// Detach the multiplexer on `terminal`. For tmux -CC this routes through + /// the controller; otherwise the native detach chord is typed into the PTY. + @discardableResult + static func detach(on terminal: Ghostty.TerminalView) -> Outcome { + guard let attachment = attachment(on: terminal) else { return .none } + switch attachment.kind { + case .tmuxControlMode: + // Banner is posted inside requestGracefulDetach (via sendTmuxDetach). + terminal.sendTmuxDetach() + return .detached(attachment) + case .herdrControlMode: + // Banner is posted inside HerdrController.detach. + HerdrController.controller(for: terminal)?.detach(closeGateway: false) + return .detached(attachment) + case .keySequence(let type): + performKeySequenceDetach(on: terminal, type: type) + announce(attachment, reconnectFrom: terminal) + return .detached(attachment) + } + } + + /// Detach whatever multiplexer backs `tab` (window tabs resolve to their + /// gateway controller for tmux -CC). + @discardableResult + static func detach( + tab: TabModel, + tmuxController: (TabModel) -> TmuxController? + ) -> Outcome { + // Prefer an in-pane raw/passthrough binding on this tab before falling + // through to the tmux gateway — a split that hosts zmx beside a tmux + // pane should detach the focused attachment, not the whole gateway. + if let focused = tab.focusedTerminal, + let attachment = attachment(on: focused), + attachment.kind != .tmuxControlMode, + attachment.kind != .herdrControlMode { + return detach(on: focused) + } + + if let controller = HerdrController.controller(forAnyTab: tab), controller.isActive { + let name = controller.sessionName + let attachment = Attachment( + kind: .herdrControlMode, + sessionName: name, + displayName: displayName(for: .herdr, sessionName: name) + ) + controller.detach(closeGateway: false) + return .detached(attachment) + } + + if let controller = tmuxController(tab), controller.isActive { + let name = controller.currentSessionName + let attachment = Attachment( + kind: .tmuxControlMode, + sessionName: name, + displayName: displayName(for: .tmux, sessionName: name), + tmuxSocket: controller.resumeSocket, + tmuxSocketSelector: controller.configuredResumeSocket + ) + // Banner is posted inside requestGracefulDetach. + controller.requestGracefulDetach(source: "keybind") + return .detached(attachment) + } + + for view in tab.splitTree.terminalLeaves { + if attachment(on: view) != nil { + return detach(on: view) + } + } + return .none + } + + // MARK: - Key sequences + + /// Type the multiplexer’s native detach chord (when needed), drop local + /// bindings, then close the pane/tab — same journey as iTerm2’s Shell → + /// tmux → Detach (remote session keeps running; local UI goes away). + static func performKeySequenceDetach( + on terminal: Ghostty.TerminalView, + type: MultiplexerType + ) { + let steps = keySteps(for: type, on: terminal) + let settle = postDetachSettleDelay(for: type) + Task { @MainActor [weak terminal] in + guard let terminal else { return } + + // zmx: closing the client is the supported detach. Do not type + // Ctrl-\ first — pairing that with an immediate SSH teardown races + // the client exit and can destroy the session instead of leaving + // it for `zmx attach ` on reconnect. + if steps.isEmpty { + clearLocalBinding(on: terminal) + postCloseLeavingMuxSession(terminal) + return + } + + for (index, step) in steps.enumerated() { + terminal.sendUserInput(step) + if index < steps.count - 1 { + try? await Task.sleep(for: .seconds(Self.prefixSettleDelay)) + } + } + try? await Task.sleep(for: .seconds(settle)) + clearLocalBinding(on: terminal) + // Close this attachment’s UI (split or whole tab). closeSplit routes + // by the posted view, so a background-tab detach still targets the + // right pane even if the user switched away during the settle. + postCloseLeavingMuxSession(terminal) + } + } + + /// Close the local pane without destroying a zmx session. + private static func postCloseLeavingMuxSession(_ terminal: Ghostty.TerminalView) { + NotificationCenter.default.post( + name: .closeSplit, + object: terminal, + userInfo: [leaveMuxSessionUserInfoKey: true] + ) + } + + /// Bytes to type, in order. Prefers swipe-discovery bindings, then + /// built-in defaults. Empty means “close the local client only” (zmx). + static func keySteps(for type: MultiplexerType, on terminal: Ghostty.TerminalView? = nil) -> [Data] { + if let discovered = discoveredSteps(for: type, on: terminal) { + return dataSteps(from: discovered) + } + return defaultKeySteps(for: type) + } + + /// Built-in detach chords used when swipe discovery has not learned + /// the host's bindings. + static func defaultKeySteps(for type: MultiplexerType) -> [Data] { + switch type { + case .tmux: + // Default prefix Ctrl-b, then d. + return [Data([0x02]), Data("d".utf8)] + case .zellij: + // Session mode Ctrl-o, then d. + return [Data([0x0F]), Data("d".utf8)] + case .herdr: + // Prefix Ctrl-b, then q. + return [Data([0x02]), Data("q".utf8)] + case .zmx: + // No chord: zmx treats closing the client window as detach + // (https://zmx.sh/). Ctrl-\ remains available for manual use. + return [] + } + } + + private static func discoveredSteps( + for type: MultiplexerType, + on terminal: Ghostty.TerminalView? + ) -> [SequenceStep]? { + guard let bindings = terminal?.discoveredMultiplexerSwipeBindings else { return nil } + switch type { + case .tmux: + return bindings.tmuxDetachClient + case .zellij: + return bindings.zellijDetach + case .herdr, .zmx: + return nil + } + } + + private static func dataSteps(from steps: [SequenceStep]) -> [Data] { + steps.map { $0.terminalData() }.filter { !$0.isEmpty } + } + + private static func clearLocalBinding(on terminal: Ghostty.TerminalView) { + var changed = false + if terminal.rawMultiplexer != nil { + terminal.rawMultiplexer = nil + changed = true + } + if terminal.passthroughMultiplexer != nil { + terminal.passthroughMultiplexer = nil + changed = true + } + if changed { + AgentAttentionCenter.shared.topologyDidChange() + } + } + + private static func displayName(for type: MultiplexerType, sessionName: String?) -> String { + if let sessionName, !sessionName.isEmpty { + return "\(type.rawValue) “\(sessionName)”" + } + return type.rawValue + } + + /// Posted by `TmuxController.requestGracefulDetach` so every tmux -CC leave + /// path (context-menu confirm, dashboard, ESC, keybind, tab-close) shows the + /// same reconnect banner zmx already got via `detach(on:)`. + static func notifyControlModeDetached( + type: MultiplexerType = .tmux, + sessionName: String?, + windowId: String, + terminal: Ghostty.TerminalView?, + tmuxSocket: TmuxSocketIdentity? = nil, + tmuxSocketSelector: TmuxSocketIdentity? = nil + ) { + let kind: Kind = type == .herdr ? .herdrControlMode : .tmuxControlMode + let attachment = Attachment( + kind: kind, + sessionName: sessionName, + displayName: displayName(for: type, sessionName: sessionName), + tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector + ) + announce(attachment, reconnectFrom: terminal, windowId: windowId) + } + + private static func announce( + _ attachment: Attachment, + reconnectFrom terminal: Ghostty.TerminalView? = nil, + windowId: String? = nil + ) { + let message = String( + localized: "Detached from \(attachment.displayName). Session keeps running.", + comment: "Accessibility announcement after detaching a multiplexer" + ) + UIAccessibility.post(notification: .announcement, argument: message) + postReconnectOffer(attachment: attachment, terminal: terminal, windowId: windowId) + } + + private static func postReconnectOffer( + attachment: Attachment, + terminal: Ghostty.TerminalView?, + windowId: String? = nil + ) { + var userInfo: [AnyHashable: Any] = ["displayName": attachment.displayName] + if let windowId = windowId ?? terminal?.windowId { + userInfo["windowId"] = windowId + } + if let terminal, + let target = attachment.resumeTarget, + let ssh = terminal.connectionConfig.sshConfigForHistory + ?? terminal.connectionConfig.underlyingSSHConfig { + let proto: ConnectionProtocol + switch terminal.connectionConfig { + case .mosh, .shellLaunchedMosh: + proto = .mosh + case .trzsz, .shellLaunchedTrzsz: + proto = .trzsz + default: + proto = .ssh + } + userInfo["offer"] = MuxSessionResume.ReconnectOffer( + displayName: attachment.displayName, + sshConfig: ssh, + connectionProtocol: proto, + profileID: terminal.sourceProfileID, + target: target + ) + } + // object: nil — do not require the pane to still be in the tab tree. + // tmux -CC prune tears windows down as soon as control mode ends. + NotificationCenter.default.post(name: .muxSessionDidDetach, object: nil, userInfo: userInfo) + } +} diff --git a/rootshell/Features/Multiplexer/MuxSessionResume.swift b/rootshell/Features/Multiplexer/MuxSessionResume.swift new file mode 100644 index 000000000..746ab9ba1 --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxSessionResume.swift @@ -0,0 +1,231 @@ +// +// MuxSessionResume.swift +// rootshell +// +// Resume-or-focus: when a mux auto-start profile is opened while a live +// attachment to the same host/session already exists, focus that UI instead +// of spawning a second unrelated-looking control client. +// + +import Foundation +import UIKit + +@MainActor +enum MuxSessionResume { + struct Match: Equatable { + let windowId: String + let tabID: UUID + let displayName: String + } + + /// Payload for the post-detach reconnect banner. + struct ReconnectOffer: Equatable { + let displayName: String + let sshConfig: SSHConfig + let connectionProtocol: ConnectionProtocol + let profileID: UUID? + let target: MuxSessionTarget + } + + /// Find a live multiplexer attachment that matches this profile's auto-start + /// target. Returns nil when the profile does not auto-start a mux, or when + /// no matching attachment is open. + static func findLiveAttachment(for config: SSHConfig) -> Match? { + guard let target = autoStartTarget(for: config) else { return nil } + let gatewayKey = TmuxGatewaySessionStore.connectionKey( + host: config.host, + port: config.port, + username: config.username + ) + + for (windowId, model) in TmuxWindowRegistry.allWindows() { + for tab in model.tabs { + if let match = matchTmuxControl( + tab: tab, + model: model, + windowId: windowId, + gatewayKey: gatewayKey, + target: target + ) { + return match + } + if let match = matchHerdrControl( + tab: tab, + model: model, + windowId: windowId, + gatewayKey: gatewayKey, + target: target + ) { + return match + } + // A raw attachment cannot satisfy a request for native panes. + if target.controlMode { + continue + } + if let match = matchRawOrPassthrough( + tab: tab, + windowId: windowId, + gatewayKey: gatewayKey, + target: target + ) { + return match + } + } + } + return nil + } + + /// Focus an existing attachment: activate its window scene if needed, then + /// select the tab. Returns true when focus was requested. + @discardableResult + static func focus( + _ match: Match, + in currentWindowId: String, + selectTab: (UUID) -> Void + ) -> Bool { + if match.windowId != currentWindowId, + let sceneSessionId = TerminalWindowRegistry.sceneSessionId(for: match.windowId), + let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.session.persistentIdentifier == sceneSessionId }) { + UIApplication.shared.requestSceneSessionActivation( + scene.session, + userActivity: nil, + options: nil, + errorHandler: nil + ) + // Selecting across windows: ask the owning TabsModel directly. + if let model = TmuxWindowRegistry.tabsModel(for: match.windowId) { + model.selectedTabID = match.tabID + return true + } + } + selectTab(match.tabID) + return true + } + + // MARK: - Private + + private static func autoStartTarget(for config: SSHConfig) -> MuxSessionTarget? { + if let target = config.muxResumeTarget { return target } + if config.tmuxAutoEnable { + return MuxSessionTarget( + type: .tmux, + sessionName: config.tmuxSessionNameForConnection, + controlMode: config.tmuxAutoMode == .control, + tmuxSocket: config.tmuxSocketForResume + ) + } + if config.herdrAutoEnable { + return MuxSessionTarget( + type: .herdr, + sessionName: config.herdrSessionNameForConnection, + controlMode: config.herdrControlModeEnabled + ) + } + if config.zmxAutoEnable { + return MuxSessionTarget( + type: .zmx, + sessionName: config.zmxSessionNameForConnection, + controlMode: false + ) + } + return nil + } + + private static func matchTmuxControl( + tab: TabModel, + model: TabsModel, + windowId: String, + gatewayKey: String, + target: MuxSessionTarget + ) -> Match? { + guard target.type == .tmux, target.controlMode else { return nil } + let controller = + TmuxController.controller(forWindowTab: tab) + ?? TmuxController.controller(forGatewayTab: tab) + ?? tab.splitTree.terminalLeaves.first(where: { $0.tmuxController != nil })?.tmuxController + guard let controller else { return nil } + let attached = MuxSessionTarget(type: .tmux, sessionName: controller.currentSessionName, + controlMode: true, tmuxSocket: controller.resumeSocket, + tmuxSocketSelector: controller.configuredResumeSocket) + guard target.matchesLiveAttachment(attached, connectionKey: controller.connectionKey, + requestedConnectionKey: gatewayKey, isActive: controller.isActive) else { return nil } + let ownerID = controller.ownerTerminalUUIDForNotifications + let focusTab = model.tabs.first(where: { + $0.isTmuxWindow + && !$0.isHiddenTmuxWindow + && $0.owningGatewayTerminalUUID == ownerID + }) ?? model.tabs.first(where: { + $0.isTmuxGateway + && $0.splitTree.terminalLeaves.contains(where: { $0.tmuxController === controller }) + }) ?? tab + let name = target.sessionName + return Match( + windowId: windowId, + tabID: focusTab.id, + displayName: "tmux “\(name)”" + ) + } + + /// Live herdr control-mode family (gateway + projected tabs), analogous + /// to `matchTmuxControl`. The gateway pty is a normal shell, so a raw + /// herdr binding is not present. + private static func matchHerdrControl( + tab: TabModel, + model: TabsModel, + windowId: String, + gatewayKey: String, + target: MuxSessionTarget + ) -> Match? { + guard target.type == .herdr, target.controlMode, + let controller = HerdrController.controller(forAnyTab: tab) else { return nil } + let ssh = controller.gateway?.connectionConfig.sshConfigForHistory + ?? controller.gateway?.connectionConfig.underlyingSSHConfig + let connectionKey = ssh.map { + TmuxGatewaySessionStore.connectionKey(host: $0.host, port: $0.port, username: $0.username) + } + let attached = MuxSessionTarget(type: .herdr, sessionName: controller.sessionName ?? "default", controlMode: true) + guard target.matchesLiveAttachment(attached, connectionKey: connectionKey, + requestedConnectionKey: gatewayKey, isActive: controller.isActive) else { return nil } + let focusTab = model.tabs.first(where: { + $0.isHerdrWindow + && !$0.isHiddenTmuxWindow + && $0.owningGatewayTerminalUUID == controller.gatewayUUID + }) ?? model.tabs.first(where: { + $0.isHerdrGateway + && $0.splitTree.terminalLeaves.contains(where: { $0.herdrController === controller }) + }) ?? tab + let name = target.sessionName + return Match( + windowId: windowId, + tabID: focusTab.id, + displayName: "herdr “\(name)”" + ) + } + + private static func matchRawOrPassthrough( + tab: TabModel, + windowId: String, + gatewayKey: String, + target: MuxSessionTarget + ) -> Match? { + for view in tab.splitTree.terminalLeaves { + // Configuration alone is not proof of an attachment: failed + // connections and shells left after detach retain that config. + guard view.herdrController == nil, view.tmuxController == nil, + let binding = view.rawMultiplexer ?? view.passthroughMultiplexer else { continue } + let connectionKey = view.connectionConfig.sshConfigForHistory.map { + TmuxGatewaySessionStore.connectionKey(host: $0.host, port: $0.port, username: $0.username) + } + let attached = MuxSessionTarget(type: binding.type, sessionName: binding.sessionName, tmuxSocket: binding.tmuxSocket, + tmuxSocketSelector: binding.tmuxSocketSelector) + guard target.matchesLiveAttachment(attached, connectionKey: connectionKey, + requestedConnectionKey: gatewayKey, + isActive: view.session?.isRunning == true) else { continue } + return Match(windowId: windowId, tabID: tab.id, + displayName: "\(target.type.rawValue) “\(target.sessionName)”") + } + return nil + } +} diff --git a/rootshell/Features/Multiplexer/MuxSessionTarget.swift b/rootshell/Features/Multiplexer/MuxSessionTarget.swift new file mode 100644 index 000000000..52b14a29c --- /dev/null +++ b/rootshell/Features/Multiplexer/MuxSessionTarget.swift @@ -0,0 +1,170 @@ +import Foundation + +// MARK: - Multiplexer Type + +nonisolated enum MultiplexerType: String, Codable, Sendable, Equatable, Hashable { + case tmux + case zellij + case herdr + case zmx + + /// Whether the multiplexer, rather than its inner program, owns the screen. + var ownsAlternateScreen: Bool { + switch self { + case .tmux, .zellij, .herdr: return true + case .zmx: return false + } + } + + /// SF Symbol representing this multiplexer. + /// + var iconName: String { + switch self { + case .tmux: return "rectangle.split.2x1" + case .zellij: return "rectangle.split.3x1" + case .herdr: return "square.grid.2x2" + case .zmx: return "rectangle" + } + } +} + +/// The actual attachment to resume, independent of profile and global defaults. +nonisolated struct MuxSessionTarget: Codable, Equatable, Hashable, Sendable { + let type: MultiplexerType + let sessionName: String + let controlMode: Bool + let tmuxSocket: TmuxSocketIdentity? + /// Original profile selector, retained when tmux reports its resolved path. + let tmuxSocketSelector: TmuxSocketIdentity? + + var configuredTmuxSocket: TmuxSocketIdentity? { tmuxSocketSelector ?? tmuxSocket } + + init?(type: MultiplexerType, sessionName: String?, controlMode: Bool = false, + tmuxSocket: TmuxSocketIdentity? = .defaultServer, + tmuxSocketSelector: TmuxSocketIdentity? = nil) { + guard let sessionName, !sessionName.isEmpty else { return nil } + guard type != .tmux || tmuxSocket != nil else { return nil } + self.type = type + self.sessionName = sessionName + self.controlMode = controlMode && (type == .tmux || type == .herdr) + self.tmuxSocket = type == .tmux ? tmuxSocket : nil + self.tmuxSocketSelector = type == .tmux ? tmuxSocketSelector : nil + } + + /// Missing remote identity (including a local gateway) is never a match. + func matchesLiveAttachment(_ attachment: Self?, connectionKey: String?, + requestedConnectionKey: String, isActive: Bool) -> Bool { + guard isActive, connectionKey == requestedConnectionKey, let attachment, + type == attachment.type, sessionName == attachment.sessionName, + controlMode == attachment.controlMode else { return false } + // The control client reports a resolved -S path. Its original -L name + // (or default-server selection) is also a valid way to address it. + return tmuxSocket == attachment.tmuxSocket + || (type == .tmux && tmuxSocket != nil && tmuxSocket == attachment.configuredTmuxSocket) + } + + /// herdr control mode opens its own channel beside the gateway shell. + var execCommand: String? { + let name = LoginShellCommand.doubleQuoted(sessionName) + let command: String + switch type { + case .tmux: + let target = LoginShellCommand.doubleQuoted("=" + sessionName) + command = "exec tmux \(tmuxSocket?.arguments ?? "")\(controlMode ? "-CC " : "")attach-session -t \(target)" + case .herdr: + guard !controlMode else { return nil } + command = "exec herdr session attach \(name)" + case .zellij: + command = "exec zellij attach \(name)" + case .zmx: + command = "ZMX_SESSION_PREFIX= exec zmx attach \(name)" + } + return LoginShellCommand.runInPOSIXShell(LoginShellCommand.pathPrefix + command) + } +} + +nonisolated enum TmuxSocketIdentity: Codable, Equatable, Hashable, Sendable { + case defaultServer + case name(String) + case path(String) + + var arguments: String { + switch self { + case .defaultServer: return "" + case .name(let name): return "-L \(LoginShellCommand.doubleQuoted(name)) " + case .path(let path): return "-S \(LoginShellCommand.doubleQuoted(path)) " + } + } + + /// The in-band identity is host:socket,pid,start_time. Split the numeric + /// suffix from the right so commas and colons in socket paths survive. + static func fromServerIdentity(_ identity: String) -> Self? { + guard let started = identity.lastIndex(of: ","), + let pid = identity[.. Self? { + guard var words = literalWords(command) else { return nil } + if words.first == "exec" { words.removeFirst() } + guard let program = words.first.map({ ($0 as NSString).lastPathComponent }) else { return nil } + if ["sh", "bash", "zsh"].contains(program), words.count == 3, + ["-c", "-lc"].contains(words[1]) { + return fromStartupCommand(words[2]) + } + guard program == "tmux" else { return nil } + var socket: Self = .defaultServer + var explicitPath: Self? + var index = 1 + while index < words.count, words[index].hasPrefix("-") { + let flag = words[index] + if flag == "-L" || flag == "-S" || flag == "-f" { + index += 1 + guard index < words.count, !words[index].isEmpty else { return nil } + if flag == "-L" { socket = .name(words[index]) } + if flag == "-S" { explicitPath = .path(words[index]) } + } else if flag.hasPrefix("-L"), flag.count > 2 { + socket = .name(String(flag.dropFirst(2))) + } else if flag.hasPrefix("-S"), flag.count > 2 { + explicitPath = .path(String(flag.dropFirst(2))) + } + index += 1 + } + return explicitPath ?? socket + } + + private static func literalWords(_ command: String) -> [String]? { + var words: [String] = [] + var word = "" + var quote: Character? + var escaped = false + for character in command { + if escaped { + if quote == "\"", !"$`\"\\\n".contains(character) { word.append("\\") } + if character != "\n" { word.append(character) } + escaped = false + } else if character == "\\", quote != "'" { + escaped = true + } else if character == quote { + quote = nil + } else if quote == nil, character == "'" || character == "\"" { + quote = character + } else if character.isWhitespace, quote == nil { + if !word.isEmpty { words.append(word); word = "" } + } else { + // Never treat an unevaluated expansion or shell operator as a + // literal socket selector; the live control query resolves it. + if quote != "'", "$`".contains(character) { return nil } + if quote == nil, ";|&()<>{}*?[]".contains(character) { return nil } + if quote == nil, word.isEmpty, character == "~" { return nil } + word.append(character) + } + } + guard quote == nil, !escaped else { return nil } + if !word.isEmpty { words.append(word) } + return words + } +} diff --git a/rootshell/Features/SSH/Config/SSHConfig.swift b/rootshell/Features/SSH/Config/SSHConfig.swift index 51e0a5398..503ebdc1b 100644 --- a/rootshell/Features/SSH/Config/SSHConfig.swift +++ b/rootshell/Features/SSH/Config/SSHConfig.swift @@ -177,6 +177,9 @@ struct SSHConfig: Codable, Hashable { /// existing profile decodes. var multiplexerSessionName: String? = nil + /// Captured at detach and saved with tab state, never with a profile. + var muxResumeTarget: MuxSessionTarget? = nil + /// Command to run when the session starts. The mode controls whether this is /// sent as terminal input or used as the initial PTY exec command. var launchCommand: String? = nil @@ -976,7 +979,7 @@ struct SSHConfig: Codable, Hashable { /// Whether the channel replaced the interactive shell with a command. var hasExecTakeoverCommand: Bool { !MuxDetachGate.hasFallbackShell( - hasRemoteCommand: !(remoteCommand?.isEmpty ?? true), + hasRemoteCommand: !(remoteCommand?.isEmpty ?? true) || muxResumeTarget?.execCommand != nil, hasInitialCommandLaunch: initialLaunchCommand != nil, tmuxAutoEnable: tmuxAutoEnable, // Control mode keeps the interactive shell; herdr runs out of band. @@ -1006,6 +1009,7 @@ struct SSHConfig: Codable, Hashable { /// multiplexer auto-start; precedence among multiplexers is tmux, then /// herdr, then zmx. private var baseExecCommand: String? { + if let muxResumeTarget { return muxResumeTarget.execCommand } if let remoteCommand, !remoteCommand.isEmpty { return Self.command(remoteCommand, applying: remoteCommandPolicy) } @@ -1030,6 +1034,7 @@ struct SSHConfig: Codable, Hashable { } private var baseMoshSessionCommand: String { + if let muxResumeTarget { return muxResumeTarget.execCommand ?? "$SHELL -l" } if let remoteCommand, !remoteCommand.isEmpty { let script = remoteCommandPolicy == .prependPATH ? Self.remoteExecPathPrefix + remoteCommand : remoteCommand return LoginShellCommand.runInPOSIXShell(script, login: true) @@ -1055,3 +1060,39 @@ struct SSHConfig: Codable, Hashable { return "$SHELL -l" } } + +extension SSHConfig { + var tmuxSocketForResume: TmuxSocketIdentity? { + if let target = muxResumeTarget { return target.tmuxSocket } + if let remoteCommand, !remoteCommand.isEmpty { + return TmuxSocketIdentity.fromStartupCommand(remoteCommand) + } + if let initialLaunchCommand { + return TmuxSocketIdentity.fromStartupCommand(initialLaunchCommand) + } + if tmuxAutoEnable { + if let custom = Self.tmuxGlobalCustomCommand { + return TmuxSocketIdentity.fromStartupCommand(custom) + } + return .defaultServer + } + return launchCommand.flatMap(TmuxSocketIdentity.fromStartupCommand) + } + + /// Keep connection/authentication settings while replacing startup behavior + /// with the attachment the user actually detached from. + func resumingMultiplexer(_ target: MuxSessionTarget) -> SSHConfig { + var config = self + config.muxResumeTarget = target + config.tmuxAutoEnable = target.type == .tmux + config.tmuxAutoMode = target.controlMode ? .control : .regular + config.herdrAutoEnable = target.type == .herdr + config.herdrAutoMode = target.controlMode ? .control : .regular + config.zmxAutoEnable = target.type == .zmx + config.multiplexerSessionName = target.sessionName + config.remoteCommand = nil + config.launchCommand = nil + config.launchCommandMode = .afterConnect + return config + } +} diff --git a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift index 9084bdf6e..4aa5a4826 100644 --- a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift +++ b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift @@ -397,6 +397,8 @@ enum SessionDiscoveryParser { swipeBindings.tmuxPreviousSession, swipeBindings.zellijNextTab, swipeBindings.zellijPreviousTab, + swipeBindings.tmuxDetachClient, + swipeBindings.zellijDetach, ].compactMap { $0 }.count logger.info("Session discovery complete: \(count) sessions (\(typeNames)), \(bindingCount) resolved bindings") diff --git a/rootshell/Features/SSH/Session/MultiplexerSession.swift b/rootshell/Features/SSH/Session/MultiplexerSession.swift index fb318eb76..42dc3390f 100644 --- a/rootshell/Features/SSH/Session/MultiplexerSession.swift +++ b/rootshell/Features/SSH/Session/MultiplexerSession.swift @@ -7,34 +7,6 @@ import Foundation -// MARK: - Multiplexer Type - -enum MultiplexerType: String, Sendable, Equatable, Hashable { - case tmux - case zellij - case herdr - case zmx - - /// Whether the multiplexer, rather than its inner program, owns the screen. - var ownsAlternateScreen: Bool { - switch self { - case .tmux, .zellij, .herdr: return true - case .zmx: return false - } - } - - /// SF Symbol representing this multiplexer. - /// - var iconName: String { - switch self { - case .tmux: return "rectangle.split.2x1" - case .zellij: return "rectangle.split.3x1" - case .herdr: return "square.grid.2x2" - case .zmx: return "rectangle" - } - } -} - // MARK: - Unified Session Model struct MultiplexerSession: Identifiable, Equatable, Sendable { diff --git a/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift b/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift index 5fd6acf4d..f1cb63a0f 100644 --- a/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift +++ b/rootshell/Features/SSH/Session/MultiplexerSwipeBindings.swift @@ -24,6 +24,8 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { var tmuxPreviousSession: [SequenceStep]? var zellijNextTab: [SequenceStep]? var zellijPreviousTab: [SequenceStep]? + var tmuxDetachClient: [SequenceStep]? + var zellijDetach: [SequenceStep]? init( tmuxNextWindow: [SequenceStep]? = nil, @@ -31,7 +33,9 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { tmuxNextSession: [SequenceStep]? = nil, tmuxPreviousSession: [SequenceStep]? = nil, zellijNextTab: [SequenceStep]? = nil, - zellijPreviousTab: [SequenceStep]? = nil + zellijPreviousTab: [SequenceStep]? = nil, + tmuxDetachClient: [SequenceStep]? = nil, + zellijDetach: [SequenceStep]? = nil ) { self.tmuxNextWindow = tmuxNextWindow self.tmuxPreviousWindow = tmuxPreviousWindow @@ -39,6 +43,8 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { self.tmuxPreviousSession = tmuxPreviousSession self.zellijNextTab = zellijNextTab self.zellijPreviousTab = zellijPreviousTab + self.tmuxDetachClient = tmuxDetachClient + self.zellijDetach = zellijDetach } var hasResolvedBindings: Bool { @@ -48,6 +54,8 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { || tmuxPreviousSession != nil || zellijNextTab != nil || zellijPreviousTab != nil + || tmuxDetachClient != nil + || zellijDetach != nil } func sequence(for preset: SwipeGesturePreset) -> [SequenceStep]? { @@ -76,7 +84,9 @@ struct MultiplexerSwipeBindings: Sendable, Equatable { tmuxNextSession: other.tmuxNextSession ?? tmuxNextSession, tmuxPreviousSession: other.tmuxPreviousSession ?? tmuxPreviousSession, zellijNextTab: other.zellijNextTab ?? zellijNextTab, - zellijPreviousTab: other.zellijPreviousTab ?? zellijPreviousTab + zellijPreviousTab: other.zellijPreviousTab ?? zellijPreviousTab, + tmuxDetachClient: other.tmuxDetachClient ?? tmuxDetachClient, + zellijDetach: other.zellijDetach ?? zellijDetach ) } } @@ -88,6 +98,7 @@ enum TmuxSwipeBindingParser { case previousWindow case nextSession case previousSession + case detachClient } private struct BindingLine { @@ -151,6 +162,9 @@ enum TmuxSwipeBindingParser { default: return false } + case .detachClient: + guard command == "detach-client" || command == "detach" else { return false } + return !tokens.dropFirst().contains("-a") } } } @@ -217,7 +231,8 @@ enum TmuxSwipeBindingParser { tmuxNextWindow: resolve(action: .nextWindow, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), tmuxPreviousWindow: resolve(action: .previousWindow, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), tmuxNextSession: resolve(action: .nextSession, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), - tmuxPreviousSession: resolve(action: .previousSession, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos) + tmuxPreviousSession: resolve(action: .previousSession, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos), + tmuxDetachClient: resolve(action: .detachClient, rootBindings: rootBindings, prefixBindings: prefixBindings, prefixes: prefixCombos) ) } @@ -334,6 +349,7 @@ enum ZellijSwipeBindingParser { private enum Mode: String, CaseIterable { case normal case tab + case session } private struct Semantic { @@ -341,9 +357,12 @@ enum ZellijSwipeBindingParser { var exitsToNormal = false var goesToNextTab = false var goesToPreviousTab = false + var entersSessionMode = false + var detaches = false var isRelevant: Bool { entersTabMode || exitsToNormal || goesToNextTab || goesToPreviousTab + || entersSessionMode || detaches } } @@ -415,6 +434,8 @@ enum ZellijSwipeBindingParser { bind(keys: ["Ctrl t", "Enter", "Esc"], semantic: Semantic(exitsToNormal: true), to: [.tab]) bind(keys: ["h", "Left", "Up", "k"], semantic: Semantic(goesToPreviousTab: true), to: [.tab]) bind(keys: ["l", "Right", "Down", "j"], semantic: Semantic(goesToNextTab: true), to: [.tab]) + bind(keys: ["Ctrl o"], semantic: Semantic(entersSessionMode: true), to: [.normal]) + bind(keys: ["d"], semantic: Semantic(detaches: true), to: [.session]) } mutating func clear(_ targetModes: Set) { @@ -456,10 +477,12 @@ enum ZellijSwipeBindingParser { let nextTab = resolveTabSequence(in: state, next: true) let previousTab = resolveTabSequence(in: state, next: false) + let detach = resolveDetachSequence(in: state) return MultiplexerSwipeBindings( zellijNextTab: nextTab, - zellijPreviousTab: previousTab + zellijPreviousTab: previousTab, + zellijDetach: detach ) } @@ -488,6 +511,21 @@ enum ZellijSwipeBindingParser { return steps } + private static func resolveDetachSequence(in state: State) -> [SequenceStep]? { + let normalBindings = state.modes[.normal] ?? ModeBindings() + let sessionBindings = state.modes[.session] ?? ModeBindings() + + if let direct = normalBindings.firstMatchingCombo(where: { $0.detaches }) { + return [.keyCombo(direct)] + } + + guard let enterSession = normalBindings.firstMatchingCombo(where: { $0.entersSessionMode }), + let action = sessionBindings.firstMatchingCombo(where: { $0.detaches }) else { + return nil + } + return [.keyCombo(enterSession), .keyCombo(action)] + } + private static func preferredExitCombo(in bindings: ModeBindings) -> SequenceStep.KeyCombo? { let allExits = bindings.matchingCombos(where: { $0.exitsToNormal }) if let escape = allExits.first(where: isPlainEscape) { @@ -564,6 +602,8 @@ enum ZellijSwipeBindingParser { return [.normal] case Mode.tab.rawValue: return [.tab] + case Mode.session.rawValue: + return [.session] case "shared": return Set(Mode.allCases) case "shared_except": @@ -587,7 +627,9 @@ enum ZellijSwipeBindingParser { entersTabMode: compact.contains("switchtomode\"tab\""), exitsToNormal: compact.contains("switchtomode\"normal\""), goesToNextTab: compact.contains("gotonexttab"), - goesToPreviousTab: compact.contains("gotoprevioustab") + goesToPreviousTab: compact.contains("gotoprevioustab"), + entersSessionMode: compact.contains("switchtomode\"session\""), + detaches: compact.contains("detach") ) } diff --git a/rootshell/Features/Tmux/TmuxController.swift b/rootshell/Features/Tmux/TmuxController.swift index 1d4319f7b..d6f78cb2b 100644 --- a/rootshell/Features/Tmux/TmuxController.swift +++ b/rootshell/Features/Tmux/TmuxController.swift @@ -275,6 +275,11 @@ final class TmuxController { private(set) var gatewaySourceSystemImage = "terminal" /// Identifies this tmux server lifetime; combined with pane IDs for push routing. var pushRouteServerIdentity: String? + var configuredResumeSocket: TmuxSocketIdentity? + var startupResumeSocket: TmuxSocketIdentity? + var resumeSocket: TmuxSocketIdentity? { + pushRouteServerIdentity.flatMap(TmuxSocketIdentity.fromServerIdentity) ?? startupResumeSocket + } var pushRouteServerIdentityTask: Task? /// Tag 0 is never used. var nextReplyTag: UInt32 = 1 @@ -1814,6 +1819,20 @@ final class TmuxController { } let uuidPrefix = ownerTerminalUUID.uuidString.prefix(8) TmuxDebugLogger.shared.event("DETACH", "requested \(source) gw=\(uuidPrefix)") + + // Context-menu / dashboard / ESC / tab-close all enter here without + // going through MuxSessionDetach.detach — post the reconnect banner + // from this choke point so tmux -CC matches zmx. + let bannerTerminal = ownGatewayView() + ?? windowTabs.values.lazy.compactMap { $0.splitTree.terminalLeaves.first }.first + MuxSessionDetach.notifyControlModeDetached( + sessionName: currentSessionName, + windowId: baseWindowId, + terminal: bannerTerminal, + tmuxSocket: resumeSocket, + tmuxSocketSelector: configuredResumeSocket + ) + // Recheck after the async hops; the surface can be freed in between. // ROOTSHELL-TMUX (id=tmux-gateway-surface-freed) Ghostty.TerminalView.ghosttyAPIQueue.async { [weak self] in @@ -2816,6 +2835,8 @@ extension Ghostty.TerminalView { // Flush session info that arrived before the controller existed. // ROOTSHELL-TMUX (id=tmux-session-info-stash) if let ssh = connectionConfig.underlyingSSHConfig { + controller.startupResumeSocket = ssh.tmuxSocketForResume + controller.configuredResumeSocket = ssh.muxResumeTarget?.configuredTmuxSocket ?? ssh.tmuxSocketForResume controller.connectionKey = TmuxGatewaySessionStore.connectionKey( host: ssh.host, port: ssh.port, username: ssh.username) } diff --git a/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift b/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift index dc9051761..e529646ac 100644 --- a/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift +++ b/rootshell/Features/Tmux/Views/TmuxSessionDashboardView.swift @@ -280,17 +280,17 @@ struct TmuxSessionDashboardView: View { private var detachGatewayDialog: some View { Color.clear.confirmationDialog( - "Detach Gateway?", + "Detach Session?", isPresented: $showingDetachConfirmation, titleVisibility: .visible ) { - Button("Detach Gateway", role: .destructive) { + Button("Detach Session", role: .destructive) { detachGateway() } .keyboardShortcut(.defaultAction) Button("Cancel", role: .cancel) {} } message: { - Text("Leaves tmux control mode for this tab. The tmux session keeps running on the server.") + Text("Leaves tmux control mode for this connection. Window tabs close; sessions keep running on the server.") } } @@ -440,7 +440,7 @@ struct TmuxSessionDashboardView: View { } .buttonStyle(.borderless) .disabled(controller.didEnd) - .accessibilityLabel("Detach Gateway") + .accessibilityLabel("Detach Session") // Evict every OTHER client (e.g. a small-screen device left // attached, clamping the shared window). Shown only while other diff --git a/rootshell/Features/Tmux/Views/TmuxTabMenu.swift b/rootshell/Features/Tmux/Views/TmuxTabMenu.swift index 3ba198f7a..9ec171dfc 100644 --- a/rootshell/Features/Tmux/Views/TmuxTabMenu.swift +++ b/rootshell/Features/Tmux/Views/TmuxTabMenu.swift @@ -187,20 +187,43 @@ struct TmuxTabMenuItems: View { } } -/// Destructive "Detach" item for a tmux gateway tab. Separate from -/// `TmuxTabMenuItems` so each surface can place it in its destructive -/// section (next to Close), keeping menu ordering idiomatic per surface. +/// Destructive "Detach" item for a tmux control-mode attachment. Shown on both +/// gateway and window tabs so Detach stays reachable when the gateway is +/// auto-hidden — the action always leaves the whole control client (all +/// windows), matching iTerm2-style detach. Separate from `TmuxTabMenuItems` +/// so each surface can place it in its destructive section (next to Close). struct TmuxGatewayDetachMenuItem: View { let tab: TabModel let controller: TmuxController? let dialogs: TmuxTabDialogCoordinator var body: some View { - if tab.isTmuxGateway, let controller, controller.isActive { + if (tab.isTmuxGateway || tab.isTmuxWindow), + let controller, controller.isActive { Button(role: .destructive) { dialogs.requestDetach(tab) } label: { - Label("Detach", systemImage: "eject") + Label("Detach Session", systemImage: "eject") + } + } + } +} + +/// Destructive Detach for a raw / passthrough multiplexer tab (zellij, herdr, +/// zmx, plain tmux). Hidden when the tab is already covered by the tmux -CC +/// detach item above. +struct MultiplexerDetachMenuItem: View { + let tab: TabModel + let onDetach: (TabModel) -> Void + + var body: some View { + if !tab.isTmuxGateway, !tab.isTmuxWindow, + HerdrController.controller(forAnyTab: tab)?.isActive != true, + MuxSessionDetach.attachment(for: tab, tmuxController: { _ in nil }) != nil { + Button(role: .destructive) { + onDetach(tab) + } label: { + Label("Detach Session", systemImage: "eject") } } } @@ -290,21 +313,22 @@ private struct TmuxTabDialogsModifier: ViewModifier { private var detachGatewayDialog: some View { Color.clear.confirmationDialog( - "Detach Gateway?", + "Detach Session?", isPresented: Binding( get: { dialogs.detachConfirmGatewayTab != nil }, set: { if !$0 { dialogs.detachConfirmGatewayTab = nil } } ), titleVisibility: .visible ) { - Button("Detach Gateway", role: .destructive) { - guard let tab = dialogs.detachConfirmGatewayTab, - let controller = controller(tab) else { return } - controller.detachGatewayClient() + Button("Detach Session", role: .destructive) { + guard let tab = dialogs.detachConfirmGatewayTab else { return } + // Same funnel as Tabs → Detach Session / zmx: graceful detach + // posts the reconnect banner from TmuxController. + _ = MuxSessionDetach.detach(tab: tab, tmuxController: controller) } Button("Cancel", role: .cancel) {} } message: { - Text("Leaves tmux control mode for this tab. The tmux session keeps running on the server.") + Text("Leaves tmux control mode for this connection. All window tabs for this attachment close; the tmux sessions keep running on the server.") } } } diff --git a/rootshell/UI/Shell/MainView+ConnectionSheet.swift b/rootshell/UI/Shell/MainView+ConnectionSheet.swift index c44b9ffd8..a4fccbbc4 100644 --- a/rootshell/UI/Shell/MainView+ConnectionSheet.swift +++ b/rootshell/UI/Shell/MainView+ConnectionSheet.swift @@ -299,6 +299,13 @@ extension MainView { var config = profile.sshConfig let connectionProtocol = profile.connectionProtocol + // Resume-or-focus: opening a mux auto-start profile while already + // attached to that host/session focuses the live UI instead of a + // second client (especially important for zmx — one PTY per name). + if focusLiveMuxAttachmentIfPresent(for: config) { + return + } + let transportMode = profile.trzszTransportMode let profileMTU = profile.trzszMTU let profilePortMin = profile.trzszPortMin @@ -446,6 +453,12 @@ extension MainView { #endif func connectWithConfig(_ config: SSHConfig, connectionProtocol: ConnectionProtocol = .ssh, splitOption: SSHConnectionView.SplitOption, trzszTransportMode: ProfileTransportMode = .default, trzszMTU: Int? = nil, trzszPortMin: Int? = nil, trzszPortMax: Int? = nil, trzszServerPath: String? = nil, sourceProfileID: UUID? = nil) { + // History / duplicate / deep-link paths also land here — resume before + // spawning another client to the same mux session. + if focusLiveMuxAttachmentIfPresent(for: config) { + return + } + // Safety net: verify key is still resolvable before creating session if case .key(let keyID) = config.authMethod, SSHKeyManager.shared.findKey(id: keyID) == nil { let resolution = ConnectionKeyResolver.resolve(config: config) @@ -569,7 +582,9 @@ extension MainView { } // Update profile auth method to reflect saved password preference - var updatedProfile = profile + // Reconnect can carry a temporary startup override. Save only the auth + // change to the stored profile, not that one-shot attachment target. + var updatedProfile = ConnectionProfileManager.shared.profile(for: profile.id) ?? profile updatedProfile.sshConfig.authMethod = savedSuccessfully ? .savedPassword : .password("") try? ConnectionProfileManager.shared.updateProfile(updatedProfile) diff --git a/rootshell/UI/Shell/MainView+Notifications.swift b/rootshell/UI/Shell/MainView+Notifications.swift index 7890a2666..b4b67015f 100644 --- a/rootshell/UI/Shell/MainView+Notifications.swift +++ b/rootshell/UI/Shell/MainView+Notifications.swift @@ -191,10 +191,11 @@ extension MainView { // events close the dying tab, not whichever tab the user has since // switched to. nil object → fall back to the focused split. let target = notification.object as? SplitPaneView + let leaveMux = notification.userInfo?[MuxSessionDetach.leaveMuxSessionUserInfoKey] as? Bool ?? false if notification.userInfo?[GhosttyCommandRouting.userInitiatedCloseSplitKey] as? Bool == true { self.requestUserCloseSplit(targeting: target) } else { - self.closeSplit(targeting: target) + self.closeSplit(targeting: target, leaveMuxSession: leaveMux) } } @@ -360,11 +361,40 @@ extension MainView { self.discoverSessionsForSelectedTab(origin: notification.object as? Ghostty.TerminalView) } + observerBag.observeOnMainActor(.detachSession) { [self] notification in + guard self.shouldHandleNotification(notification) else { return } + self.detachSessionForSelectedTab() + } + observerBag.observeOnMainActor(.detachOtherClients) { [self] notification in guard self.shouldHandleNotification(notification) else { return } self.detachOtherClientsForSelectedTab() } + observerBag.observeOnMainActor(.muxSessionDidDetach) { [self] notification in + // Prefer windowId: tmux -CC prune can remove the notifying pane + // from the tab tree before (or as) this handler runs. + if let targetWindow = notification.userInfo?["windowId"] as? String { + guard targetWindow == self.windowId else { return } + } else if !self.shouldHandleNotification(notification) { + // No windowId and no pane object: only the focused window + // accepts the banner (Mac Catalyst often has >1 scene). + guard self.isWindowFocused || self.windowIsKeyWindow else { return } + } + let offer = notification.userInfo?["offer"] as? MuxSessionResume.ReconnectOffer + let name = offer?.displayName + ?? (notification.userInfo?["displayName"] as? String) + ?? String(localized: "session", comment: "Generic mux session label in detach banner") + self.muxDetachBanner = MuxDetachBannerState( + message: String( + localized: "Detached from \(name). Session keeps running.", + comment: "Post-detach banner message" + ), + offer: offer + ) + self.scheduleMuxDetachBannerDismiss() + } + observerBag.observeOnMainActor(.increaseFontSize) { [self] notification in guard self.shouldHandleNotification(notification) else { return } guard terminals.indices.contains(selectedTabIndex), diff --git a/rootshell/UI/Shell/MainView+Splits.swift b/rootshell/UI/Shell/MainView+Splits.swift index 53c21088e..e78aca834 100644 --- a/rootshell/UI/Shell/MainView+Splits.swift +++ b/rootshell/UI/Shell/MainView+Splits.swift @@ -302,8 +302,8 @@ extension MainView { terminals[selectedTabIndex].splitTree = terminals[selectedTabIndex].splitTree.toggleZoom(for: currentNode) } - func closeSplit(targeting targetPane: SplitPaneView? = nil) { - Ghostty.logger.info("closeSplit() called (target=\(targetPane?.uuid.uuidString.prefix(8).description ?? "nil"))") + func closeSplit(targeting targetPane: SplitPaneView? = nil, leaveMuxSession: Bool = false) { + Ghostty.logger.info("closeSplit() called (target=\(targetPane?.uuid.uuidString.prefix(8).description ?? "nil"), leaveMux=\(leaveMuxSession))") // Resolve which tab + pane to close. // When a specific pane is provided (e.g. from `.closeSplit` posted by an @@ -430,9 +430,12 @@ extension MainView { // keep their user-close semantics (plus withdrawing any pending // keyboard-interactive prompt so its auth future doesn't park until // the login timeout); other panes take the generic close funnel. + // Mux detach posts leaveMuxSession so cleanup uses `.muxDetach`. + // Close Tab also leaves a zmx session running (closing the client + // is zmx’s detach path). if let terminalToClose = paneToClose.asTerminal { withdrawKeyboardInteractive(for: terminalToClose) - terminalToClose.cleanup(reason: .userClose) + terminalToClose.cleanup(reason: leaveMuxSession ? .muxDetach : .userClose) } else { paneToClose.prepareForClose() } diff --git a/rootshell/UI/Shell/MainView+TabManagement.swift b/rootshell/UI/Shell/MainView+TabManagement.swift index d256662ba..054f84146 100644 --- a/rootshell/UI/Shell/MainView+TabManagement.swift +++ b/rootshell/UI/Shell/MainView+TabManagement.swift @@ -324,10 +324,12 @@ extension MainView { // MARK: - Per-Protocol Creators func createSSHTab(with config: SSHConfig, sourceProfileID: UUID? = nil) { + if focusLiveMuxAttachmentIfPresent(for: config) { return } openTerminalTab(config: .ssh(config), title: config.displayName, sourceProfileID: sourceProfileID) } func createMoshTab(with config: MoshConfig, sourceProfileID: UUID? = nil) { + if focusLiveMuxAttachmentIfPresent(for: config.sshConfig) { return } openTerminalTab(config: .mosh(config), title: config.sshConfig.displayName, sourceProfileID: sourceProfileID) } @@ -342,6 +344,7 @@ extension MainView { } func createTrzszTab(with config: TrzszConfig, sourceProfileID: UUID? = nil) { + if focusLiveMuxAttachmentIfPresent(for: config.sshConfig) { return } openTerminalTab(config: .trzsz(config), title: config.sshConfig.displayName, sourceProfileID: sourceProfileID) } diff --git a/rootshell/UI/Shell/MainView+TabSidebar.swift b/rootshell/UI/Shell/MainView+TabSidebar.swift index 1a2ca868e..60c09234a 100644 --- a/rootshell/UI/Shell/MainView+TabSidebar.swift +++ b/rootshell/UI/Shell/MainView+TabSidebar.swift @@ -191,6 +191,71 @@ extension MainView { target.discoverSessionsIfConfigured(manual: true) } + /// Leave the multiplexer on the selected tab. Works for tmux -CC (including + /// window tabs when the gateway is auto-hidden), raw tmux / zellij / herdr, + /// and zmx. Sessions keep running for later reattach. + func detachSessionForSelectedTab() { + guard terminals.indices.contains(selectedTabIndex) else { return } + let tab = terminals[selectedTabIndex] + _ = MuxSessionDetach.detach(tab: tab, tmuxController: tmuxControllerForTab) + } + + func scheduleMuxDetachBannerDismiss() { + muxDetachBannerDismissTask?.cancel() + muxDetachBannerDismissTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(8)) + guard !Task.isCancelled else { return } + dismissMuxDetachBanner() + } + } + + func dismissMuxDetachBanner() { + muxDetachBannerDismissTask?.cancel() + muxDetachBannerDismissTask = nil + muxDetachBanner = nil + } + + /// If a live mux auto-start attachment already matches `config`, focus it + /// and show a short banner. Returns true when a new connection was skipped. + @discardableResult + func focusLiveMuxAttachmentIfPresent(for config: SSHConfig) -> Bool { + guard let match = MuxSessionResume.findLiveAttachment(for: config) else { + return false + } + _ = MuxSessionResume.focus(match, in: windowId) { id in + selectTab(id: id) + } + muxDetachBanner = MuxDetachBannerState( + message: String( + localized: "Already attached to \(match.displayName)", + comment: "Banner when opening a mux profile that is already live" + ), + offer: nil + ) + scheduleMuxDetachBannerDismiss() + return true + } + + func reconnectFromMuxDetachBanner() { + guard let offer = muxDetachBanner?.offer else { return } + dismissMuxDetachBanner() + let config = offer.sshConfig.resumingMultiplexer(offer.target) + if let profileID = offer.profileID, + var profile = ConnectionProfileManager.shared.profiles.first(where: { $0.id == profileID }) { + // A temporary copy keeps the profile's authentication prompts and + // transport options without reopening its original mux/default. + profile.sshConfig = config + profile.connectionProtocol = offer.connectionProtocol + connectToProfile(profile, splitOption: .newTab) + return + } + connectWithConfig( + config, + connectionProtocol: offer.connectionProtocol, + splitOption: .newTab + ) + } + /// Evict every OTHER tmux client (`detach-client -a`) for the selected /// tab's gateway, keeping this client attached. Works from ANY tmux CC tab: /// `tmuxControllerForTab` resolves a window tab through its pane binding to diff --git a/rootshell/UI/Shell/MainView+TerminalContent.swift b/rootshell/UI/Shell/MainView+TerminalContent.swift index ff444ed6b..35ab324f8 100644 --- a/rootshell/UI/Shell/MainView+TerminalContent.swift +++ b/rootshell/UI/Shell/MainView+TerminalContent.swift @@ -314,6 +314,9 @@ extension MainView { // tmux -CC window placeholder restored from disk, awaiting reconcile tmuxReconnectingOverlay + // Detach banner lives on MainView’s content area (not here) so it + // still shows after tmux -CC prune empties every tab. + if showQuickSettingsOverlay { QuickSettingsHUD(isPresented: $showQuickSettingsOverlay) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -406,6 +409,54 @@ extension MainView { .bannerBackground() } + /// Post-detach / already-attached / missing-mux banner. Hosted above both + /// the terminal stack and the empty state — tmux -CC detach prunes every + /// tab immediately, so a terminal-only overlay never paints. + /// + /// Sized to the card only (no full-bleed VStack). A max-size container in + /// `.overlay` steals Catalyst hits from the dismiss control even when the + /// spacer disables hit testing. + @ViewBuilder + var muxDetachBannerOverlay: some View { + if let banner = muxDetachBanner { + HStack(spacing: 10) { + Image(systemName: banner.offer == nil ? "exclamationmark.triangle.fill" : "eject.circle.fill") + .foregroundStyle(.secondary) + Text(banner.message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + Spacer(minLength: 8) + if banner.offer != nil { + Button("Reconnect") { + reconnectFromMuxDetachBanner() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + Button { + dismissMuxDetachBanner() + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 10) + .frame(maxWidth: 480) + .bannerBackground() + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.2), value: muxDetachBanner) + } + } + private var tmuxReconnectStatusRow: some View { HStack(spacing: 8) { ProgressView() diff --git a/rootshell/UI/Shell/MainView.swift b/rootshell/UI/Shell/MainView.swift index 26e7f784c..04cb191aa 100644 --- a/rootshell/UI/Shell/MainView.swift +++ b/rootshell/UI/Shell/MainView.swift @@ -142,6 +142,9 @@ struct MainView: View { /// original target if focus or tab selection changes while the dialog is up. @State var pendingClosePaneID: UUID? @State var pendingNewTabRequest: NewTabRequest? + /// Transient post-detach / already-attached banner. + @State var muxDetachBanner: MuxDetachBannerState? + @State var muxDetachBannerDismissTask: Task? @State var unavailableNewTabRequest: NewTabRequest? @State var authenticationRetryRequest: SSHAuthenticationRetryRequest? @State var reconnectConfig: SSHConfig? @@ -529,23 +532,31 @@ struct MainView: View { } } - // Terminal view - if ghosttyApp.readiness == .ready, !terminals.isEmpty { - terminalAndSidebarContent(geometry: geometry) - } else if ghosttyApp.readiness == .ready, terminals.isEmpty, !windowClosingAfterTabTransfer { - // Empty state - shown when all tabs are closed - EmptyStateResponder( - onNewTab: addNewTab, - onNewLocalShell: handleNewTabCommand - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if ghosttyApp.readiness == .ready, terminals.isEmpty { - Color.clear + // Terminal view (detach banner overlays empty state too — + // tmux -CC prune removes every tab in one go). + Group { + if ghosttyApp.readiness == .ready, !terminals.isEmpty { + terminalAndSidebarContent(geometry: geometry) + } else if ghosttyApp.readiness == .ready, terminals.isEmpty, !windowClosingAfterTabTransfer { + // Empty state - shown when all tabs are closed + EmptyStateResponder( + onNewTab: addNewTab, + onNewLocalShell: handleNewTabCommand + ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if ghosttyApp.readiness == .loading { - loadingView - } else if ghosttyApp.readiness == .error { - errorView + } else if ghosttyApp.readiness == .ready, terminals.isEmpty { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if ghosttyApp.readiness == .loading { + loadingView + } else if ghosttyApp.readiness == .error { + errorView + } + } + .overlay(alignment: .top) { + if ghosttyApp.readiness == .ready { + muxDetachBannerOverlay + } } } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) diff --git a/rootshell/UI/Sidebar/VerticalTabSidebar.swift b/rootshell/UI/Sidebar/VerticalTabSidebar.swift index ef9646ed2..7495f8941 100644 --- a/rootshell/UI/Sidebar/VerticalTabSidebar.swift +++ b/rootshell/UI/Sidebar/VerticalTabSidebar.swift @@ -1695,6 +1695,9 @@ struct VerticalTabSidebar: View { groupOverrideMenuItem(for: tab) Divider() HerdrGatewayDetachMenuItem(tab: tab, dialogs: herdrDialogs) + MultiplexerDetachMenuItem(tab: tab) { tab in + _ = MuxSessionDetach.detach(tab: tab, tmuxController: tmuxController) + } Button(role: .destructive) { onCloseTab(tab.id) } label: { @@ -1717,6 +1720,11 @@ struct VerticalTabSidebar: View { moveToWindowItems(for: tab) groupOverrideMenuItem(for: tab) Divider() + TmuxGatewayDetachMenuItem( + tab: tab, + controller: tmuxController(tab), + dialogs: tmuxDialogs + ) Button(role: .destructive) { onCloseTab(tab.id) } label: { diff --git a/rootshell/UI/Tabs/TabBar.swift b/rootshell/UI/Tabs/TabBar.swift index 2583809aa..4ec8ec220 100644 --- a/rootshell/UI/Tabs/TabBar.swift +++ b/rootshell/UI/Tabs/TabBar.swift @@ -584,6 +584,9 @@ struct TabBar: View { dialogs: tmuxDialogs ) HerdrGatewayDetachMenuItem(tab: tab, dialogs: herdrDialogs) + MultiplexerDetachMenuItem(tab: tab) { tab in + _ = MuxSessionDetach.detach(tab: tab, tmuxController: tmuxController) + } Button(role: .destructive) { onCloseTab(index) } label: { diff --git a/rootshell/UI/Terminal/TerminalSplitTreeView.swift b/rootshell/UI/Terminal/TerminalSplitTreeView.swift index 50d052c17..0807e531b 100644 --- a/rootshell/UI/Terminal/TerminalSplitTreeView.swift +++ b/rootshell/UI/Terminal/TerminalSplitTreeView.swift @@ -1711,7 +1711,9 @@ extension Notification.Name { static let toggleFullScreen = Notification.Name("com.rootshell.toggleFullScreen") static let showTmuxSessions = Notification.Name("com.rootshell.showTmuxSessions") static let discoverSessions = Notification.Name("com.rootshell.discoverSessions") + static let detachSession = Notification.Name("com.rootshell.detachSession") static let detachOtherClients = Notification.Name("com.rootshell.detachOtherClients") + static let muxSessionDidDetach = Notification.Name("com.rootshell.muxSessionDidDetach") static let showToolbarSettings = Notification.Name("com.rootshell.showToolbarSettings") static let forceASCIIKeyboardChanged = Notification.Name("com.rootshell.forceASCIIKeyboardChanged") static let ghosttySessionDiscoveryChanged = Notification.Name("com.rootshell.sessionDiscoveryChanged") diff --git a/rootshell/UI/Terminal/TerminalView+Keyboard.swift b/rootshell/UI/Terminal/TerminalView+Keyboard.swift index 8c1eba0ca..95ffac1b7 100644 --- a/rootshell/UI/Terminal/TerminalView+Keyboard.swift +++ b/rootshell/UI/Terminal/TerminalView+Keyboard.swift @@ -2458,6 +2458,10 @@ extension Ghostty.TerminalView { NotificationCenter.default.post(name: .discoverSessions, object: self) } + @objc func menuDetachSession(_ sender: Any?) { + NotificationCenter.default.post(name: .detachSession, object: self) + } + @objc func menuDetachOtherClients(_ sender: Any?) { noteModTapCommand(sender as? UIKeyCommand) NotificationCenter.default.post(name: .detachOtherClients, object: self) diff --git a/rootshell/UI/Terminal/TerminalView+Session.swift b/rootshell/UI/Terminal/TerminalView+Session.swift index 5a028174c..84646728e 100644 --- a/rootshell/UI/Terminal/TerminalView+Session.swift +++ b/rootshell/UI/Terminal/TerminalView+Session.swift @@ -535,6 +535,18 @@ extension Ghostty.TerminalView { func applyConfiguredMultiplexerBinding() { guard let sshConfig = connectionConfig.sshConfigForHistory else { return } + if let target = sshConfig.muxResumeTarget { + if !target.controlMode { + if target.type.ownsAlternateScreen { + bindRawMultiplexer(target.type, sessionName: target.sessionName, tmuxSocket: target.tmuxSocket, + tmuxSocketSelector: target.configuredTmuxSocket) + } else { + bindPassthroughMultiplexer(target.type, sessionName: target.sessionName, canDetachSwitch: false) + } + } + return + } + // Keep zmx's transparent identity separate from raw multiplexer // bindings, since raw bindings also suppress agent attention. if sshConfig.zmxAutoEnable, let name = sshConfig.zmxSessionNameForConnection { @@ -546,7 +558,8 @@ extension Ghostty.TerminalView { // Auto-connect. Control mode gets its own surface per pane, so only // the plain mode collapses a whole session onto this one. if sshConfig.tmuxAutoEnable, sshConfig.tmuxAutoMode == .regular { - bindRawMultiplexer(.tmux, sessionName: sshConfig.tmuxSessionNameForConnection) + bindRawMultiplexer(.tmux, sessionName: sshConfig.tmuxSessionNameForConnection, + tmuxSocket: sshConfig.tmuxSocketForResume) return } @@ -560,7 +573,7 @@ extension Ghostty.TerminalView { .compactMap { $0 } for command in configured { if let type = Self.rawMultiplexerType(launching: command) { - bindRawMultiplexer(type, sessionName: nil) + bindRawMultiplexer(type, sessionName: nil, tmuxSocket: TmuxSocketIdentity.fromStartupCommand(command)) return } } @@ -571,10 +584,12 @@ extension Ghostty.TerminalView { /// /// Binds only multiplexers that own the alternate screen; raw bindings /// suppress agent attention until ownership is released. - func bindRawMultiplexer(_ type: MultiplexerType, sessionName: String?) { + func bindRawMultiplexer(_ type: MultiplexerType, sessionName: String?, tmuxSocket: TmuxSocketIdentity? = .defaultServer, + tmuxSocketSelector: TmuxSocketIdentity? = nil) { guard type.ownsAlternateScreen else { return } guard rawMultiplexer == nil else { return } - rawMultiplexer = .init(type: type, sessionName: sessionName) + rawMultiplexer = .init(type: type, sessionName: sessionName, tmuxSocket: tmuxSocket, + tmuxSocketSelector: tmuxSocketSelector) AgentAttentionCenter.shared.topologyDidChange() } @@ -826,7 +841,8 @@ extension Ghostty.TerminalView { // // A manual run overrides all of it: the user asked for the picker, so an // already-started multiplexer or launch command must not swallow it. - let multiplexerAutoStart = sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable || sshConfig.zmxAutoEnable + let multiplexerAutoStart = sshConfig.muxResumeTarget != nil + || sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable || sshConfig.zmxAutoEnable let allowSessionPickerOverlay = manual || (!hasLaunchCommand && !hasRemoteCommand && !wasResumed && !multiplexerAutoStart) let skipTmuxSessions = !tmuxEnabled || !allowSessionPickerOverlay diff --git a/rootshell/UI/Terminal/TerminalView.swift b/rootshell/UI/Terminal/TerminalView.swift index 3653c3264..0a2736d4f 100644 --- a/rootshell/UI/Terminal/TerminalView.swift +++ b/rootshell/UI/Terminal/TerminalView.swift @@ -480,6 +480,8 @@ extension Ghostty { /// Known when the app issued the attach; nil when the binding came /// from a corroborating screen signal. let sessionName: String? + var tmuxSocket: TmuxSocketIdentity? = .defaultServer + var tmuxSocketSelector: TmuxSocketIdentity? /// True once the multiplexer has actually taken the alternate /// screen. The binding is recorded at session-ready, before the /// remote command has run, so the surface is legitimately on the @@ -497,6 +499,7 @@ extension Ghostty { /// A transparent multiplexer identity that does not suppress agent /// attention or depend on alternate-screen ownership. var passthroughMultiplexer: RawMultiplexerBinding? + nonisolated(unsafe) var tmuxDetachInProgressAtomic: Bool = false var isTmuxDetachInProgress: Bool { @@ -1581,13 +1584,19 @@ extension Ghostty { /// Why the terminal is being torn down. Drives the resumable-session /// branch in `cleanup` — the wrong choice here either kills a server - /// session the user wants preserved (.userClose during a scene - /// teardown) or strands a server session the user wanted closed - /// (.sceneTeardown for an explicit tab close). + /// session the user wants preserved (`.userClose` / `.muxDetach` during + /// a scene teardown) or strands a server session the user wanted + /// closed (`.sceneTeardown` for an explicit tab close). Detach and + /// Close Tab both leave a zmx session running (closing the client is + /// zmx’s supported detach path). enum CleanupReason { /// User tapped Close Tab / closed the split. Send "close" to - /// tsshd/mosh-server and delete local credentials. + /// tsshd/mosh-server and delete local credentials. A zmx session + /// stays alive on the host. case userClose + /// Detaching from a multiplexer: tear down the local client like + /// `.userClose` and leave a zmx session running. + case muxDetach /// Scene/window is being torn down (rotation, app exit). Keep /// server-side session alive so resume can pick it back up. case sceneTeardown @@ -1638,10 +1647,17 @@ extension Ghostty { TrzszTransferInbox.shared.cancel(ticketID) } - // 1-2. Stop the session and close the PTY. The per-session-type + completeCleanupAfterSessionStop(reason: reason) + } + + /// Remainder of ``cleanup(reason:)`` after session-stop work that + /// used to run asynchronously (zmx kill). Close Tab now leaves zmx + /// running, same as Detach. + private func completeCleanupAfterSessionStop(reason: CleanupReason) { + // 2. Stop the session and close the PTY. The per-session-type // teardown semantics (resumable Trzsz/Mosh keep the server session - // alive for .sceneTeardown; .userClose terminates) live on the - // owning controller now. See TerminalSessionController.teardown. + // alive for .sceneTeardown; .userClose / .muxDetach terminate) + // live on the owning controller now. See TerminalSessionController.teardown. #if !targetEnvironment(macCatalyst) hideSelectionMagnifier(animated: false) loupeSecondaryClick = nil @@ -4646,6 +4662,7 @@ extension Ghostty { #if os(iOS) && !targetEnvironment(macCatalyst) if focused && !iPadVisorController.permitsFocus(self) { return false } #endif + invalidateWritingAssistance(resetDocument: true) // Update mouse capture state when focus changes to ensure scroll handling // has accurate state for this terminal (fixes split view mouse capture scrolling) diff --git a/rootshellTests/MuxSessionTargetTests.swift b/rootshellTests/MuxSessionTargetTests.swift new file mode 100644 index 000000000..e358804ec --- /dev/null +++ b/rootshellTests/MuxSessionTargetTests.swift @@ -0,0 +1,180 @@ +import XCTest + +final class MuxSessionTargetTests: XCTestCase { + func testCustomTmuxServersSurviveReconnect() throws { + for (command, socket) in [ + ("tmux -L work -CC new-session -A -s main", TmuxSocketIdentity.name("work")), + ("exec /usr/bin/tmux -S '/tmp/work socket' attach -t main", .path("/tmp/work socket")), + ("sh -c 'exec tmux -Lwork -CC attach -t main'", .name("work")), + ("tmux -S/tmp/socket -Lignored attach -t main", .path("/tmp/socket")), + ("tmux -CC new-session -A -s main", .defaultServer) + ] { + XCTAssertEqual(TmuxSocketIdentity.fromStartupCommand(command), socket) + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", controlMode: true, + tmuxSocket: socket)) + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec tmux \(socket.arguments)-CC attach-session -t \"=main\"")) + } + XCTAssertNil(TmuxSocketIdentity.fromStartupCommand("tmux -S \"$SOCKET\" attach")) + XCTAssertNil(TmuxSocketIdentity.fromStartupCommand("tmux -S ~/socket attach")) + XCTAssertNil(TmuxSocketIdentity.fromStartupCommand("tmux -S /tmp/socket-* attach")) + XCTAssertNil(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: nil)) + } + + func testControlServerIdentityCapturesResolvedNamedSocketAndQuotesIt() throws { + let path = "/tmp/tmux-501/work,team:one ' $HOME `id`" + let socket = try XCTUnwrap(TmuxSocketIdentity.fromServerIdentity("host:\(path),123,456")) + XCTAssertEqual(socket, .path(path)) + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", controlMode: true, tmuxSocket: socket)) + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell(LoginShellCommand.pathPrefix + + "exec tmux -S \"/tmp/tmux-501/work,team:one ' \\$HOME \\`id\\`\" -CC attach-session -t \"=main\"")) + XCTAssertNil(TmuxSocketIdentity.fromServerIdentity("incomplete")) + } + + func testSameNamedSessionsOnDifferentTmuxServersDoNotMatch() throws { + let work = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .name("work"))) + let other = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .name("other"))) + XCTAssertFalse(work.matchesLiveAttachment(other, connectionKey: "remote", requestedConnectionKey: "remote", isActive: true)) + let resolved = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .path("/tmp/tmux-501/work"), + tmuxSocketSelector: .name("work"))) + XCTAssertTrue(work.matchesLiveAttachment(resolved, connectionKey: "remote", requestedConnectionKey: "remote", + isActive: true)) + XCTAssertFalse(other.matchesLiveAttachment(resolved, connectionKey: "remote", requestedConnectionKey: "remote", + isActive: true)) + } + + func testOriginalProfileMatchesAfterRepeatedReconnectAndRestoration() throws { + for selector in [TmuxSocketIdentity.defaultServer, .name("work")] { + for controlMode in [false, true] { + let profile = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", + controlMode: controlMode, tmuxSocket: selector)) + let path = TmuxSocketIdentity.path("/tmp/tmux-501/resolved") + var attachment = profile + for _ in 0..<2 { + let detached = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: attachment.sessionName, + controlMode: attachment.controlMode, tmuxSocket: path, + tmuxSocketSelector: attachment.configuredTmuxSocket)) + attachment = try JSONDecoder().decode(MuxSessionTarget.self, from: JSONEncoder().encode(detached)) + XCTAssertEqual(attachment.configuredTmuxSocket, selector) + XCTAssertEqual(attachment.tmuxSocket, path) + XCTAssertEqual(attachment.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec tmux -S \"/tmp/tmux-501/resolved\" " + + "\(controlMode ? "-CC " : "")attach-session -t \"=main\"")) + XCTAssertTrue(profile.matchesLiveAttachment(attachment, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + let other = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "main", + controlMode: controlMode, tmuxSocket: .name("other"))) + XCTAssertFalse(other.matchesLiveAttachment(attachment, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + } + } + } + } + + func testTildeExpansionIsUnknownWhileQuotedTildesRemainLiteral() { + for command in ["tmux -S ~/work.sock attach", "tmux -S ~user/work.sock attach", + "sh -c 'exec tmux -S ~/work.sock attach'"] { + let socket = TmuxSocketIdentity.fromStartupCommand(command) + XCTAssertNil(socket) + XCTAssertNil(MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: socket)) + } + for command in ["tmux -S '~/work.sock' attach", "tmux -S \"~/work.sock\" attach", + "tmux -S \\~/work.sock attach"] { + XCTAssertEqual(TmuxSocketIdentity.fromStartupCommand(command), .path("~/work.sock")) + } + } + + func testZellijAndCustomTmuxTargetsRoundTripForTabRestoration() throws { + for target in [ + MuxSessionTarget(type: .zellij, sessionName: "restored project"), + MuxSessionTarget(type: .tmux, sessionName: "main", controlMode: true, tmuxSocket: .path("/tmp/custom socket")), + MuxSessionTarget(type: .tmux, sessionName: "main", tmuxSocket: .name("work")) + ] { + let original = try XCTUnwrap(target) + let data = try JSONEncoder().encode(original) + let restored = try JSONDecoder().decode(MuxSessionTarget.self, from: data) + XCTAssertEqual(restored, original) + XCTAssertEqual(restored.execCommand, original.execCommand) + XCTAssertNotNil(restored.execCommand) + } + } + + func testLocalControlSessionCannotInterceptRemoteProfile() throws { + for type in [MultiplexerType.tmux, .herdr] { + let target = try XCTUnwrap(MuxSessionTarget(type: type, sessionName: "default", controlMode: true)) + XCTAssertFalse(target.matchesLiveAttachment(target, connectionKey: nil, + requestedConnectionKey: "user@remote:22", isActive: true)) + XCTAssertFalse(target.matchesLiveAttachment(target, connectionKey: "user@other:22", + requestedConnectionKey: "user@remote:22", isActive: true)) + XCTAssertTrue(target.matchesLiveAttachment(target, connectionKey: "user@remote:22", + requestedConnectionKey: "user@remote:22", isActive: true)) + } + } + + func testFailedConnectionsAndUnboundShellsDoNotBlockReconnect() throws { + for type in [MultiplexerType.tmux, .herdr, .zmx, .zellij] { + let target = try XCTUnwrap(MuxSessionTarget(type: type, sessionName: "work")) + XCTAssertFalse(target.matchesLiveAttachment(target, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: false)) + XCTAssertFalse(target.matchesLiveAttachment(nil, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + XCTAssertTrue(target.matchesLiveAttachment(target, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + } + } + + func testDifferentSessionsAndModesAreNotInterchangeable() throws { + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "work", controlMode: true)) + for other in [ + MuxSessionTarget(type: .tmux, sessionName: "work"), + MuxSessionTarget(type: .tmux, sessionName: "other", controlMode: true), + MuxSessionTarget(type: .herdr, sessionName: "work", controlMode: true) + ] { + XCTAssertFalse(target.matchesLiveAttachment(other, connectionKey: "remote", + requestedConnectionKey: "remote", isActive: true)) + } + } + + func testUnknownSessionDoesNotOfferAnUnrelatedReconnect() { + for type in [MultiplexerType.tmux, .herdr, .zellij, .zmx] { + XCTAssertNil(MuxSessionTarget(type: type, sessionName: nil)) + XCTAssertNil(MuxSessionTarget(type: type, sessionName: "")) + } + } + + func testTmuxReconnectPreservesControlModeAndUsesExactSessionTarget() throws { + for control in [false, true] { + let target = try XCTUnwrap(MuxSessionTarget(type: .tmux, sessionName: "work", controlMode: control)) + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec tmux \(control ? "-CC " : "")attach-session -t \"=work\"")) + } + } + + func testHerdrControlReconnectLeavesGatewayOnShell() throws { + let target = try XCTUnwrap(MuxSessionTarget(type: .herdr, sessionName: "default", controlMode: true)) + XCTAssertEqual(target.sessionName, "default") + XCTAssertTrue(target.controlMode) + XCTAssertNil(target.execCommand) + let raw = try XCTUnwrap(MuxSessionTarget(type: .herdr, sessionName: "project")) + XCTAssertEqual(raw.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec herdr session attach \"project\"")) + } + + func testZellijAndZmxReconnectToDiscoveredName() throws { + let zellij = try XCTUnwrap(MuxSessionTarget(type: .zellij, sessionName: "project", controlMode: true)) + XCTAssertFalse(zellij.controlMode) + XCTAssertEqual(zellij.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec zellij attach \"project\"")) + let zmx = try XCTUnwrap(MuxSessionTarget(type: .zmx, sessionName: "prefix-project")) + XCTAssertEqual(zmx.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "ZMX_SESSION_PREFIX= exec zmx attach \"prefix-project\"")) + } + + func testSessionNamesAreQuotedAcrossBothShellLayers() throws { + let name = "work's \"tab\"; $HOME `id` \\ end" + let target = try XCTUnwrap(MuxSessionTarget(type: .zellij, sessionName: name)) + let quoted = "\"work's \\\"tab\\\"; \\$HOME \\`id\\` \\\\ end\"" + XCTAssertEqual(target.execCommand, LoginShellCommand.runInPOSIXShell( + LoginShellCommand.pathPrefix + "exec zellij attach " + quoted)) + } +}