diff --git a/Sources/RemoteTmuxControlCommandKind.swift b/Sources/RemoteTmuxControlCommandKind.swift index 2783a6abb661..ce7f83a36064 100644 --- a/Sources/RemoteTmuxControlCommandKind.swift +++ b/Sources/RemoteTmuxControlCommandKind.swift @@ -8,6 +8,7 @@ enum RemoteTmuxControlCommandKind: Equatable { case paneReflow(Int) case paneAltScreen(Int) case activityQuery(UUID) + case newWindow(UUID) /// A per-window `refresh-client -C '@id:WxH'` — an %error reply means /// the server predates the form and sizing falls back session-wide. case perWindowSize(Int) diff --git a/Sources/RemoteTmuxControlConnection+CommandResults.swift b/Sources/RemoteTmuxControlConnection+CommandResults.swift index b3514d840488..6e9ec6cf216f 100644 --- a/Sources/RemoteTmuxControlConnection+CommandResults.swift +++ b/Sources/RemoteTmuxControlConnection+CommandResults.swift @@ -16,6 +16,10 @@ extension RemoteTmuxControlConnection { let completion = activityQueryCompletions.removeValue(forKey: token) { completion(nil) } + if case let .newWindow(token) = kind, + let completion = newWindowCompletions.removeValue(forKey: token) { + completion(nil) + } // A rejected per-window size normally means the server predates // the '@id:WxH' form: degrade to session-wide sizing, visibly. // But a "can't find window" error is about ONE dead window (it @@ -45,6 +49,12 @@ extension RemoteTmuxControlConnection { return } switch kind { + case let .newWindow(token): + guard let completion = newWindowCompletions.removeValue(forKey: token) else { break } + let windowId = lines.first.flatMap { + RemoteTmuxControlStreamParser.id(Substring($0), sigil: "@") + } + completion(windowId) case let .paneRects(windowId, generation): handlePaneRectsReply(windowId: windowId, generation: generation, lines: lines) case .listWindows: @@ -229,4 +239,10 @@ extension RemoteTmuxControlConnection { break } } + + func failPendingNewWindowRequests() { + let completions = Array(newWindowCompletions.values) + newWindowCompletions.removeAll() + completions.forEach { $0(nil) } + } } diff --git a/Sources/RemoteTmuxControlConnection+Commands.swift b/Sources/RemoteTmuxControlConnection+Commands.swift new file mode 100644 index 000000000000..789838126655 --- /dev/null +++ b/Sources/RemoteTmuxControlConnection+Commands.swift @@ -0,0 +1,21 @@ +import Foundation + +extension RemoteTmuxControlConnection { + /// Sends a tmux command on the control stream (newline-terminated). + @discardableResult + func send(_ command: String) -> Bool { + sendInternal(command, kind: .other) + } + + /// Sends `new-window -P -F '#{window_id}'` and returns its stable window id. + @discardableResult + func sendNewWindow(_ command: String, completion: @escaping (Int?) -> Void) -> Bool { + let token = UUID() + newWindowCompletions[token] = completion + guard sendInternal(command, kind: .newWindow(token)) else { + newWindowCompletions.removeValue(forKey: token)?(nil) + return false + } + return true + } +} diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index c771d9a6f8c1..ff34936ca907 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -78,6 +78,7 @@ final class RemoteTmuxControlConnection { /// the cached classification instead of hanging until a reconnect that may /// never come. var activityQueryCompletions: [UUID: ([Int: PaneForegroundState]?) -> Void] = [:] + var newWindowCompletions: [UUID: (Int?) -> Void] = [:] private var process: Process? private var stdinWriter: RemoteTmuxControlPipeWriter? @@ -361,8 +362,9 @@ final class RemoteTmuxControlConnection { initialBatchAwaiting = nil initialBatchStaged.removeAll() // Normally already flushed by beginReconnecting; kept here so a future - // caller of spawnProcess can't strand a close decision. + // caller of spawnProcess can't strand command decisions. failPendingActivityQueries() + failPendingNewWindowRequests() attachBlockDrained = false stderrBuffer = "" enterReceived = false @@ -461,12 +463,6 @@ final class RemoteTmuxControlConnection { } } - /// Sends a tmux command on the control stream (newline-terminated). - @discardableResult - func send(_ command: String) -> Bool { - sendInternal(command, kind: .other) - } - /// The last size any writer requested per window — per-window dedup /// baseline and the reconnect re-pin table. var lastWindowSizes: [Int: (Int, Int)] = [:] @@ -694,6 +690,7 @@ final class RemoteTmuxControlConnection { /// (``stop()``) and a genuine remote end (`%exit`). private func cancelScheduledWork() { failPendingActivityQueries() + failPendingNewWindowRequests() reconnectTask?.cancel() reconnectTask = nil cancelSizingFollowUps() @@ -825,6 +822,7 @@ final class RemoteTmuxControlConnection { // The stream is dead: a close decision awaiting an activity query must // not hang for the whole backoff window — fail it onto the cache now. failPendingActivityQueries() + failPendingNewWindowRequests() cancelSizingFollowUps() pendingPostAttachAction = nil teardownProcessHandles() diff --git a/Sources/RemoteTmuxController+Decisions.swift b/Sources/RemoteTmuxController+Decisions.swift index 1fc789e4d888..d29cc24c2b3b 100644 --- a/Sources/RemoteTmuxController+Decisions.swift +++ b/Sources/RemoteTmuxController+Decisions.swift @@ -1,6 +1,64 @@ import Foundation extension RemoteTmuxController { + /// A new tab was requested in a mirrored workspace → create a tmux window in + /// that session. The new tab arrives via the `%window-add` notification (one + /// source of truth), so the caller must NOT also create a local tab. + /// + /// `placement` mirrors cmux's `newTabPosition` for the workspace tab strip so + /// a remote new tab lands where a local one would (after the selected tab, or + /// at the end), instead of wherever tmux's bare `new-window` picks (the lowest + /// free index, which lands mid-list when the session has window-index gaps). + /// + /// Requires a live `.connected` stream — NOT just `!exited`: while + /// reconnecting there is no stdin and `send` silently drops the command, so + /// returning `true` would let socket callers report an accepted mutation + /// that never reached tmux. + /// + /// - Parameter workingDirectory: the directory the new tmux window should + /// start in (the active tab's cwd, resolved by the caller), so a new tab + /// inherits the active tab's directory the way local cmux does. A + /// nil/blank/unsafe value, or a source panel that is not backed by a live + /// mirror window, omits `-c` and lets tmux pick its default-path. + /// - Parameter focus: whether this request explicitly intends to select and + /// focus the created mirror tab. Background requests use tmux's `-d` and + /// never enqueue local focus. + /// - Returns: `true` if routed to the remote; `false` if there is no live + /// mirror/connection (callers must still NOT create a local tab in a + /// mirror workspace — they report failure instead). + func handleMirrorNewTabRequested( + workspaceId: UUID, + placement: RemoteTmuxMirrorNewTabPlacement, + workingDirectory: String?, + workingDirectorySourcePanelId: UUID?, + focus: Bool + ) -> Bool { + guard let mirror = sessionMirror(workspaceId: workspaceId), + mirror.connection.connectionState == .connected else { return false } + let afterWindowId: Int? + switch placement { + case .end: + afterWindowId = nil + case .afterPanel(let panelId): + afterWindowId = mirror.windowId(forPanel: panelId) + } + let commandWorkingDirectory = Self.liveMirrorWindowWorkingDirectory( + workingDirectory, + sourcePanelId: workingDirectorySourcePanelId, + windowIdForPanel: mirror.windowId(forPanel:) + ) + let command = Self.newWindowCommand( + afterWindowId: afterWindowId, + workingDirectory: commandWorkingDirectory, + focus: focus + ) + guard focus else { return mirror.connection.send(command) } + return mirror.connection.sendNewWindow(command) { [weak mirror] windowId in + guard let windowId else { return } + mirror?.focusWindowWhenAvailable(windowId) + } + } + /// Returns the interactive SSH argv when an attach preflight failed because /// BatchMode could not prompt; otherwise the caller can handle the command /// result normally. @@ -35,13 +93,13 @@ extension RemoteTmuxController { /// Builds the tmux `new-window` command for a mirror new-tab. Pure (testable). /// /// Placement (`afterWindowId`): - /// - nil -> `new-window -a -t '{end}'`: `-a` inserts *after* the target and + /// - nil -> `new-window -d -a -t '{end}'`: `-a` inserts *after* the target and /// `'{end}'` resolves to the highest-indexed window, so the new window lands /// at the very end regardless of index gaps or which window tmux considers /// current. (`'{end}'` is an alias for `$`, available since tmux 2.1.) Plain /// `new-window` instead fills the lowest free index, landing mid-list when /// the session has gaps from closed windows. - /// - id -> `new-window -a -t @id`: insert right after that window. cmux never + /// - id -> `new-window -d -a -t @id`: insert right after that window. cmux never /// `select-window`s the remote, so the selected tab's window is targeted by /// id rather than relying on tmux's current window. /// @@ -51,8 +109,17 @@ extension RemoteTmuxController { /// metacharacters survive tmux's parser (the quoting the `rename-*` commands /// use on this stream); a path carrying CR/LF/control bytes that could /// terminate the command line is dropped, leaving the placement-only command. - nonisolated static func newWindowCommand(afterWindowId: Int?, workingDirectory: String?) -> String { - var command = afterWindowId.map { "new-window -a -t @\($0)" } ?? "new-window -a -t '{end}'" + /// Background requests add `-d`; focused requests ask tmux to print the stable + /// new window id so focus can be applied only after the mirror tab exists. + nonisolated static func newWindowCommand( + afterWindowId: Int?, + workingDirectory: String?, + focus: Bool = false + ) -> String { + var command = focus + ? "new-window -P -F '#{window_id}'" + : "new-window -d" + command += afterWindowId.map { " -a -t @\($0)" } ?? " -a -t '{end}'" if let directory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty, RemoteTmuxHost.controlModeLineSafeName(directory) != nil { diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 80a578309817..ad386692dbdd 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -525,54 +525,6 @@ final class RemoteTmuxController { // MARK: - Create / destroy propagation (P5) - /// A new tab was requested in a mirrored workspace → create a tmux window in - /// that session. The new tab arrives via the `%window-add` notification (one - /// source of truth), so the caller must NOT also create a local tab. - /// - /// `placement` mirrors cmux's `newTabPosition` for the workspace tab strip so - /// a remote new tab lands where a local one would (after the selected tab, or - /// at the end), instead of wherever tmux's bare `new-window` picks (the lowest - /// free index, which lands mid-list when the session has window-index gaps). - /// - /// Requires a live `.connected` stream — NOT just `!exited`: while - /// reconnecting there is no stdin and `send` silently drops the command, so - /// returning `true` would let socket callers report an accepted mutation - /// that never reached tmux. - /// - /// - Parameter workingDirectory: the directory the new tmux window should - /// start in (the active tab's cwd, resolved by the caller), so a new tab - /// inherits the active tab's directory the way local cmux does. A - /// nil/blank/unsafe value, or a source panel that is not backed by a live - /// mirror window, omits `-c` and lets tmux pick its default-path. - /// - Returns: `true` if routed to the remote; `false` if there is no live - /// mirror/connection (callers must still NOT create a local tab in a - /// mirror workspace — they report failure instead). - func handleMirrorNewTabRequested( - workspaceId: UUID, - placement: RemoteTmuxMirrorNewTabPlacement, - workingDirectory: String?, - workingDirectorySourcePanelId: UUID? - ) -> Bool { - guard let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == workspaceId }), - mirror.connection.connectionState == .connected else { return false } - let afterWindowId: Int? - switch placement { - case .end: - afterWindowId = nil - case .afterPanel(let panelId): - // nil (panel has no live window) falls back to end placement. - afterWindowId = mirror.windowId(forPanel: panelId) - } - let commandWorkingDirectory = Self.liveMirrorWindowWorkingDirectory( - workingDirectory, - sourcePanelId: workingDirectorySourcePanelId, - windowIdForPanel: mirror.windowId(forPanel:) - ) - return mirror.connection.send( - Self.newWindowCommand(afterWindowId: afterWindowId, workingDirectory: commandWorkingDirectory) - ) - } - /// A mirrored workspace was renamed → `rename-session` on the remote so the /// tmux session name tracks the cmux workspace title. func handleMirrorWorkspaceRenamed(workspaceId: UUID, title: String?) { @@ -1145,6 +1097,10 @@ final class RemoteTmuxController { /// Returns the control connection for a host+session, if attached. func connection(host: RemoteTmuxHost, sessionName: String) -> RemoteTmuxControlConnection? { connectionsByHostSession[Self.connectionKey(host: host, sessionName: sessionName)] } func sessionMirror(host: RemoteTmuxHost, sessionName: String) -> RemoteTmuxSessionMirror? { sessionMirrors[Self.connectionKey(host: host, sessionName: sessionName)] } + + func sessionMirror(workspaceId: UUID) -> RemoteTmuxSessionMirror? { + sessionMirrors.values.first { $0.mirroredWorkspaceId == workspaceId } + } /// Detaches a control client and removes its mirror workspace while leaving /// the remote session alive (#7364). Internal callers that already removed the /// mirror keep the low-level stop-only path, preserving their kill semantics. diff --git a/Sources/RemoteTmuxMirrorMutationCoordinator.swift b/Sources/RemoteTmuxMirrorMutationCoordinator.swift new file mode 100644 index 000000000000..1dcfe5dc3ca2 --- /dev/null +++ b/Sources/RemoteTmuxMirrorMutationCoordinator.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Owns the focus-neutral transaction boundary for remote-tmux topology bookkeeping. +@MainActor +final class RemoteTmuxMirrorMutationCoordinator { + private var activeSnapshot: RemoteTmuxMirrorMutationSnapshot? + + var suppressesFocusActivation: Bool { activeSnapshot != nil } + + @discardableResult + func perform( + in workspace: Workspace, + operation: () throws -> Result + ) rethrows -> Result { + if activeSnapshot != nil { + return try operation() + } + + let snapshot = RemoteTmuxMirrorMutationSnapshot(workspace: workspace) + activeSnapshot = snapshot + defer { + snapshot.restore(in: workspace) + activeSnapshot = nil + if snapshot.requiresReplacementFocus(in: workspace) { + workspace.scheduleFocusReconcile() + } + } + return try operation() + } +} + +extension Workspace { + @discardableResult + func performRemoteTmuxMirrorMutation( + _ operation: () throws -> Result + ) rethrows -> Result { + try remoteTmuxMirrorMutations.perform(in: self, operation: operation) + } + + @discardableResult + func removeRemoteTmuxDisplayPane(_ panelId: UUID) -> Bool { + performRemoteTmuxMirrorMutation { + closePanel(panelId, force: true) + } + } +} diff --git a/Sources/RemoteTmuxMirrorMutationSnapshot.swift b/Sources/RemoteTmuxMirrorMutationSnapshot.swift new file mode 100644 index 000000000000..f3b39c1ea5ce --- /dev/null +++ b/Sources/RemoteTmuxMirrorMutationSnapshot.swift @@ -0,0 +1,86 @@ +import AppKit +import Bonsplit +import Foundation + +/// User-visible selection and window state preserved across one mirror topology mutation. +@MainActor +struct RemoteTmuxMirrorMutationSnapshot { + let selectedTabs: [(paneId: PaneID, tabId: TabID)] + let focusedPaneId: PaneID? + let tabManager: TabManager? + let selectedWorkspaceId: UUID? + let window: NSWindow? + let wasWindowVisible: Bool + let wasWindowKey: Bool + let wasApplicationActive: Bool + let previousKeyWindow: NSWindow? + + init(workspace: Workspace) { + selectedTabs = workspace.bonsplitController.allPaneIds.compactMap { paneId in + workspace.bonsplitController.selectedTab(inPane: paneId).map { (paneId, $0.id) } + } + focusedPaneId = workspace.bonsplitController.focusedPaneId + tabManager = workspace.owningTabManager + selectedWorkspaceId = tabManager?.selectedTabId + window = tabManager?.window + wasWindowVisible = window?.isVisible == true + wasWindowKey = window?.isKeyWindow == true + wasApplicationActive = NSApp.isActive + previousKeyWindow = NSApp.keyWindow + } + + func restore(in workspace: Workspace) { + let selectedWorkspaceStillExists = selectedWorkspaceId.map { selectedWorkspaceId in + tabManager?.tabs.contains(where: { $0.id == selectedWorkspaceId }) == true + } ?? true + if tabManager?.selectedTabId != selectedWorkspaceId, selectedWorkspaceStillExists { + tabManager?.selectedTabId = selectedWorkspaceId + } + + for selection in selectedTabs + where workspace.bonsplitController.tabs(inPane: selection.paneId).contains(where: { $0.id == selection.tabId }) { + workspace.bonsplitController.selectTab(selection.tabId) + } + if let focusedPaneId, + workspace.bonsplitController.allPaneIds.contains(focusedPaneId) { + workspace.bonsplitController.focusPane(focusedPaneId) + } + + // A session-end lifecycle may legitimately discard the dedicated window; + // never resurrect a window its manager no longer owns. + guard let window, tabManager?.window === window else { return } + // Ordering a window out is the supported way to make AppKit resign an + // unexpected key window; `resignKeyWindow` is an override hook and must + // not be invoked directly. + if !wasWindowKey && window.isKeyWindow { + window.orderOut(nil) + } + if wasWindowVisible && !window.isVisible { + window.orderFront(nil) + } else if !wasWindowVisible && window.isVisible { + window.orderOut(nil) + } + if wasWindowKey { + if !window.isKeyWindow { window.makeKey() } + } else if let previousKeyWindow, + previousKeyWindow !== window, + previousKeyWindow.isVisible, + !previousKeyWindow.isKeyWindow { + previousKeyWindow.makeKey() + } + if !wasApplicationActive && NSApp.isActive { NSApp.deactivate() } + } + + func requiresReplacementFocus(in workspace: Workspace) -> Bool { + guard wasWindowVisible, + wasWindowKey, + selectedWorkspaceId == workspace.id, + tabManager?.window === window, + let focusedPaneId, + let selectedTabId = selectedTabs.first(where: { $0.paneId == focusedPaneId })?.tabId + else { return false } + return !workspace.bonsplitController.tabs(inPane: focusedPaneId).contains { + $0.id == selectedTabId + } + } +} diff --git a/Sources/RemoteTmuxSessionMirror.swift b/Sources/RemoteTmuxSessionMirror.swift index 20e6260daad2..ced9cad84ec1 100644 --- a/Sources/RemoteTmuxSessionMirror.swift +++ b/Sources/RemoteTmuxSessionMirror.swift @@ -68,6 +68,7 @@ final class RemoteTmuxSessionMirror { private var titleFilters: [Int: RemoteTmuxScreenTitleFilter] = [:] /// Per-window multi-pane renderers (present once a window has >1 pane). private var windowMirrorByWindowId: [Int: RemoteTmuxWindowMirror] = [:] + private var pendingExplicitFocusWindowId: Int? private var observerToken: RemoteTmuxControlConnection.ObserverToken? init( @@ -178,6 +179,13 @@ final class RemoteTmuxSessionMirror { /// local tab(s) once at least one remote tab exists. func rebuild() { guard let workspace else { return } + workspace.performRemoteTmuxMirrorMutation { + rebuildTopology(in: workspace) + } + focusExplicitlyRequestedWindowIfAvailable() + } + + private func rebuildTopology(in workspace: Workspace) { for windowId in connection.windowOrder { guard let window = connection.windowsByID[windowId], let firstPaneId = window.paneIDsInOrder.first else { continue } @@ -247,7 +255,7 @@ final class RemoteTmuxSessionMirror { mirror.teardown() windowMirrorByWindowId[windowId] = nil } - _ = workspace.closePanel(panelId, force: true) + _ = workspace.removeRemoteTmuxDisplayPane(panelId) panelIdByWindow[windowId] = nil panelIdByPane = panelIdByPane.filter { $0.value != panelId } } @@ -269,6 +277,21 @@ final class RemoteTmuxSessionMirror { } } + /// Applies explicit focus only after the corresponding mirror tab exists and + /// the focus-neutral topology transaction has completed. + func focusWindowWhenAvailable(_ windowId: Int) { + pendingExplicitFocusWindowId = windowId + focusExplicitlyRequestedWindowIfAvailable() + } + + private func focusExplicitlyRequestedWindowIfAvailable() { + guard let windowId = pendingExplicitFocusWindowId, + let panelId = panelIdByWindow[windowId], + let workspace else { return } + pendingExplicitFocusWindowId = nil + workspace.focusPanel(panelId) + } + /// Creates the in-tab multi-pane renderer the first time a window has more /// than one pane, and reconciles it on subsequent layout changes. Once /// created it persists for that window (rendering even a single pane), so the @@ -316,7 +339,7 @@ final class RemoteTmuxSessionMirror { private func closeDefaultTabsIfNeeded() { guard !defaultClosed, !panelIdByWindow.isEmpty, let workspace else { return } for panelId in defaultPanelIds where workspace.panels[panelId] != nil { - _ = workspace.closePanel(panelId, force: true) + _ = workspace.removeRemoteTmuxDisplayPane(panelId) } defaultClosed = true } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index dc652fd8eb8e..7d1cf608eadf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3363,12 +3363,8 @@ final class Workspace: Identifiable, ObservableObject { splitLayout.activeDetachCloseTransactions } private var isDetachingCloseTransaction: Bool { splitLayout.isDetachingCloseTransaction } - /// True while ``reorderRemoteTmuxMirrorTabs(toPanelOrder:)`` is rearranging tabs. - /// bonsplit's `reorderTab`/`selectTab`/`focusPane` fire `didSelectTab` / - /// `didFocusPane`, each of which runs the full `applyTabSelection` activation - /// (focus moves, hibernation resume, focus-LRU record). A reactive tmux-driven - /// reorder must not run any of that because the user's selection/focus is unchanged. - private var isApplyingRemoteTmuxTabReorder = false + /// Single transaction owner for focus-neutral remote-tmux topology bookkeeping. + let remoteTmuxMirrorMutations = RemoteTmuxMirrorMutationCoordinator() private var pendingRemoteSurfaceTTYName: String? private var pendingRemoteSurfaceTTYSurfaceId: UUID? private var pendingRemoteSurfacePortKickReason: PortScanKickReason? @@ -7465,7 +7461,8 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, placement: placement, workingDirectory: resolvedWorkingDirectory, - workingDirectorySourcePanelId: inheritSourcePanelId + workingDirectorySourcePanelId: inheritSourcePanelId, + focus: focus ?? (bonsplitController.focusedPaneId == paneId) ) ?? false return routed ? .routedToRemote : .failed } @@ -7660,9 +7657,8 @@ final class Workspace: Identifiable, ObservableObject { /// /// - Parameter focus: when `true`, selects and reasserts AppKit keyboard /// focus onto the created tab (a user-initiated attach). When `false` - /// (socket/background mirroring), the tab is created and selected within - /// its pane but the user's keyboard focus is left untouched, per the - /// socket focus policy. + /// (socket/background mirroring), selection and keyboard focus remain + /// unchanged, per the socket focus policy. @discardableResult func addRemoteTmuxDisplayPane( remotePaneId: Int, @@ -7672,47 +7668,43 @@ final class Workspace: Identifiable, ObservableObject { onInput: @escaping @Sendable (Data) -> Void, onResize: (@MainActor @Sendable (_ columns: Int, _ rows: Int) -> Void)? = nil ) -> TerminalPanel? { - guard let paneId = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first - else { return nil } - - let title = customTitle ?? String(localized: "remoteTmux.tab.pane", defaultValue: "tmux pane") - let surface = TerminalSurface( - tabId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - manualIO: true, - manualInputHandler: onInput - ) - if let onResize { surface.onManualSizeApplied = { onResize($0.columns, $0.rows) } } - let newPanel = TerminalPanel(workspaceId: id, surface: surface) - configureNewTerminalPanel( - newPanel, - allowTextBoxFocusDefault: focus && allowTextBoxFocusDefault - ) - panels[newPanel.id] = newPanel - panelTitles[newPanel.id] = title + let newPanel = performRemoteTmuxMirrorMutation { () -> TerminalPanel? in + guard let paneId = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first + else { return nil } + + let title = customTitle ?? String(localized: "remoteTmux.tab.pane", defaultValue: "tmux pane") + let surface = TerminalSurface( + tabId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + manualIO: true, + manualInputHandler: onInput + ) + if let onResize { surface.onManualSizeApplied = { onResize($0.columns, $0.rows) } } + let newPanel = TerminalPanel(workspaceId: id, surface: surface) + configureNewTerminalPanel( + newPanel, + allowTextBoxFocusDefault: focus && allowTextBoxFocusDefault + ) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = title - guard let newTabId = bonsplitController.createTab( - title: title, - icon: "rectangle.connected.to.line.below", - kind: SurfaceKind.terminal.rawValue, - inPane: paneId - ) else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - return nil - } - bindSurface(newTabId, toPanelId: newPanel.id) - if focus { - bonsplitController.focusPane(paneId) + guard let newTabId = bonsplitController.createTab( + title: title, + icon: "rectangle.connected.to.line.below", + kind: SurfaceKind.terminal.rawValue, + inPane: paneId + ) else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + return nil + } + bindSurface(newTabId, toPanelId: newPanel.id) + return newPanel } - bonsplitController.selectTab(newTabId) - if focus { - newPanel.focus() + if focus, let newPanel { + focusPanel(newPanel.id) } - // Reassert AppKit first-responder (keyboard focus) only on a user-initiated - // attach; a background/socket mirror must not steal focus. - applyTabSelection(tabId: newTabId, inPane: paneId, reassertAppKitFocus: focus) return newPanel } @@ -9280,10 +9272,10 @@ final class Workspace: Identifiable, ObservableObject { /// drag direction is handled by `handleMirrorWindowsReordered`. bonsplit's /// `reorderTab` selects+focuses the moved tab (and `selectTab`/`focusPane` fire /// the same activation), so the whole operation runs under - /// ``isApplyingRemoteTmuxTabReorder`` to suppress that churn — a reactive tmux - /// event must not steal focus or resume agents (socket focus policy). The user's - /// selection/focus are unchanged, so bonsplit's internal state is just restored - /// to match. No-ops when the tabs already match or aren't all in one pane. + /// the shared mirror-mutation transaction to suppress that churn — a reactive + /// tmux event must not steal focus or resume agents (socket focus policy). The + /// user's selection/focus are restored from one snapshot after the reorder. + /// No-ops when the tabs already match or aren't all in one pane. /// /// Known beta limitation: if a *remote* window reorder arrives while the user is /// mid tab-drag, this can move tabs under the drag. The trigger is narrow (a @@ -9304,21 +9296,12 @@ final class Workspace: Identifiable, ObservableObject { cmuxDebugLog("remote-tmux: reorder mirror tabs ws=\(id.uuidString.prefix(5)) count=\(desired.count)") #endif - let savedSelectedTabId = bonsplitController.selectedTab(inPane: paneId)?.id - let savedFocusedPaneId = bonsplitController.focusedPaneId - - isApplyingRemoteTmuxTabReorder = true - defer { isApplyingRemoteTmuxTabReorder = false } - for (index, panelId) in desired.enumerated() { - guard let tabId = surfaceIdFromPanelId(panelId) else { continue } - _ = bonsplitController.reorderTab(tabId, toIndex: index) + performRemoteTmuxMirrorMutation { + for (index, panelId) in desired.enumerated() { + guard let tabId = surfaceIdFromPanelId(panelId) else { continue } + _ = bonsplitController.reorderTab(tabId, toIndex: index) + } } - // Restore bonsplit's internal selection + focus (the loop moved them to the - // last-reordered tab). cmux's own focus/selection were never touched (the - // delegate handlers short-circuited), so this just realigns bonsplit with - // the user's unchanged state — no `applyTabSelection` runs. - if let savedSelectedTabId { bonsplitController.selectTab(savedSelectedTabId) } - if let savedFocusedPaneId { bonsplitController.focusPane(savedFocusedPaneId) } scheduleTerminalGeometryReconcile() return true @@ -9708,6 +9691,7 @@ final class Workspace: Identifiable, ObservableObject { trigger: FocusPanelTrigger = .standard, focusIntent: PanelFocusIntent? = nil ) { + guard !remoteTmuxMirrorMutations.suppressesFocusActivation else { return } markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" @@ -10256,8 +10240,9 @@ final class Workspace: Identifiable, ObservableObject { /// Reconcile focus/first-responder convergence. /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. - private func scheduleFocusReconcile() { + func scheduleFocusReconcile() { guard portalRenderingEnabled else { return } + guard !remoteTmuxMirrorMutations.suppressesFocusActivation else { return } #if DEBUG if isDetachingCloseTransaction { debugFocusReconcileScheduledDuringDetachCount += 1 @@ -11682,6 +11667,7 @@ extension Workspace: BonsplitDelegate { resumeHibernatedAgent: Bool? = nil, previousTerminalHostedView: GhosttySurfaceScrollView? = nil ) { + guard !remoteTmuxMirrorMutations.suppressesFocusActivation else { return } pendingTabSelection = PendingTabSelectionRequest( tabId: tabId, pane: pane, @@ -12453,9 +12439,8 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { - // Suppress the per-move selection churn of a reactive mirror-tab reorder - // (the user's selection/focus is restored explicitly afterwards). - guard !isApplyingRemoteTmuxTabReorder else { return } + // Mirror bookkeeping restores selection from its transaction snapshot. + guard !remoteTmuxMirrorMutations.suppressesFocusActivation else { return } applyTabSelection(tabId: tab.id, inPane: pane) } @@ -12537,9 +12522,8 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { - // See `isApplyingRemoteTmuxTabReorder`: a reactive reorder restores the - // prior pane focus itself, without re-running tab activation. - guard !isApplyingRemoteTmuxTabReorder else { return } + // Mirror bookkeeping restores pane focus without re-running activation. + guard !remoteTmuxMirrorMutations.suppressesFocusActivation else { return } // When a pane is focused, focus its selected tab's panel guard let tab = controller.selectedTab(inPane: pane) else { return } #if DEBUG diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f1bbcbc43c9f..91e9e9f6c41b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -983,6 +983,7 @@ D4A72DFB57B2B6043CF2C6DC /* RemoteTmuxConnectionWindowSizingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7032605BF360630E6E207A /* RemoteTmuxConnectionWindowSizingTests.swift */; }; 477796B44AAC7290AA9EFB6C /* RemoteTmuxControlCommandKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */; }; 7B577C74BB011F20214F880E /* RemoteTmuxControlConnection+CommandResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 919E2D0DAC80E2DAB13BD782 /* RemoteTmuxControlConnection+CommandResults.swift */; }; + D77330020000000000000002 /* RemoteTmuxControlConnection+Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77330020000000000000001 /* RemoteTmuxControlConnection+Commands.swift */; }; DED9C7914715D6FB4B83C12F /* RemoteTmuxControlConnection+LayoutPublication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2781B9ABDA90796E4EEBACBA /* RemoteTmuxControlConnection+LayoutPublication.swift */; }; 33742346CBA6FEF7836FD043 /* RemoteTmuxControlConnection+PaneSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BACF539E41CB537E0363E93D /* RemoteTmuxControlConnection+PaneSubscriptions.swift */; }; 47390CF61FE52509FE53FE75 /* RemoteTmuxControlConnection+Sizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05663199C0335662D0736AFA /* RemoteTmuxControlConnection+Sizing.swift */; }; @@ -1011,6 +1012,8 @@ A11FBDE1CC12989770B70EBE /* RemoteTmuxMirrorGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32650AC37D12294A52E4D63 /* RemoteTmuxMirrorGeometry.swift */; }; BC22F721323079468ED57528 /* RemoteTmuxMirrorGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD61EDB9377BE136E564F02B /* RemoteTmuxMirrorGeometryTests.swift */; }; 736200000000000000000006 /* RemoteTmuxMirrorLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */; }; + D77330010000000000000004 /* RemoteTmuxMirrorMutationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77330010000000000000003 /* RemoteTmuxMirrorMutationCoordinator.swift */; }; + D77330010000000000000002 /* RemoteTmuxMirrorMutationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77330010000000000000001 /* RemoteTmuxMirrorMutationSnapshot.swift */; }; F11ED7AB0004000400040004 /* RemoteTmuxMirrorNewTabPlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11ED7AB0003000300030003 /* RemoteTmuxMirrorNewTabPlacement.swift */; }; F11ED7AB0001000100010001 /* RemoteTmuxMirrorNewTabPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11ED7AB0002000200020002 /* RemoteTmuxMirrorNewTabPlacementTests.swift */; }; D4F8A2E61C5B39707A8E6F12 /* RemoteTmuxMirrorSplitRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */; }; @@ -2577,6 +2580,7 @@ 5C7032605BF360630E6E207A /* RemoteTmuxConnectionWindowSizingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxConnectionWindowSizingTests.swift; sourceTree = ""; }; AAAD58EFBF501E599E6D9E9A /* RemoteTmuxControlCommandKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxControlCommandKind.swift; sourceTree = ""; }; 919E2D0DAC80E2DAB13BD782 /* RemoteTmuxControlConnection+CommandResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+CommandResults.swift"; sourceTree = ""; }; + D77330020000000000000001 /* RemoteTmuxControlConnection+Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+Commands.swift"; sourceTree = ""; }; 2781B9ABDA90796E4EEBACBA /* RemoteTmuxControlConnection+LayoutPublication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+LayoutPublication.swift"; sourceTree = ""; }; BACF539E41CB537E0363E93D /* RemoteTmuxControlConnection+PaneSubscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+PaneSubscriptions.swift"; sourceTree = ""; }; 05663199C0335662D0736AFA /* RemoteTmuxControlConnection+Sizing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxControlConnection+Sizing.swift"; sourceTree = ""; }; @@ -2605,6 +2609,8 @@ D32650AC37D12294A52E4D63 /* RemoteTmuxMirrorGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorGeometry.swift; sourceTree = ""; }; AD61EDB9377BE136E564F02B /* RemoteTmuxMirrorGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorGeometryTests.swift; sourceTree = ""; }; 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorLifecycleTests.swift; sourceTree = ""; }; + D77330010000000000000003 /* RemoteTmuxMirrorMutationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorMutationCoordinator.swift; sourceTree = ""; }; + D77330010000000000000001 /* RemoteTmuxMirrorMutationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorMutationSnapshot.swift; sourceTree = ""; }; F11ED7AB0003000300030003 /* RemoteTmuxMirrorNewTabPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorNewTabPlacement.swift; sourceTree = ""; }; F11ED7AB0002000200020002 /* RemoteTmuxMirrorNewTabPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorNewTabPlacementTests.swift; sourceTree = ""; }; A7E1C4D2B3F09865E217D4C0 /* RemoteTmuxMirrorSplitRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorSplitRoutingTests.swift; sourceTree = ""; }; @@ -4215,7 +4221,8 @@ D7D5CC45BBE157F3EF880936 /* RemoteTmuxSessionMirror.swift */, 7F023A000000000000007023 /* RemoteTmuxSessionMirror+Helpers.swift */, 602197314BF2C4CC53565BBC /* RemoteTmuxControlConnection.swift */, - 919E2D0DAC80E2DAB13BD782 /* RemoteTmuxControlConnection+CommandResults.swift */, + 919E2D0DAC80E2DAB13BD782 /* RemoteTmuxControlConnection+CommandResults.swift */, + D77330020000000000000001 /* RemoteTmuxControlConnection+Commands.swift */, 2781B9ABDA90796E4EEBACBA /* RemoteTmuxControlConnection+LayoutPublication.swift */, BACF539E41CB537E0363E93D /* RemoteTmuxControlConnection+PaneSubscriptions.swift */, 05663199C0335662D0736AFA /* RemoteTmuxControlConnection+Sizing.swift */, @@ -4242,6 +4249,8 @@ C6434F910F6CBC3BC698D51C /* RemoteTmuxStripLabel.swift */, 8A5717357D55291686F99625 /* RemoteTmuxLayoutNode.swift */, F11ED7AB0003000300030003 /* RemoteTmuxMirrorNewTabPlacement.swift */, + D77330010000000000000003 /* RemoteTmuxMirrorMutationCoordinator.swift */, + D77330010000000000000001 /* RemoteTmuxMirrorMutationSnapshot.swift */, 6FC55CC7B42874062C64013A /* RemoteTmuxMirrorTabActivity.swift */, 123BF93B1ADC00C5D25EE028 /* RemoteTmuxPaneForegroundState.swift */, CE24906539C19AB10E4C1C71 /* RemoteTmuxPostAttachAction.swift */, @@ -5870,6 +5879,7 @@ 6A9D9021221BF8B429986368 /* RemoteTmuxConnectionState.swift in Sources */, 477796B44AAC7290AA9EFB6C /* RemoteTmuxControlCommandKind.swift in Sources */, 7B577C74BB011F20214F880E /* RemoteTmuxControlConnection+CommandResults.swift in Sources */, + D77330020000000000000002 /* RemoteTmuxControlConnection+Commands.swift in Sources */, DED9C7914715D6FB4B83C12F /* RemoteTmuxControlConnection+LayoutPublication.swift in Sources */, 33742346CBA6FEF7836FD043 /* RemoteTmuxControlConnection+PaneSubscriptions.swift in Sources */, 47390CF61FE52509FE53FE75 /* RemoteTmuxControlConnection+Sizing.swift in Sources */, @@ -5890,6 +5900,8 @@ E54DED0FDC24F51BF1470A1A /* RemoteTmuxLayoutNode.swift in Sources */, 7E110877D4049839B248D5B2 /* RemoteTmuxMirrorFrames.swift in Sources */, A11FBDE1CC12989770B70EBE /* RemoteTmuxMirrorGeometry.swift in Sources */, + D77330010000000000000004 /* RemoteTmuxMirrorMutationCoordinator.swift in Sources */, + D77330010000000000000002 /* RemoteTmuxMirrorMutationSnapshot.swift in Sources */, F11ED7AB0004000400040004 /* RemoteTmuxMirrorNewTabPlacement.swift in Sources */, F861B8D7C62A0BCB252A523A /* RemoteTmuxMirrorTabActivity.swift in Sources */, E9A8CC35D632F14A8669B7D1 /* RemoteTmuxPaneForegroundState.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxMirrorLifecycleTests.swift b/cmuxTests/RemoteTmuxMirrorLifecycleTests.swift index b2408873d7e1..7160bf332a15 100644 --- a/cmuxTests/RemoteTmuxMirrorLifecycleTests.swift +++ b/cmuxTests/RemoteTmuxMirrorLifecycleTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @@ -7,15 +8,17 @@ import Testing @testable import cmux #endif -/// Regression tests for remote-tmux mirror detach behavior. They use cached, -/// unstarted control connections so no ssh/tmux ever attaches anywhere. The -/// last-mirror teardown does fire-and-forget the production `ssh -O exit` at -/// cmux's own (nonexistent here) ControlPath socket — a local-only no-op that -/// exits immediately; a test seam to suppress it is exactly the production -/// test-scaffolding cmux policy forbids. +/// Regression tests for remote-tmux mirror lifecycle and focus-neutral topology +/// mutations. Detach coverage uses cached, unstarted control connections so no +/// ssh/tmux ever attaches anywhere. The last-mirror teardown does fire-and-forget +/// the production `ssh -O exit` at cmux's own (nonexistent here) ControlPath +/// socket — a local-only no-op that exits immediately; a test seam to suppress +/// it is exactly the production test-scaffolding cmux policy forbids. @MainActor @Suite(.serialized) struct RemoteTmuxMirrorLifecycleTests { + private let ignoreInput: @Sendable (Data) -> Void = { _ in } + private func mirror( controller: RemoteTmuxController, manager: TabManager, @@ -78,4 +81,76 @@ struct RemoteTmuxMirrorLifecycleTests { #expect(alpha.exited) #expect(!beta.exited) } + + @Test func backgroundDisplayPaneCreationPreservesSelectedSurface() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let pane = try #require(workspace.bonsplitController.focusedPaneId) + let selectedBefore = try #require(workspace.bonsplitController.selectedTab(inPane: pane)?.id) + + let mirrorPanel = workspace.addRemoteTmuxDisplayPane( + remotePaneId: 7, + title: "background", + focus: false, + onInput: ignoreInput + ) + + #expect(mirrorPanel != nil) + #expect(workspace.bonsplitController.focusedPaneId == pane) + #expect(workspace.bonsplitController.selectedTab(inPane: pane)?.id == selectedBefore) + } + + @Test func hiddenMirrorWindowStaysHiddenAndNonKeyAcrossBackgroundClose() async throws { + _ = NSApplication.shared + let manager = TabManager() + let workspace = manager.addWorkspace(select: true, autoWelcomeIfNeeded: false) + workspace.isRemoteTmuxMirror = true + defer { workspace.teardownAllPanels() } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + manager.window = window + defer { + manager.window = nil + window.close() + } + + let pane = try #require(workspace.bonsplitController.focusedPaneId) + let selectedBefore = try #require(workspace.bonsplitController.selectedTab(inPane: pane)?.id) + _ = try #require(workspace.addRemoteTmuxDisplayPane( + remotePaneId: 7, + title: "first mirror", + focus: false, + onInput: ignoreInput + )) + let closingPanel = try #require(workspace.addRemoteTmuxDisplayPane( + remotePaneId: 8, + title: "background mirror", + focus: false, + onInput: ignoreInput + )) + workspace.bonsplitController.selectTab(selectedBefore) + window.orderOut(nil) + await confirmation("hidden mirror window became key", expectedCount: 0) { becameKey in + let keyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: nil + ) { _ in + becameKey() + } + defer { NotificationCenter.default.removeObserver(keyObserver) } + + #expect(workspace.removeRemoteTmuxDisplayPane(closingPanel.id)) + } + + #expect(workspace.bonsplitController.focusedPaneId == pane) + #expect(workspace.bonsplitController.selectedTab(inPane: pane)?.id == selectedBefore) + #expect(!window.isVisible) + #expect(!window.isKeyWindow) + } } diff --git a/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift b/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift index 6cb842bff169..3683eac5f7d6 100644 --- a/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift +++ b/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift @@ -11,7 +11,7 @@ import Testing /// instead of tmux's bare `new-window`, which fills the lowest free window index /// and lands the tab mid-list when the session has gaps from closed windows. /// -/// `RemoteTmuxController.newWindowCommand(afterWindowId:workingDirectory:)` is the +/// `RemoteTmuxController.newWindowCommand(afterWindowId:workingDirectory:focus:)` is the /// pure command builder behind `handleMirrorNewTabRequested`: /// - no target window (`.end`, or an unresolved `.current` selection) → append at /// the end (`-a -t '{end}'`). @@ -23,7 +23,7 @@ import Testing @Test func appendsAtEndWhenNoTargetWindow() { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: nil, workingDirectory: nil) - == "new-window -a -t '{end}'" + == "new-window -d -a -t '{end}'" ) } @@ -32,7 +32,28 @@ import Testing @Test func insertsAfterSelectedWindow() { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: nil) - == "new-window -a -t @7" + == "new-window -d -a -t @7" + ) + } + + /// A background surface request must create the remote tmux window detached, + /// otherwise tmux changes its active window before the mirror can reconcile. + @Test func backgroundCreationKeepsTmuxSelectionDetached() { + let command = RemoteTmuxController.newWindowCommand( + afterWindowId: nil, + workingDirectory: nil + ) + + #expect(command.split(separator: " ").contains("-d")) + } + + @Test func focusedCreationReturnsStableWindowIdWithoutDetaching() { + #expect( + RemoteTmuxController.newWindowCommand( + afterWindowId: 7, + workingDirectory: nil, + focus: true + ) == "new-window -P -F '#{window_id}' -a -t @7" ) } } diff --git a/cmuxTests/RemoteTmuxNewWindowCwdTests.swift b/cmuxTests/RemoteTmuxNewWindowCwdTests.swift index e10a80cc33be..37dff8b4415b 100644 --- a/cmuxTests/RemoteTmuxNewWindowCwdTests.swift +++ b/cmuxTests/RemoteTmuxNewWindowCwdTests.swift @@ -15,7 +15,7 @@ import Testing /// remote mirror routes a new tab to a tmux `new-window`, which — without an /// explicit `-c ` — starts in tmux's default-path (`~`) instead of the /// focused tab's directory. cmux appends that directory onto the placement -/// command built by ``RemoteTmuxController/newWindowCommand(afterWindowId:workingDirectory:)``. +/// command built by ``RemoteTmuxController/newWindowCommand(afterWindowId:workingDirectory:focus:)``. /// /// These assert the produced control-mode command: a known directory adds a /// single-quoted `-c` after the placement target, and absent/blank/unsafe @@ -25,21 +25,21 @@ import Testing @Test func seedsStartingDirectoryAfterSelectedWindow() { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: "/Users/me/proj") - == "new-window -a -t @7 -c '/Users/me/proj'" + == "new-window -d -a -t @7 -c '/Users/me/proj'" ) } @Test func seedsStartingDirectoryForEndPlacement() { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: nil, workingDirectory: "/Users/me/proj") - == "new-window -a -t '{end}' -c '/Users/me/proj'" + == "new-window -d -a -t '{end}' -c '/Users/me/proj'" ) } @Test func singleQuotesPathsWithSpaces() { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: "/Users/me/My Project") - == "new-window -a -t @7 -c '/Users/me/My Project'" + == "new-window -d -a -t @7 -c '/Users/me/My Project'" ) } @@ -47,7 +47,7 @@ import Testing // shell single-quote escaping: ' -> '\'' so the path survives tmux's parser. #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: "/Users/me/o'brien") - == "new-window -a -t @7 -c '/Users/me/o'\\''brien'" + == "new-window -d -a -t @7 -c '/Users/me/o'\\''brien'" ) } @@ -60,7 +60,7 @@ import Testing func omitsDirectoryWhenUnusable(_ directory: String?) { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: directory) - == "new-window -a -t @7" + == "new-window -d -a -t @7" ) } @@ -74,7 +74,7 @@ import Testing // the quoted argument, so an unsafe path leaves the placement-only command. #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: directory) - == "new-window -a -t @7" + == "new-window -d -a -t @7" ) }