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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/RemoteTmuxControlCommandKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions Sources/RemoteTmuxControlConnection+CommandResults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -229,4 +239,10 @@ extension RemoteTmuxControlConnection {
break
}
}

func failPendingNewWindowRequests() {
let completions = Array(newWindowCompletions.values)
newWindowCompletions.removeAll()
completions.forEach { $0(nil) }
}
}
21 changes: 21 additions & 0 deletions Sources/RemoteTmuxControlConnection+Commands.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 5 additions & 7 deletions Sources/RemoteTmuxControlConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)] = [:]
Expand Down Expand Up @@ -694,6 +690,7 @@ final class RemoteTmuxControlConnection {
/// (``stop()``) and a genuine remote end (`%exit`).
private func cancelScheduledWork() {
failPendingActivityQueries()
failPendingNewWindowRequests()
reconnectTask?.cancel()
reconnectTask = nil
cancelSizingFollowUps()
Expand Down Expand Up @@ -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()
Expand Down
75 changes: 71 additions & 4 deletions Sources/RemoteTmuxController+Decisions.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
///
Expand All @@ -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 {
Expand Down
52 changes: 4 additions & 48 deletions Sources/RemoteTmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand Down Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions Sources/RemoteTmuxMirrorMutationCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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<Result>(
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<Result>(
_ operation: () throws -> Result
) rethrows -> Result {
try remoteTmuxMirrorMutations.perform(in: self, operation: operation)
}

@discardableResult
func removeRemoteTmuxDisplayPane(_ panelId: UUID) -> Bool {
performRemoteTmuxMirrorMutation {
closePanel(panelId, force: true)
}
}
}
Loading
Loading