Skip to content
57 changes: 53 additions & 4 deletions Sources/RemoteTmuxControlConnection+PaneSubscriptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,76 @@ extension RemoteTmuxControlConnection {
/// Host-wide session digest subscription used only by the shared view stream.
/// The value changes on create/kill/rename even when no window event fires,
/// giving the multiplexer an event-driven reconcile source instead of polling.
/// Each session's window ids are in it too, in index order: `swap-window` sends
/// no notification at all, and this value changing is the only report of it.
static let sessionDigestSubscriptionName = "cmux_sessions"

///
/// The target is empty, which subscribes on the attached session. A pane target only reports
/// while that pane exists, and pane ids are never reused: measured on tmux 3.7b, a `%0`
/// subscription on a server whose first pane is gone reports nothing for a session created
/// or renamed, while the empty target reports both.
///
/// The comma in the session loop gives the attached session (the view itself) an empty
/// entry. The window loop has no comma, so it lists every window, the current one included.
static var sessionDigestSubscriptionCommand: String {
"refresh-client -B '\(sessionDigestSubscriptionName)::#{S:#{session_id}=#{session_name},}'"
"refresh-client -B '\(sessionDigestSubscriptionName)::"
+ "#{S:#{session_id}=#{session_name}:#{W:#{window_id}.}|,}'"
}

/// The attached session's window ids in index order, used by a stream attached to one
/// session. tmux reports `swap-window` to no client, so without this a swap made on the
/// host never reached the tabs.
static let windowOrderSubscriptionName = "cmux_order"

static var windowOrderSubscriptionCommand: String {
"refresh-client -B '\(windowOrderSubscriptionName)::#{W:#{window_id}.}'"
}

/// The window ids in a ``windowOrderSubscriptionName`` value (`@3.@1.@2.`), in order.
nonisolated static func windowIds(inOrderDigest value: String) -> [Int] {
value.split(separator: ".").compactMap { RemoteTmuxControlStreamParser.id($0, sigil: "@") }
}

/// Subscribes to the stream's order digest: the host-wide session digest on the shared
/// view stream, the attached session's window order on any other.
func subscribeSessionDigest() {
guard isSharedViewStream, !sessionDigestSubscribed else { return }
guard !sessionDigestSubscribed else { return }
sessionDigestSubscribed = true
send(Self.sessionDigestSubscriptionCommand)
send(isSharedViewStream ? Self.sessionDigestSubscriptionCommand : Self.windowOrderSubscriptionCommand)
}

/// The attach block drained: read the window list and subscribe to the order digest. The
/// shared view stream subscribes first; a per-session stream subscribes
/// after its window list, so the list stays the first reply that stream waits for.
func requestWindowsAndSubscribeAfterAttach() {
if isSharedViewStream { subscribeSessionDigest() }
requestWindows()
if !isSharedViewStream { subscribeSessionDigest() }
}

/// A ``windowOrderSubscriptionName`` push. The session's windows changed order with no
/// window notification (a `swap-window`), or tmux is reporting the first value. Only an
/// order that differs from the published one needs the window list read again.
func handleWindowOrderDigest(_ value: String) {
guard !isSharedViewStream, Self.windowIds(inOrderDigest: value) != windowOrder else { return }
record("window-order-changed")
requestWindows()
}

/// `%window-close`: the window is still in this client's session, so its tab stays. The
/// unlink may have moved it (a `move-window` links it at a new index first), and the
/// window list is what says where it is now.
func handleWindowUnlinkedElsewhere(windowId: Int) {
record("window-unlinked-elsewhere @\(windowId)")
requestWindows()
}

func unsubscribeSessionDigest() {
guard sessionDigestSubscribed else { return }
sessionDigestSubscribed = false
send("refresh-client -B \(Self.sessionDigestSubscriptionName)")
let name = isSharedViewStream ? Self.sessionDigestSubscriptionName : Self.windowOrderSubscriptionName
send("refresh-client -B \(name)")
}

/// Subscribes to live changes of `paneId`'s expanded `pane-border-format`
Expand Down
10 changes: 5 additions & 5 deletions Sources/RemoteTmuxControlConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ final class RemoteTmuxControlConnection {
/// than a dedicated per-session connection. Enables the host-wide session-digest
/// subscription and the extra topology notifications a shared stream needs.
var isSharedViewStream = false
/// Whether the ``sessionDigestSubscriptionName`` `refresh-client -B` is active.
/// Whether the stream's order digest (see ``subscribeSessionDigest()``) is subscribed.
var sessionDigestSubscribed = false
var newWindowCompletions: [UUID: (Int?) -> Void] = [:]
var newPaneCompletions: [UUID: (Int?) -> Void] = [:]
Expand Down Expand Up @@ -1470,7 +1470,7 @@ final class RemoteTmuxControlConnection {
// A `refresh-client -B` subscription belongs to the client too, so the
// session digest dies with it. The reconnect's attach drain calls
// `subscribeSessionDigest()` again, and it returns early unless this flag is
// cleared here — leaving the shared view blind to session create/kill/rename.
// cleared here — leaving the stream blind to session changes and window reorders.
sessionDigestSubscribed = false
pendingPostAttachAction = nil
teardownProcessHandles()
Expand Down Expand Up @@ -1715,6 +1715,7 @@ final class RemoteTmuxControlConnection {
// ledger above keeps any moved pane's control identity alive until
// the authoritative window snapshot publishes its destination.
observers.notifyTopologyChanged()
case let .windowUnlinkedElsewhere(id): handleWindowUnlinkedElsewhere(windowId: id)
case let .windowRenamed(id, name):
record("window-renamed @\(id)")
// Update published AND quarantined topology. A rename racing a
Expand Down Expand Up @@ -1804,15 +1805,14 @@ final class RemoteTmuxControlConnection {
// to re-list and rebuild multiplexed mirrors (coalesced by the view
// coordinator's in-flight reconcile guard).
observers.notifyTopologyChanged()
}
} else if name == Self.windowOrderSubscriptionName { handleWindowOrderDigest(value) }
case let .commandResult(_, lines, isError):
// The first block on each control stream is the attach command's own —
// consume it explicitly so it can never pop a queued command's slot off
// the positional FIFO (see ``attachBlockDrained``).
if !attachBlockDrained {
attachBlockDrained = true
if isSharedViewStream { subscribeSessionDigest() }
requestWindows()
requestWindowsAndSubscribeAfterAttach()
} else {
handleCommandResult(lines: lines, isError: isError)
}
Expand Down
9 changes: 8 additions & 1 deletion Sources/RemoteTmuxControlMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,16 @@ enum RemoteTmuxControlMessage: Sendable, Equatable {
/// way, because a window is not in the view until cmux links it.
case unlinkedWindowAdd(windowId: Int)

/// `%window-close @<id>` / `%unlinked-window-close @<id>` — a window closed.
/// `%unlinked-window-close @<id>` — a window left the attached session: it was killed, or
/// unlinked from this session.
case windowClose(windowId: Int)

/// `%window-close @<id>` — a window lost a link in some session but is still in the
/// attached session. tmux checks the client's session after the unlink and sends this form
/// only when the window is still there (measured on tmux 3.7b); `move-window` inside a
/// session produces it, because it links the window at the new index and unlinks the old one.
case windowUnlinkedElsewhere(windowId: Int)

/// `%window-renamed @<id> <name>` — a window was renamed.
case windowRenamed(windowId: Int, name: String)

Expand Down
6 changes: 5 additions & 1 deletion Sources/RemoteTmuxControlStreamParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,14 @@ struct RemoteTmuxControlStreamParser {
guard let id = Self.fieldId(line, 1, sigil: "@") else { return .unparsed(line) }
return .unlinkedWindowAdd(windowId: id)
}
if line.hasPrefix("%window-close ") || line.hasPrefix("%unlinked-window-close ") {
if line.hasPrefix("%unlinked-window-close ") {
guard let id = Self.fieldId(line, 1, sigil: "@") else { return .unparsed(line) }
return .windowClose(windowId: id)
}
if line.hasPrefix("%window-close ") {
guard let id = Self.fieldId(line, 1, sigil: "@") else { return .unparsed(line) }
return .windowUnlinkedElsewhere(windowId: id)
}
if line.hasPrefix("%window-renamed ") {
guard let id = Self.fieldId(line, 1, sigil: "@") else { return .unparsed(line) }
let name = Self.fieldsFrom(line, 2)
Expand Down
4 changes: 2 additions & 2 deletions Sources/RemoteTmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -887,8 +887,8 @@ final class RemoteTmuxController {
}

/// A tab close was requested in a mirrored workspace → kill that tmux window
/// on the remote. The local tab is removed when tmux reports `%window-close`,
/// so the caller should VETO the immediate local close.
/// on the remote. The local tab is removed when tmux reports
/// `%unlinked-window-close`, so the caller should VETO the local close.
///
/// - Returns: `true` if routed to the remote (caller vetoes the local close);
/// `false` if there is no live mirror/connection or the panel isn't a
Expand Down
31 changes: 24 additions & 7 deletions Sources/RemoteTmuxSessionMirror+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,37 @@ extension RemoteTmuxSessionMirror {
: trimmed
}

/// The workspace's panels that no tmux window owns, such as the terminal cmux opens when
/// the workspace's last tab closes. A reorder leaves their tabs where they are.
var panelsWithoutWindow: Set<UUID> {
guard let workspace else { return [] }
return Set(workspace.panels.keys).subtracting(windowIdByPanel.keys)
}

/// Computes the target tab order for a remote-tmux-driven reorder, or `nil`
/// when no reorder is needed or safe. Pure helper called by
/// `Workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder:)`.
/// `Workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder:keepingInPlace:)`.
///
/// - Parameters:
/// - current: the workspace's current mirror-tab order (panel ids).
/// - current: the workspace's current tab order (panel ids).
/// - requested: the tmux window order mapped to panel ids.
/// - fixed: tabs no tmux window owns. They keep their positions and the
/// rest are reordered around them.
/// - Returns: the new order to apply, or `nil` when the tabs already match
/// `requested` or when `requested` (restricted to currently-present tabs) is
/// not a permutation of `current` (sets diverge; leave the tabs untouched).
nonisolated static func mirrorTabReorder(current: [UUID], requested: [UUID]) -> [UUID]? {
let present = Set(current)
/// not a permutation of the tabs outside `fixed` (sets diverge; leave the
/// tabs untouched).
nonisolated static func mirrorTabReorder(
current: [UUID],
requested: [UUID],
keepingInPlace fixed: Set<UUID> = []
) -> [UUID]? {
let movable = current.filter { !fixed.contains($0) }
let present = Set(movable)
let desired = requested.filter { present.contains($0) }
guard desired.count == current.count, Set(desired) == present else { return nil }
return desired == current ? nil : desired
guard desired.count == movable.count, Set(desired) == present else { return nil }
var next = desired.makeIterator()
let reordered = current.map { fixed.contains($0) ? $0 : (next.next() ?? $0) }
return reordered == current ? nil : reordered
}
}
2 changes: 1 addition & 1 deletion Sources/RemoteTmuxSessionMirror.swift
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ final class RemoteTmuxSessionMirror: RemoteTmuxControlPaneMutationOwner {
// already matches, so this no-ops there.
let desiredPanelOrder = connection.windowOrder.compactMap { panelIdByWindow[$0] }
if desiredPanelOrder.count > 1 {
workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder: desiredPanelOrder)
workspace.reorderRemoteTmuxMirrorTabs(toPanelOrder: desiredPanelOrder, keepingInPlace: panelsWithoutWindow)
}
refreshAgentStatus()
}
Expand Down
9 changes: 7 additions & 2 deletions Sources/Workspace+RemoteTmuxTabOrder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,25 @@ extension Workspace {
/// 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.
///
/// Tabs in `fixed` belong to no tmux window (the terminal cmux opens when a
/// workspace's last tab closes, for one). They keep their positions, so one of
/// them can't stop the rest from following tmux.
///
/// 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
/// concurrent remote reorder during a ~1s local drag) and self-heals — the
/// drop's `didReorderTabsInPane` reconciles `connection.windowOrder` to the
/// final order. A drag-aware guard would need bonsplit to expose drag state.
@discardableResult
func reorderRemoteTmuxMirrorTabs(toPanelOrder panelOrder: [UUID]) -> Bool {
func reorderRemoteTmuxMirrorTabs(toPanelOrder panelOrder: [UUID], keepingInPlace fixed: Set<UUID> = []) -> Bool {
// A global tmux window order cannot span multiple cmux panes.
let presentPaneIds = Set(panelOrder.compactMap { paneId(forPanelId: $0) })
guard presentPaneIds.count == 1, let paneId = presentPaneIds.first else { return false }
let currentPanelIds = bonsplitController.tabs(inPane: paneId).compactMap { panelIdFromSurfaceId($0.id) }
guard let desired = RemoteTmuxSessionMirror.mirrorTabReorder(
current: currentPanelIds,
requested: panelOrder
requested: panelOrder,
keepingInPlace: fixed
) else { return false }
#if DEBUG
cmuxDebugLog("remote-tmux: reorder mirror tabs ws=\(id.uuidString.prefix(5)) count=\(desired.count)")
Expand Down
2 changes: 1 addition & 1 deletion Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10053,7 +10053,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
/// query (the subscription cache lags ~1s, which would let a just-started
/// command slip through), falling back to the cached state when the link is
/// down. The pane is removed by the resulting `%layout-change` (or
/// `%window-close` for the window's last pane), never locally.
/// `%unlinked-window-close` for the window's last pane), never locally.
func requestRemoteTmuxPaneClose(windowMirror: RemoteTmuxWindowMirror, tmuxPaneId: Int) {
guard CloseTabWarningStore(defaults: closeTabWarningDefaults).shouldConfirmCloseIncludingSafety(
requiresConfirmation: true, source: .tabCloseButton
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3219,6 +3219,7 @@
7020C0DE7020C0DE7020B002 /* RemoteTmuxSSHTransport+InteractiveRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7020C0DE7020C0DE7020B001 /* RemoteTmuxSSHTransport+InteractiveRetry.swift */; };
7368BEEF7368BEEF7368B004 /* RemoteTmuxSSHTransport+MissingTmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7368BEEF7368BEEF7368B003 /* RemoteTmuxSSHTransport+MissingTmux.swift */; };
6D3C19C2014FF9C754358EFF /* RemoteTmuxSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */; };
6C9D4527F1AB28A02FA1B2B9 /* RemoteTmuxTabOrderFollowsTmuxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7799EB5BD69203749106FE2 /* RemoteTmuxTabOrderFollowsTmuxTests.swift */; };
0E17C0DE0E17C0DE0E17C002 /* RemoteTmuxTransportRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */; };
0A17C0DE0A17C0DE0A17C902 /* RemoteTmuxVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C901 /* RemoteTmuxVersion.swift */; };
0A17C0DE0A17C0DE0A17CA02 /* RemoteTmuxVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */; };
Expand Down Expand Up @@ -7899,6 +7900,7 @@
7020C0DE7020C0DE7020B001 /* RemoteTmuxSSHTransport+InteractiveRetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxSSHTransport+InteractiveRetry.swift"; sourceTree = "<group>"; };
7368BEEF7368BEEF7368B003 /* RemoteTmuxSSHTransport+MissingTmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxSSHTransport+MissingTmux.swift"; sourceTree = "<group>"; };
E8F5DB43CBC1B7DF31B7A0C9 /* RemoteTmuxSSHTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSSHTransport.swift; sourceTree = "<group>"; };
A7799EB5BD69203749106FE2 /* RemoteTmuxTabOrderFollowsTmuxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxTabOrderFollowsTmuxTests.swift"; sourceTree = "<group>"; };
0E17C0DE0E17C0DE0E17C001 /* RemoteTmuxTransportRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxTransportRegistry.swift; sourceTree = "<group>"; };
0A17C0DE0A17C0DE0A17C901 /* RemoteTmuxVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxVersion.swift; sourceTree = "<group>"; };
0A17C0DE0A17C0DE0A17CA01 /* RemoteTmuxVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxVersionTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -14332,6 +14334,7 @@
A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */,
C978CC3C2D3FCCE66696C72C /* CLIKiroGlobalHooksTests.swift */,
B88325A2907EB7D69739349B /* ClaudeBackgroundWorkDrainTests.swift */,
A7799EB5BD69203749106FE2 /* RemoteTmuxTabOrderFollowsTmuxTests.swift */,
4189B3FB78ACEE79AE12B1AA /* ChiefDisabledHomeTests.swift */,
2E93DBA33C7287FE3D7FA1BB /* NotificationPopoverRowLayoutTests.swift */,
C0C7EE23B232921D28CA56FD /* NotificationsPopoverDrawnRowsTests.swift */,
Expand Down Expand Up @@ -19339,6 +19342,7 @@
3AC9AB9046E742B93726A501 /* RemoteTmuxSessionRenameTitleTests.swift in Sources */,
9A5CF3AA2E6462E77144F9E6 /* RemoteTmuxSessionSnapshotTests.swift in Sources */,
BCDF8EB16135AB24D023889D /* RemoteTmuxSocketErrorTextTests.swift in Sources */,
6C9D4527F1AB28A02FA1B2B9 /* RemoteTmuxTabOrderFollowsTmuxTests.swift in Sources */,
0A17C0DE0A17C0DE0A17CA02 /* RemoteTmuxVersionTests.swift in Sources */,
9EA7A34350C84A850797313A /* RemoteTmuxViewUnlinkedWindowTests.swift in Sources */,
74060000000000000000000E /* RemoteTmuxWindowMirrorFocusSeedTests.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion cmuxTests/RemoteTmuxAuthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ import Testing
connection.handleMessageForTesting(.commandResult(commandNumber: 1, lines: [], isError: false))

#expect(connection.pendingCommandKindsForTesting == [
.listWindows(reorderGeneration: 0, retainedPaneIDs: [])
.listWindows(reorderGeneration: 0, retainedPaneIDs: []), .other, // then the window-order subscription
])
}

Expand Down
2 changes: 1 addition & 1 deletion cmuxTests/RemoteTmuxControlParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ import Testing
@Test @MainActor func sessionDigestSubscribesOnTheAttachedSessionNotAPane() {
#expect(
RemoteTmuxControlConnection.sessionDigestSubscriptionCommand
== "refresh-client -B 'cmux_sessions::#{S:#{session_id}=#{session_name},}'"
== "refresh-client -B 'cmux_sessions::#{S:#{session_id}=#{session_name}:#{W:#{window_id}.}|,}'"
)
}
}
Expand Down
Loading
Loading