diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index c35f3e789784..e1ada3396d8c 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -887,12 +887,22 @@ jobs: run: | set -uo pipefail started="$(python3 -c 'import time; print(time.monotonic())')" - scripts/ci/compile-app-host-test-product.sh canonical-build \ - "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" \ - "$PWD/.ci-source-packages" \ - "$CMUX_COMPILE_ADMISSION_CAS" \ - "$RUNNER_TEMP/cmux-compile-admission.txt" - status=$? + status=1 + for attempt in 1 2; do + scripts/ci/compile-app-host-test-product.sh canonical-build \ + "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" \ + "$PWD/.ci-source-packages" \ + "$CMUX_COMPILE_ADMISSION_CAS" \ + "$RUNNER_TEMP/cmux-compile-admission.txt" + status=$? + [ "$status" -eq 0 ] && break + [ "$attempt" -lt 2 ] || break + echo "Admission build failed; recreating the DerivedData tree and retrying once" >&2 + scripts/ci/clear-dirs.sh \ + "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" \ + "$CMUX_COMPILE_ADMISSION_CAS" + mkdir -p "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" "$CMUX_COMPILE_ADMISSION_CAS" + done finished="$(python3 -c 'import time; print(time.monotonic())')" seconds="$(python3 -c 'import sys; print(round(float(sys.argv[2])-float(sys.argv[1]), 6))' "$started" "$finished")" echo "seconds=$seconds" >> "$GITHUB_OUTPUT" diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTerminalPaneClosure.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTerminalPaneClosure.swift index b8ea008f5131..8e55a2809d2b 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTerminalPaneClosure.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudTerminalPaneClosure.swift @@ -16,13 +16,16 @@ public enum CloudTerminalPaneClosure { /// - liveTerminalKeys: terminal keys in the freshly published graph. /// - freshness: whether that graph is current. A stale graph means the /// machine is unreachable, not that a terminal ended, so nothing closes. + /// - graphComplete: whether the graph contains catalog rows for every + /// recognized tab. An incomplete inventory cannot prove a terminal ended. /// - Returns: panel ids in a stable order. public static func panelsToClose( boundTerminals: [UUID: String], liveTerminalKeys: Set, - freshness: CloudVMStateFreshness + freshness: CloudVMStateFreshness, + graphComplete: Bool = true ) -> [UUID] { - guard freshness == .current else { return [] } + guard freshness == .current, graphComplete else { return [] } return boundTerminals .filter { !liveTerminalKeys.contains($0.value) } .keys diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTerminalPaneClosureTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTerminalPaneClosureTests.swift index b5d2cdbdcd65..7749fab10c05 100644 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTerminalPaneClosureTests.swift +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTerminalPaneClosureTests.swift @@ -41,4 +41,15 @@ struct CloudTerminalPaneClosureTests { ) #expect(closing.isEmpty) } + + @Test + func anIncompleteGraphNeverClosesAPane() { + let closing = CloudTerminalPaneClosure.panelsToClose( + boundTerminals: [paneA: "term_alive", paneB: "term_pending"], + liveTerminalKeys: ["term_alive"], + freshness: .current, + graphComplete: false + ) + #expect(closing.isEmpty) + } } diff --git a/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiPersistentRequestBuilder.swift b/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiPersistentRequestBuilder.swift index ff0a2b88f3ef..bba4f309c04d 100644 --- a/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiPersistentRequestBuilder.swift +++ b/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiPersistentRequestBuilder.swift @@ -87,6 +87,13 @@ public enum CloudTuiRequests { public static func projectTerminalArguments(socketPath: String, terminalID: String, target: CloudTuiTerminalProjectionTarget, expectedRevision: String? = nil, idempotencyKey: String? = nil) -> CloudTuiRequest { placement("terminal.project", source: ["terminal": terminalID], target: target, revision: expectedRevision, key: idempotencyKey) } + /// Replaces one workspace screen's tree shape, ratios and tab order. `document` is a + /// `LayoutDocument` whose pane, split and tab sets exactly match the live screen. + public static func applyWorkspaceLayoutArguments(workspaceID: String, document: [String: Any], expectedRevision: String? = nil) -> CloudTuiRequest { + var fields: [String: Any] = ["workspace": workspaceID, "layout": document] + if let expectedRevision { fields["expected_revision"] = expectedRevision } + return CloudTuiRequest("workspace.layout.apply", fields, mutation: true) + } public static func moveTabArguments(socketPath: String, tabID: String, target: CloudTuiTerminalProjectionTarget, expectedRevision: String? = nil, idempotencyKey: String? = nil) -> CloudTuiRequest { placement("tab.move", source: ["tab": tabID], target: target, revision: expectedRevision, key: idempotencyKey) } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncPlanner.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncPlanner.swift new file mode 100644 index 000000000000..1326e35d132b --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncPlanner.swift @@ -0,0 +1,300 @@ +import Foundation + +/// Plans the next mutation that converges one daemon workspace to a native split tree. +/// +/// The daemon's `workspace.layout.apply` rearranges tree shape, ratios and tab order, +/// but only over the panes and splits the screen already has. Membership changes are +/// therefore planned first, one step at a time, from the snapshot the caller just read: +/// +/// 1. A native pane with no machine pane gets one by splitting (a scratch terminal +/// holds the new pane open until a real tab arrives, then is closed). +/// 2. Each tab is moved to its native pane and index with `tab.move`; a machine pane +/// whose last tab leaves is removed by the daemon. +/// 3. With membership equal, the full layout document is applied once. +/// +/// Planning never guesses: a tab that exists on only one side is ``CloudLayoutSyncStep/notReady(_:)``, +/// and a shape this client does not write is ``CloudLayoutSyncStep/unsupported(_:)``. +/// +/// ```swift +/// let planner = CloudLayoutSyncPlanner(snapshot: snapshot, workspaceID: "ws_1", desired: tree) +/// if case .moveTab(let tabID, let target) = planner.step { /* send tab.move */ } +/// ``` +public struct CloudLayoutSyncPlanner: Sendable { + /// The next step for the snapshot this planner was built from. + public let step: CloudLayoutSyncStep + + /// Differences below this are treated as equal: native dividers round to pixels. + static let ratioTolerance = 0.005 + + /// Plans from a decoded `session.snapshot` result. + /// + /// - Parameters: + /// - snapshot: The machine's current snapshot, including screen layout documents. + /// - workspaceID: The daemon workspace bound to the native workspace. + /// - desired: The native split tree in daemon tab IDs. + /// - scratchTabIDs: Tabs this sync started with ``CloudLayoutSyncStep/splitPane(paneID:)``. + /// They are not part of the native tree and are closed once their pane is settled. + public init(snapshot: [String: Any], workspaceID: String, desired: CloudLayoutSyncTree, scratchTabIDs: Set = []) { + step = Self.plan(snapshot: snapshot, workspaceID: workspaceID, desired: desired, scratchTabIDs: scratchTabIDs) + } + + // MARK: - Planning + + private static func plan( + snapshot: [String: Any], + workspaceID: String, + desired: CloudLayoutSyncTree, + scratchTabIDs: Set + ) -> CloudLayoutSyncStep { + let screens = ((snapshot["screens"] as? [[String: Any]]) ?? []).filter { $0["workspace_id"] as? String == workspaceID } + guard !screens.isEmpty else { return .notReady("the workspace has no screen") } + guard screens.count == 1 else { return .unsupported("the workspace has several screens") } + guard let screenID = screens[0]["id"] as? String, + let document = screens[0]["layout"] as? [String: Any], + let daemon = LayoutNode(daemon: document["root"]) else { + return .unsupported("the screen layout is not a split tree") + } + var terminalByTab: [String: String] = [:] + for tab in (snapshot["tabs"] as? [[String: Any]]) ?? [] { + guard let id = tab["id"] as? String, tab["content_kind"] as? String == "terminal", + let terminal = tab["content_id"] as? String else { continue } + terminalByTab[id] = terminal + } + + let nativeTabs = desired.leaves.flatMap(\.tabIDs) + guard desired.leaves.allSatisfy({ !$0.tabIDs.isEmpty }), Set(nativeTabs).count == nativeTabs.count else { + return .unsupported("the native tree has an empty pane or a repeated tab") + } + let panes = daemon.leaves + let daemonTabs = panes.flatMap(\.tabIDs) + let scratch = scratchTabIDs.intersection(daemonTabs) + // Membership is owned by creates and closes, not by this writer. A native tab + // the machine already closed is dropped, and a machine tab this Mac has not + // projected yet (another client, an agent) stays beside its current neighbor, + // so the user's arrangement is written without waiting for either to settle. + guard var target = desired.keeping(Set(daemonTabs).subtracting(scratch)) else { + return .notReady("no native tab is on the machine") + } + var placed = Set(target.leaves.flatMap(\.tabIDs)) + for pane in panes { + for (index, tabID) in pane.tabIDs.enumerated() where !placed.contains(tabID) && !scratch.contains(tabID) { + if let anchor = pane.tabIDs[.. CloudLayoutSyncStep { + guard let terminal = terminalByTab[tabID] else { return .unsupported("a scratch tab has no terminal") } + return .closeScratch(tabID: tabID, terminalID: terminal) + } + // A scratch terminal is only a placeholder: once a real tab shares its pane, it goes. + if let tabID = panes.first(where: { $0.tabIDs.count > 1 && $0.tabIDs.contains(where: scratch.contains) })? + .tabIDs.first(where: scratch.contains) { + return close(tabID) + } + + // Keep each machine pane where most of its tabs already are, so a pane's + // identity (and its scrollback owner) survives the rearrangement. + var candidates: [(score: Int, leaf: Int, pane: Int)] = [] + for (leafIndex, leaf) in wanted.enumerated() { + let members = Set(leaf.tabIDs) + for (paneIndex, pane) in panes.enumerated() { + let score = pane.tabIDs.filter(members.contains).count + if score > 0 { candidates.append((score, leafIndex, paneIndex)) } + } + } + candidates.sort { ($0.score, -$0.leaf, -$0.pane) > ($1.score, -$1.leaf, -$1.pane) } + var paneForLeaf: [Int: String] = [:] + var usedPanes = Set() + for candidate in candidates where paneForLeaf[candidate.leaf] == nil { + let paneID = panes[candidate.pane].paneID + guard usedPanes.insert(paneID).inserted else { continue } + paneForLeaf[candidate.leaf] = paneID + } + let scratchPanes = panes.filter { $0.tabIDs.allSatisfy(scratch.contains) }.map(\.paneID) + for leafIndex in wanted.indices where paneForLeaf[leafIndex] == nil { + guard let paneID = scratchPanes.first(where: { !usedPanes.contains($0) }) else { continue } + usedPanes.insert(paneID) + paneForLeaf[leafIndex] = paneID + } + if let leafIndex = wanted.indices.first(where: { paneForLeaf[$0] == nil }) { + let anchor = wanted[leafIndex].tabIDs[0] + guard let paneID = panes.first(where: { $0.tabIDs.contains(anchor) })?.paneID else { + return .notReady("a native tab has no machine pane") + } + return .splitPane(paneID: paneID) + } + if let unused = panes.first(where: { !usedPanes.contains($0.paneID) && $0.tabIDs.allSatisfy(scratch.contains) }), + let tabID = unused.tabIDs.first { + return close(tabID) + } + + for (leafIndex, leaf) in wanted.enumerated() { + guard let paneID = paneForLeaf[leafIndex], + let current = panes.first(where: { $0.paneID == paneID })?.tabIDs.filter({ !scratch.contains($0) }) else { + continue + } + // Earlier indexes already match, so `index` never exceeds the pane's tab count. + for (index, tabID) in leaf.tabIDs.enumerated() where index >= current.count || current[index] != tabID { + return .moveTab(tabID: tabID, target: CloudTuiTerminalProjectionTarget( + workspaceID: workspaceID, screenID: screenID, paneID: paneID, index: index + )) + } + } + + var leafCursor = 0 + let layoutTree = LayoutNode(desired: target, panes: paneForLeaf, daemonActive: daemon.activeTabs, cursor: &leafCursor) + .assigningSplitIDs(from: daemon) + if layoutTree.matches(daemon) { return .done } + let paneIDs = Set(paneForLeaf.values) + let active = (document["active_pane_id"] as? String).flatMap { paneIDs.contains($0) ? $0 : nil } + ?? paneForLeaf[0] ?? "" + let zoomed = (document["zoomed_pane_id"] as? String).flatMap { paneIDs.contains($0) ? $0 : nil } + let object: [String: Any] = [ + "version": 1, + "screen_id": screenID, + "active_pane_id": active, + "zoomed_pane_id": zoomed ?? NSNull(), + "root": layoutTree.json, + ] + guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { + return .unsupported("the layout document could not be encoded") + } + return .applyLayout(data) + } +} + +/// One node of a screen's split tree, shared by the daemon's document and the native target. +private indirect enum LayoutNode { + case leaf(paneID: String, tabIDs: [String], activeTabID: String?) + case split(splitID: String?, horizontal: Bool, ratio: Double, first: LayoutNode, second: LayoutNode) + + /// Reads a daemon `LayoutDocument` node. Stacks and viewports are not written by + /// this client, so they read as nil and the caller leaves the machine untouched. + init?(daemon raw: Any?) { + guard let object = raw as? [String: Any] else { return nil } + switch object["kind"] as? String { + case "leaf": + guard let paneID = object["pane_id"] as? String, + let tabIDs = object["tab_ids"] as? [String], !tabIDs.isEmpty else { return nil } + self = .leaf(paneID: paneID, tabIDs: tabIDs, activeTabID: object["active_tab_id"] as? String) + case "split": + let horizontal: Bool + switch object["direction"] as? String { + case "horizontal", "right": horizontal = true + case "vertical", "down": horizontal = false + default: return nil + } + guard let splitID = object["split_id"] as? String, + let ratio = (object["ratio"] as? NSNumber)?.doubleValue, + let first = LayoutNode(daemon: object["first"]), + let second = LayoutNode(daemon: object["second"]) else { return nil } + self = .split(splitID: splitID, horizontal: horizontal, ratio: ratio, first: first, second: second) + default: + return nil + } + } + + /// The native target with each leaf bound to its planned machine pane. + init(desired: CloudLayoutSyncTree, panes: [Int: String], daemonActive: [String: String], cursor: inout Int) { + switch desired { + case .leaf(let tabIDs, let activeTabID): + let paneID = panes[cursor] ?? "" + cursor += 1 + let active = activeTabID.flatMap { tabIDs.contains($0) ? $0 : nil } + ?? daemonActive[paneID].flatMap { tabIDs.contains($0) ? $0 : nil } + self = .leaf(paneID: paneID, tabIDs: tabIDs, activeTabID: active) + case .split(let horizontal, let ratio, let first, let second): + let first = LayoutNode(desired: first, panes: panes, daemonActive: daemonActive, cursor: &cursor) + let second = LayoutNode(desired: second, panes: panes, daemonActive: daemonActive, cursor: &cursor) + self = .split(splitID: nil, horizontal: horizontal, ratio: ratio, first: first, second: second) + } + } + + var leaves: [(paneID: String, tabIDs: [String])] { + switch self { + case .leaf(let paneID, let tabIDs, _): return [(paneID, tabIDs)] + case .split(_, _, _, let first, let second): return first.leaves + second.leaves + } + } + + var paneIDs: Set { Set(leaves.map(\.paneID)) } + + var activeTabs: [String: String] { + switch self { + case .leaf(let paneID, _, let active): return active.map { [paneID: $0] } ?? [:] + case .split(_, _, _, let first, let second): return first.activeTabs.merging(second.activeTabs) { current, _ in current } + } + } + + /// Split IDs keyed by the panes each split encloses, in document order. + var splits: [(id: String?, panes: Set)] { + switch self { + case .leaf: return [] + case .split(let id, _, _, let first, let second): + return [(id, paneIDs)] + first.splits + second.splits + } + } + + /// Reuses a daemon split wherever it encloses the same panes; the rest take the + /// remaining IDs. The daemon requires the exact set of the screen's split IDs. + func assigningSplitIDs(from daemon: LayoutNode) -> LayoutNode { + let daemonSplits = daemon.splits + let exact = Dictionary(daemonSplits.compactMap { split in split.id.map { (split.panes, $0) } }) { first, _ in first } + let reused = Set(splits.compactMap { exact[$0.panes] }) + var remaining = daemonSplits.compactMap(\.id).filter { !reused.contains($0) } + return withSplitIDs(exact: exact, remaining: &remaining) + } + + private func withSplitIDs(exact: [Set: String], remaining: inout [String]) -> LayoutNode { + switch self { + case .leaf: + return self + case .split(_, let horizontal, let ratio, let first, let second): + let id = exact[paneIDs] ?? (remaining.isEmpty ? nil : remaining.removeFirst()) + return .split( + splitID: id, horizontal: horizontal, ratio: ratio, + first: first.withSplitIDs(exact: exact, remaining: &remaining), + second: second.withSplitIDs(exact: exact, remaining: &remaining) + ) + } + } + + /// Structural equality ignoring split identity; ratios compare within a pixel's slack. + func matches(_ other: LayoutNode) -> Bool { + switch (self, other) { + case let (.leaf(pane, tabs, active), .leaf(otherPane, otherTabs, otherActive)): + return pane == otherPane && tabs == otherTabs && (active == nil || active == otherActive) + case let (.split(_, horizontal, ratio, first, second), .split(_, otherHorizontal, otherRatio, otherFirst, otherSecond)): + return horizontal == otherHorizontal + && abs(ratio - otherRatio) <= CloudLayoutSyncPlanner.ratioTolerance + && first.matches(otherFirst) && second.matches(otherSecond) + default: + return false + } + } + + var json: [String: Any] { + switch self { + case .leaf(let paneID, let tabIDs, let active): + var object: [String: Any] = ["kind": "leaf", "pane_id": paneID, "tab_ids": tabIDs] + if let active { object["active_tab_id"] = active } + return object + case .split(let splitID, let horizontal, let ratio, let first, let second): + return [ + "kind": "split", + "split_id": splitID ?? "", + "direction": horizontal ? "horizontal" : "vertical", + "ratio": min(0.95, max(0.05, ratio.isFinite ? ratio : 0.5)), + "first": first.json, + "second": second.json, + ] + } + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncStep.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncStep.swift new file mode 100644 index 000000000000..d4683b307192 --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncStep.swift @@ -0,0 +1,32 @@ +import Foundation + +/// The next daemon mutation that moves a machine workspace toward a ``CloudLayoutSyncTree``. +/// +/// ``CloudLayoutSyncPlanner`` produces one step from one snapshot. The caller performs +/// it, reads a fresh snapshot and plans again, so every step is fenced by the revision +/// it was planned against and a concurrent edit from another client is re-read, never +/// overwritten from a stale plan. +public enum CloudLayoutSyncStep: Equatable, Sendable { + /// The machine already matches the requested tree. + case done + /// Tab membership differs, for example while a created terminal has not been + /// projected yet. Syncing now could strand a tab, so the caller tries again later. + case notReady(String) + /// The workspace uses a shape this client does not write (several screens, + /// scrolling viewports or stacks). The machine's layout is left untouched. + case unsupported(String) + /// Moves one tab to `index` of an existing pane. Moving a pane's last tab away + /// removes that pane on the machine. + case moveTab(tabID: String, target: CloudTuiTerminalProjectionTarget) + /// Splits `paneID` so a native pane that has no machine pane gets one. The daemon + /// cannot create an empty pane, so the split starts a scratch terminal that a later + /// ``closeScratch(tabID:terminalID:)`` step removes. + case splitPane(paneID: String) + /// Closes a scratch terminal started by ``splitPane(paneID:)`` once its pane holds + /// a real tab or is no longer needed. + case closeScratch(tabID: String, terminalID: String) + /// Applies the whole layout document (tree shape, directions, ratios and selected + /// tabs) once pane and tab membership already match. The payload is the JSON + /// `LayoutDocument` for `workspace.layout.apply`. + case applyLayout(Data) +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncTree.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncTree.swift new file mode 100644 index 000000000000..49124101d89d --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudLayoutSyncTree.swift @@ -0,0 +1,85 @@ +import Foundation + +/// The split tree a native Cloud workspace shows, expressed in daemon tab IDs. +/// +/// A bound Cloud workspace mirrors one daemon workspace. When the user changes the +/// native arrangement (moves a tab to another pane, splits by drag, reorders tabs or +/// drags a divider), this value is what the machine must be converged to, so that the +/// daemon's layout document stays the durable record of the user's arrangement. +/// +/// ```swift +/// let tree = CloudLayoutSyncTree.split( +/// horizontal: true, ratio: 0.6, +/// first: .leaf(tabIDs: ["tab_a", "tab_b"], activeTabID: "tab_b"), +/// second: .leaf(tabIDs: ["tab_c"], activeTabID: nil) +/// ) +/// ``` +public indirect enum CloudLayoutSyncTree: Hashable, Sendable { + /// One pane with its tabs in tab-bar order. + /// + /// `activeTabID`, when present, is the tab the pane shows and must be one of `tabIDs`. + case leaf(tabIDs: [String], activeTabID: String?) + /// Two subtrees side by side (`horizontal`) or stacked, where `ratio` is the + /// first child's share of the split. + case split(horizontal: Bool, ratio: Double, first: CloudLayoutSyncTree, second: CloudLayoutSyncTree) + + /// The panes of the tree in document order, first leaf first. + public var leaves: [(tabIDs: [String], activeTabID: String?)] { + switch self { + case .leaf(let tabIDs, let activeTabID): + return [(tabIDs, activeTabID)] + case .split(_, _, let first, let second): + return first.leaves + second.leaves + } + } + + /// Whether two trees show the same arrangement. Ratios within `ratioTolerance` + /// are equal: native dividers round to pixels on every resize. + /// + /// - Parameters: + /// - other: The tree to compare with. + /// - ratioTolerance: The largest ratio difference still treated as equal. + /// - Returns: True when shape, directions, tab order and selection match. + public func isEquivalent(to other: CloudLayoutSyncTree, ratioTolerance: Double = 0.005) -> Bool { + switch (self, other) { + case let (.leaf(tabs, active), .leaf(otherTabs, otherActive)): + return tabs == otherTabs && active == otherActive + case let (.split(horizontal, ratio, first, second), .split(otherHorizontal, otherRatio, otherFirst, otherSecond)): + return horizontal == otherHorizontal && abs(ratio - otherRatio) <= ratioTolerance + && first.isEquivalent(to: otherFirst, ratioTolerance: ratioTolerance) + && second.isEquivalent(to: otherSecond, ratioTolerance: ratioTolerance) + default: + return false + } + } + + /// The tree restricted to `tabIDs`; a pane left empty collapses into its sibling. + func keeping(_ tabIDs: Set) -> CloudLayoutSyncTree? { + switch self { + case .leaf(let tabs, let active): + let kept = tabs.filter(tabIDs.contains) + guard !kept.isEmpty else { return nil } + return .leaf(tabIDs: kept, activeTabID: active.flatMap { kept.contains($0) ? $0 : nil }) + case .split(let horizontal, let ratio, let first, let second): + switch (first.keeping(tabIDs), second.keeping(tabIDs)) { + case let (first?, second?): return .split(horizontal: horizontal, ratio: ratio, first: first, second: second) + case let (only?, nil), let (nil, only?): return only + case (nil, nil): return nil + } + } + } + + /// The tree with `tabID` placed next to `anchor`, after it or before it. + func inserting(_ tabID: String, beside anchor: String, after: Bool) -> CloudLayoutSyncTree { + switch self { + case .leaf(var tabs, let active): + guard let index = tabs.firstIndex(of: anchor) else { return self } + tabs.insert(tabID, at: after ? index + 1 : index) + return .leaf(tabIDs: tabs, activeTabID: active) + case .split(let horizontal, let ratio, let first, let second): + return .split(horizontal: horizontal, ratio: ratio, + first: first.inserting(tabID, beside: anchor, after: after), + second: second.inserting(tabID, beside: anchor, after: after)) + } + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMGraphCompleteness.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMGraphCompleteness.swift new file mode 100644 index 000000000000..b258580bd75e --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMGraphCompleteness.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Validates that an accepted daemon graph has enough catalog metadata to reconcile panes. +public struct CloudVMGraphCompleteness: Sendable { + private struct View: Hashable { + let resource: SurfaceResourceID + let tab: String + let workspace: String + let screen: String? + let pane: String? + } + + private let incompleteWorkspaceIDs: Set + private let hasUnresolvedOwner: Bool + + /// Checks the live tab joins once for all workspaces in a daemon graph. + /// + /// - Parameters: + /// - state: The accepted daemon graph. + /// - resources: Catalog rows derived from that graph, including pending receipts. + /// + /// Terminal reverse references may retain old tab IDs after a detach or exit. + /// Only live rows in ``CloudVMState/tabs`` attest to current membership. + /// Construction is linear in the number of resources, views and live tabs; + /// each subsequent workspace check is constant time. + /// + /// ```swift + /// let completeness = CloudVMGraphCompleteness(state: state, resources: resources) + /// if completeness.isComplete(workspaceID: workspaceID) { + /// // Reconcile this workspace's panes from the accepted graph. + /// } + /// ``` + public init(state: CloudVMState, resources: [SurfaceResource]) { + var views = Set() + for resource in resources where resource.machine == state.machine { + for view in resource.remoteViews ?? [] { + views.insert(View(resource: resource.id, tab: view.tabID, workspace: view.workspace.id, + screen: view.screenID, pane: view.paneID)) + } + } + var incomplete = Set() + var unresolvedOwner = false + for tab in state.tabs { + guard let pane = state.lookupIndex.pane(id: tab.paneID), + let screen = state.lookupIndex.screen(id: pane.screenID), + state.lookupIndex.workspace(id: screen.workspaceID) != nil else { + unresolvedOwner = true + continue + } + let kind: SurfaceResourceKind + switch tab.contentKind { + case "terminal": kind = .terminal + case "browser": kind = .browser + case "display", "screen": kind = .display + default: + incomplete.insert(screen.workspaceID) + continue + } + let resourceID = SurfaceResourceID(machine: state.machine, kind: kind, key: tab.contentID) + if !views.contains(View(resource: resourceID, tab: tab.id, workspace: screen.workspaceID, + screen: screen.id, pane: pane.id)) { + incomplete.insert(screen.workspaceID) + } + } + incompleteWorkspaceIDs = incomplete + hasUnresolvedOwner = unresolvedOwner + } + + /// Returns whether a workspace has all the metadata needed to reconcile its live tabs. + /// + /// - Parameter workspaceID: The workspace to check, or nil to check the whole machine. + /// - Returns: False when live tab ownership or a catalog view is unresolved. + public func isComplete(workspaceID: String? = nil) -> Bool { + guard !hasUnresolvedOwner else { return false } + return workspaceID.map { !incompleteWorkspaceIDs.contains($0) } ?? incompleteWorkspaceIDs.isEmpty + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/CloudLayoutSyncPlannerTests.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/CloudLayoutSyncPlannerTests.swift new file mode 100644 index 000000000000..fa060f35eca6 --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/CloudLayoutSyncPlannerTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Testing +@testable import CmuxSurfaceCatalogModel + +/// A daemon screen that applies planner steps with the daemon's documented rules: +/// `tab.move` removes a pane that loses its last tab, `pane.split` adds a pane with a +/// fresh terminal, and `workspace.layout.apply` requires exact pane and split membership. +private struct FakeDaemonScreen { + indirect enum Node: Equatable { + case leaf(pane: String, tabs: [String], active: String?) + case split(id: String, horizontal: Bool, ratio: Double, first: Node, second: Node) + } + + var root: Node + var nextID = 0 + var closedTerminals: [String] = [] + var mutations = 0 + + var snapshot: [String: Any] { + [ + "cursor": ["generation": "g", "revision": String(mutations)], + "workspaces": [["id": "ws", "name": "ws"]], + "screens": [["id": "scr", "workspace_id": "ws", "layout": [ + "version": 1, "screen_id": "scr", "active_pane_id": leaves.first?.pane ?? "", + "zoomed_pane_id": NSNull(), "root": Self.json(root), + ]]], + "panes": leaves.map { ["id": $0.pane, "screen_id": "scr"] }, + "tabs": leaves.flatMap { leaf in + leaf.tabs.map { ["id": $0, "pane_id": leaf.pane, "content_kind": "terminal", "content_id": "term_" + $0] } + }, + ] + } + + var leaves: [(pane: String, tabs: [String])] { Self.leaves(root) } + + static func leaves(_ node: Node) -> [(pane: String, tabs: [String])] { + switch node { + case .leaf(let pane, let tabs, _): return [(pane, tabs)] + case .split(_, _, _, let first, let second): return leaves(first) + leaves(second) + } + } + + static func splitIDs(_ node: Node) -> Set { + guard case .split(let id, _, _, let first, let second) = node else { return [] } + return Set([id]).union(splitIDs(first)).union(splitIDs(second)) + } + + static func json(_ node: Node) -> [String: Any] { + switch node { + case .leaf(let pane, let tabs, let active): + var object: [String: Any] = ["kind": "leaf", "pane_id": pane, "tab_ids": tabs] + if let active { object["active_tab_id"] = active } + return object + case .split(let id, let horizontal, let ratio, let first, let second): + return ["kind": "split", "split_id": id, "direction": horizontal ? "horizontal" : "vertical", + "ratio": ratio, "first": json(first), "second": json(second)] + } + } + + static func node(_ object: [String: Any]) -> Node { + if object["kind"] as? String == "leaf" { + return .leaf(pane: object["pane_id"] as! String, tabs: object["tab_ids"] as! [String], + active: object["active_tab_id"] as? String) + } + return .split(id: object["split_id"] as! String, horizontal: object["direction"] as? String == "horizontal", + ratio: (object["ratio"] as! NSNumber).doubleValue, + first: node(object["first"] as! [String: Any]), second: node(object["second"] as! [String: Any])) + } + + /// Removes `tab` wherever it is, collapsing a pane left empty into its sibling. + static func removing(_ tab: String, from node: Node) -> Node? { + switch node { + case .leaf(let pane, var tabs, let active): + tabs.removeAll { $0 == tab } + return tabs.isEmpty ? nil : .leaf(pane: pane, tabs: tabs, active: active == tab ? tabs.first : active) + case .split(let id, let horizontal, let ratio, let first, let second): + switch (removing(tab, from: first), removing(tab, from: second)) { + case let (first?, second?): return .split(id: id, horizontal: horizontal, ratio: ratio, first: first, second: second) + case let (only?, nil), let (nil, only?): return only + case (nil, nil): return nil + } + } + } + + static func inserting(_ tab: String, into target: String, at index: Int, _ node: Node) -> Node { + switch node { + case .leaf(let pane, var tabs, let active): + guard pane == target else { return node } + tabs.insert(tab, at: min(index, tabs.count)) + return .leaf(pane: pane, tabs: tabs, active: active) + case .split(let id, let horizontal, let ratio, let first, let second): + return .split(id: id, horizontal: horizontal, ratio: ratio, + first: inserting(tab, into: target, at: index, first), + second: inserting(tab, into: target, at: index, second)) + } + } + + static func splitting(_ target: String, newPane: String, tab: String, split: String, _ node: Node) -> Node { + switch node { + case .leaf(let pane, _, _): + guard pane == target else { return node } + return .split(id: split, horizontal: true, ratio: 0.5, first: node, + second: .leaf(pane: newPane, tabs: [tab], active: tab)) + case .split(let id, let horizontal, let ratio, let first, let second): + return .split(id: id, horizontal: horizontal, ratio: ratio, + first: splitting(target, newPane: newPane, tab: tab, split: split, first), + second: splitting(target, newPane: newPane, tab: tab, split: split, second)) + } + } + + /// Performs one step; returns the scratch tab a split created. + mutating func perform(_ step: CloudLayoutSyncStep) throws -> String? { + mutations += 1 + switch step { + case .moveTab(let tab, let target): + let source = try #require(leaves.first { $0.tabs.contains(tab) }) + let old = try #require(source.tabs.firstIndex(of: tab)) + // Same-pane indexes name the insertion point before removal, as in the daemon. + let index = source.pane == target.paneID && target.index > old ? target.index - 1 : target.index + try #require(leaves.contains { $0.pane == target.paneID }) + root = try #require(Self.removing(tab, from: root)) + try #require(leaves.contains { $0.pane == target.paneID }, "tab.move emptied its own destination") + root = Self.inserting(tab, into: target.paneID, at: index, root) + return nil + case .splitPane(let pane): + nextID += 1 + let tab = "scratch\(nextID)" + root = Self.splitting(pane, newPane: "pane_new\(nextID)", tab: tab, split: "split_new\(nextID)", root) + return tab + case .closeScratch(let tab, let terminal): + closedTerminals.append(terminal) + root = try #require(Self.removing(tab, from: root)) + return nil + case .applyLayout(let data): + let document = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let next = Self.node(try #require(document["root"] as? [String: Any])) + #expect(Set(Self.leaves(next).map(\.pane)) == Set(leaves.map(\.pane)), "pane membership must match") + #expect(Self.splitIDs(next) == Self.splitIDs(root), "split membership must match") + #expect(Set(Self.leaves(next).flatMap(\.tabs)) == Set(leaves.flatMap(\.tabs)), "tab membership must match") + root = next + return nil + case .done, .notReady, .unsupported: + Issue.record("Unexpected terminal step \(step)") + return nil + } + } + + /// Runs the planner to completion, as the provider's sync loop does. + mutating func converge(to desired: CloudLayoutSyncTree, limit: Int = 40) throws -> CloudLayoutSyncStep { + var scratch = Set() + for _ in 0.. CloudLayoutSyncTree { + switch node { + case .leaf(_, let tabs, _): return .leaf(tabIDs: tabs, activeTabID: nil) + case .split(_, let horizontal, let ratio, let first, let second): + return .split(horizontal: horizontal, ratio: ratio, first: tree(first), second: tree(second)) + } + } +} + +private func leaf(_ tabs: String...) -> CloudLayoutSyncTree { .leaf(tabIDs: tabs, activeTabID: nil) } + +@Suite +struct CloudLayoutSyncPlannerTests { + /// The recorded issue #15770 shape: the machine still has one pane with four tabs, + /// while the user arranged them locally as three tabs beside one. + private var flatMachine: FakeDaemonScreen { + FakeDaemonScreen(root: .leaf(pane: "p1", tabs: ["a", "b", "c", "d"], active: "a")) + } + + @Test("A matching machine needs no mutation") + func alreadySynced() { + let daemon = FakeDaemonScreen(root: .split( + id: "s1", horizontal: true, ratio: 0.5, + first: .leaf(pane: "p1", tabs: ["a", "b", "c"], active: "a"), + second: .leaf(pane: "p2", tabs: ["d"], active: "d") + )) + let step = CloudLayoutSyncPlanner( + snapshot: daemon.snapshot, workspaceID: "ws", + desired: .split(horizontal: true, ratio: 0.502, first: leaf("a", "b", "c"), second: leaf("d")) + ).step + #expect(step == .done) + } + + @Test("A local drag-split of a multi-tab pane is written to the machine") + func localSplitConverges() throws { + var daemon = flatMachine + let desired = CloudLayoutSyncTree.split(horizontal: true, ratio: 0.62, first: leaf("a", "b", "c"), second: leaf("d")) + #expect(try daemon.converge(to: desired) == .done) + #expect(daemon.tree == desired) + // The placeholder shell that held the new pane open was closed, and only it. + #expect(daemon.closedTerminals == ["term_scratch1"]) + #expect(daemon.leaves.first?.pane == "p1", "the original pane keeps its identity") + } + + @Test("A tab moved between existing panes is written with tab.move") + func tabMoveBetweenPanes() throws { + var daemon = FakeDaemonScreen(root: .split( + id: "s1", horizontal: true, ratio: 0.5, + first: .leaf(pane: "p1", tabs: ["a", "b", "c"], active: "a"), + second: .leaf(pane: "p2", tabs: ["d"], active: "d") + )) + let desired = CloudLayoutSyncTree.split(horizontal: true, ratio: 0.5, first: leaf("a", "c"), second: leaf("d", "b")) + #expect(try daemon.converge(to: desired) == .done) + #expect(daemon.tree == desired) + #expect(daemon.closedTerminals.isEmpty) + #expect(daemon.mutations <= 2, "existing panes are reused with plain tab moves") + #expect(daemon.leaves.map(\.pane) == ["p1", "p2"]) + } + + @Test("Reorders, divider drags and direction changes converge", arguments: [ + CloudLayoutSyncTree.split(horizontal: true, ratio: 0.3, first: leaf("c", "a", "b"), second: leaf("d")), + CloudLayoutSyncTree.split(horizontal: false, ratio: 0.5, first: leaf("a", "b", "c"), second: leaf("d")), + CloudLayoutSyncTree.split(horizontal: true, ratio: 0.5, first: leaf("d"), second: leaf("a", "b", "c")), + ]) + func sameMembershipEdits(desired: CloudLayoutSyncTree) throws { + var daemon = FakeDaemonScreen(root: .split( + id: "s1", horizontal: true, ratio: 0.5, + first: .leaf(pane: "p1", tabs: ["a", "b", "c"], active: "a"), + second: .leaf(pane: "p2", tabs: ["d"], active: "d") + )) + #expect(try daemon.converge(to: desired) == .done) + #expect(daemon.tree == desired) + #expect(daemon.closedTerminals.isEmpty, "no scratch terminal for a same-membership edit") + } + + @Test("Dragging a pane's last tab away removes that machine pane") + func collapseByMovingLastTab() throws { + var daemon = FakeDaemonScreen(root: .split( + id: "s1", horizontal: true, ratio: 0.5, + first: .leaf(pane: "p1", tabs: ["a", "b"], active: "a"), + second: .leaf(pane: "p2", tabs: ["c"], active: "c") + )) + #expect(try daemon.converge(to: leaf("a", "b", "c")) == .done) + #expect(daemon.tree == leaf("a", "b", "c")) + #expect(daemon.leaves.map(\.pane) == ["p1"]) + } + + @Test("Nested asymmetric arrangements converge from a flat machine") + func nestedFromFlat() throws { + var daemon = FakeDaemonScreen(root: .leaf(pane: "p1", tabs: ["a", "b", "c", "d", "e"], active: "a")) + let desired = CloudLayoutSyncTree.split( + horizontal: true, ratio: 0.4, + first: leaf("a", "b"), + second: .split(horizontal: false, ratio: 0.7, first: leaf("c"), second: .split( + horizontal: true, ratio: 0.5, first: leaf("e"), second: leaf("d") + )) + ) + #expect(try daemon.converge(to: desired) == .done) + #expect(daemon.tree == desired) + #expect(daemon.closedTerminals.count == 3) + } + + @Test("A native tab the machine already closed is not written back") + func nativeOnlyTabIsDropped() throws { + var daemon = flatMachine + let desired = CloudLayoutSyncTree.split(horizontal: true, ratio: 0.5, first: leaf("a", "b", "c"), second: leaf("d", "closed")) + #expect(try daemon.converge(to: desired) == .done) + #expect(daemon.tree == .split(horizontal: true, ratio: 0.5, first: leaf("a", "b", "c"), second: leaf("d"))) + } + + @Test("A machine tab this Mac has not projected stays beside its neighbor") + func unprojectedMachineTabIsKept() throws { + // Another client created "x" after "b" while the user split "d" off locally. + var daemon = FakeDaemonScreen(root: .leaf(pane: "p1", tabs: ["a", "b", "x", "c", "d"], active: "a")) + let desired = CloudLayoutSyncTree.split(horizontal: true, ratio: 0.5, first: leaf("a", "b", "c"), second: leaf("d")) + #expect(try daemon.converge(to: desired) == .done) + #expect(daemon.tree == .split(horizontal: true, ratio: 0.5, first: leaf("a", "b", "x", "c"), second: leaf("d"))) + } + + @Test("A machine pane holding only unprojected tabs defers the sync") + func unprojectedPaneDefers() { + let daemon = FakeDaemonScreen(root: .split( + id: "s1", horizontal: true, ratio: 0.5, + first: .leaf(pane: "p1", tabs: ["a", "b"], active: "a"), + second: .leaf(pane: "p2", tabs: ["x"], active: "x") + )) + let step = CloudLayoutSyncPlanner(snapshot: daemon.snapshot, workspaceID: "ws", desired: leaf("b", "a")).step + guard case .notReady = step else { + Issue.record("Expected deferral, got \(step)") + return + } + } + + @Test("Tree equivalence tolerates pixel-rounded ratios only") + func equivalence() { + let base = CloudLayoutSyncTree.split(horizontal: true, ratio: 0.5, first: leaf("a"), second: leaf("b")) + #expect(base.isEquivalent(to: .split(horizontal: true, ratio: 0.503, first: leaf("a"), second: leaf("b")))) + #expect(!base.isEquivalent(to: .split(horizontal: true, ratio: 0.52, first: leaf("a"), second: leaf("b")))) + #expect(!base.isEquivalent(to: .split(horizontal: false, ratio: 0.5, first: leaf("a"), second: leaf("b")))) + #expect(!base.isEquivalent(to: .split(horizontal: true, ratio: 0.5, first: leaf("b"), second: leaf("a")))) + } + + @Test("Multi-screen and stacked workspaces are left untouched") + func unsupportedShapes() { + var snapshot = flatMachine.snapshot + var screens = snapshot["screens"] as! [[String: Any]] + screens.append(["id": "scr2", "workspace_id": "ws", "layout": screens[0]["layout"]!]) + snapshot["screens"] = screens + guard case .unsupported = CloudLayoutSyncPlanner(snapshot: snapshot, workspaceID: "ws", desired: leaf("a", "b", "c", "d")).step else { + Issue.record("Expected unsupported for several screens") + return + } + var stacked = flatMachine.snapshot + stacked["screens"] = [["id": "scr", "workspace_id": "ws", "layout": [ + "version": 1, "screen_id": "scr", "root": ["kind": "stack", "pane_ids": ["p1"]], + ]]] + guard case .unsupported = CloudLayoutSyncPlanner(snapshot: stacked, workspaceID: "ws", desired: leaf("a", "b", "c", "d")).step else { + Issue.record("Expected unsupported for a stack") + return + } + } + + @Test("The selected native tab is written as the pane's active tab") + func activeTabIsWritten() throws { + var daemon = flatMachine + #expect(try daemon.converge(to: .leaf(tabIDs: ["a", "b", "c", "d"], activeTabID: "c")) == .done) + guard case .leaf(_, _, let active) = daemon.root else { + Issue.record("Expected one pane") + return + } + #expect(active == "c") + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/CloudVMGraphCompletenessTests.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/CloudVMGraphCompletenessTests.swift new file mode 100644 index 000000000000..89ba6f74d6e5 --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/CloudVMGraphCompletenessTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import CmuxSurfaceCatalogModel + +@Suite +struct CloudVMGraphCompletenessTests { + private func state(detached: Bool = false, lifecycle: String = "running") throws -> CloudVMState { + let names = ["a", "b"] + let snapshot: [String: Any] = [ + "cursor": ["generation": "test", "revision": "1"], + "workspaces": names.map { ["id": $0, "name": $0] }, + "screens": names.map { ["id": "screen_" + $0, "workspace_id": $0] }, + "panes": names.map { ["id": "pane_" + $0, "screen_id": "screen_" + $0] }, + "tabs": names.filter { !detached || $0 != "b" }.map { + ["id": "tab_" + $0, "pane_id": "pane_" + $0, + "content_kind": "terminal", "content_id": "term_" + $0] + }, + // Detached/exited terminal records may retain an old reverse tab + // reference. The parser deliberately permits that wire format. + "terminals": names.map { + ["id": "term_" + $0, "tab_ids": ["tab_" + $0], "lifecycle": lifecycle] as [String: Any] + }, + "browsers": [], "agents": [] + ] + return try #require(CmuxTuiSnapshotParser.state(fromSnapshot: snapshot, machine: .cloud("complete-graph"))) + } + + @Test("Complete live placements admit reconciliation") + func completeGraph() throws { + let graph = try state() + let completeness = CloudVMGraphCompleteness(state: graph, resources: CmuxTuiSnapshotParser.resources(from: graph)) + #expect(completeness.isComplete()) + #expect(completeness.isComplete(workspaceID: "a")) + #expect(completeness.isComplete(workspaceID: "b")) + } + + @Test("Incomplete resource joins block only the affected workspace", arguments: [ + "resource", "views", "tab", "workspace", "screen", "pane", "machine" + ]) + func missingPlacement(field: String) throws { + let graph = try state() + var resources = CmuxTuiSnapshotParser.resources(from: graph) + let index = try #require(resources.firstIndex { $0.id.key == "term_b" }) + switch field { + case "resource": resources.remove(at: index) + case "views": resources[index].remoteViews = [] + case "tab": resources[index].remoteViews?[0].tabID = "another-tab" + case "workspace": resources[index].remoteViews?[0].workspace.id = "a" + case "screen": resources[index].remoteViews?[0].screenID = "screen_a" + case "pane": resources[index].remoteViews?[0].paneID = "pane_a" + case "machine": resources[index].id = .init(machine: .cloud("other"), kind: .terminal, key: "term_b") + default: Issue.record("Unexpected field") + } + let completeness = CloudVMGraphCompleteness(state: graph, resources: resources) + #expect(!completeness.isComplete()) + #expect(completeness.isComplete(workspaceID: "a")) + #expect(!completeness.isComplete(workspaceID: "b")) + } + + @Test("Detached or exited terminals do not hold unrelated workspaces open", arguments: ["running", "exited"]) + func detachedReverseReference(lifecycle: String) throws { + let graph = try state(detached: true, lifecycle: lifecycle) + #expect(graph.lookupIndex.tab(id: "tab_b") == nil) + #expect(graph.lookupIndex.terminal(id: "term_b")?.tabIDs == ["tab_b"]) + let completeness = CloudVMGraphCompleteness(state: graph, resources: CmuxTuiSnapshotParser.resources(from: graph)) + #expect(completeness.isComplete()) + #expect(completeness.isComplete(workspaceID: "b")) + } + + @Test("Each live view of a shared terminal is required") + func oneTerminalWithMultipleViews() throws { + var snapshot = try #require(state().snapshotObject()) + var tabs = try #require(snapshot["tabs"] as? [[String: Any]]) + tabs[1]["content_id"] = "term_a" + snapshot["tabs"] = tabs + snapshot["terminals"] = [["id": "term_a", "lifecycle": "running"]] + let graph = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: snapshot, machine: .cloud("complete-graph"))) + var resources = CmuxTuiSnapshotParser.resources(from: graph) + try #require(resources.count == 1) + #expect(CloudVMGraphCompleteness(state: graph, resources: resources).isComplete()) + resources[0].remoteViews?.removeAll { $0.workspace.id == "b" } + let completeness = CloudVMGraphCompleteness(state: graph, resources: resources) + #expect(completeness.isComplete(workspaceID: "a")) + #expect(!completeness.isComplete(workspaceID: "b")) + } +} diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper index f0d19e84380d..9e6c3a7c0a3d 100755 --- a/Resources/bin/cmux-codex-wrapper +++ b/Resources/bin/cmux-codex-wrapper @@ -261,6 +261,14 @@ cmux_computer_use_resolve_client() { # unless it is the exact app-installed cmux-cua helper selected by cmux. local installed_client client_real installed_client="${CMUX_CUA_CLIENT_PATH:-}" + # Older app launches and CI-hosted wrapper invocations may not inherit the + # app's exported client path. Reconstruct the same app-owned location from + # the runtime scope, then apply the identical canonicalization and trust + # checks below. Never fall back to an untrusted PATH lookup. + if [[ -z "$installed_client" && "${HOME:-}" = /* ]]; then + local runtime_scope="${CMUX_CUA_RUNTIME_SCOPE:-${CMUX_TAG:-default}}" + installed_client="${HOME}/Library/Application Support/cmux/cmux-cua/helper/${runtime_scope}/cmux Computer Use.app/Contents/MacOS/cmux-cua" + fi [[ -n "$installed_client" ]] || return 1 client_real="$(cmux_computer_use_canonical_executable "$installed_client")" || return 1 cmux_computer_use_trusted_client_path "$client_real" || return 1 diff --git a/Sources/Surfaces/CloudPlacementCoordinator.swift b/Sources/Surfaces/CloudPlacementCoordinator.swift index a3818e9d1d58..859f3b75668b 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator.swift @@ -182,6 +182,15 @@ final class CloudPlacementCoordinator { let pane = state.lookupIndex.pane(id: tab.paneID), let screen = state.lookupIndex.screen(id: pane.screenID), projection.remoteWorkspaceID != screen.workspaceID else { continue } + // Do not adopt a tab's new workspace until the destination has a + // complete resource inventory. The projection coordinator uses the + // same fence before retiring or recreating panes, so updating the + // remote coordinate here first would make an incomplete move look + // accepted and lose the source projection. + guard CloudVMGraphCompleteness( + state: state, + resources: catalog.snapshot.resources(on: state.machine) + ).isComplete(workspaceID: screen.workspaceID) else { continue } var updated = projection updated.remoteWorkspaceID = screen.workspaceID replacements[projection] = updated diff --git a/Sources/Surfaces/CloudWorkspaceLayoutSyncCoordinator.swift b/Sources/Surfaces/CloudWorkspaceLayoutSyncCoordinator.swift new file mode 100644 index 000000000000..878f73ea0915 --- /dev/null +++ b/Sources/Surfaces/CloudWorkspaceLayoutSyncCoordinator.swift @@ -0,0 +1,133 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel +import Foundation + +/// Writes native Cloud workspace arrangements to their machine. +/// +/// The machine's layout document is the durable record of a bound workspace: the +/// projection coordinator re-derives the native split tree from it on every graph +/// change, restore and reconnect. A native edit (tab dragged to another pane, a split +/// by drag, a reorder, a divider drag) is therefore written back here, and native +/// reconciliation for that machine is suspended from the edit until the machine has +/// accepted it, so an older graph can never re-apply the arrangement being replaced. +/// +/// Only user edits are written. Each workspace keeps a baseline: the tree the machine's +/// layout last produced natively, or the tree last written. Resizes, programmatic +/// changes and restored-but-unreconciled trees match or lack a baseline and write +/// nothing, so they cannot overwrite a newer arrangement made by another client. +@MainActor +final class CloudWorkspaceLayoutSyncCoordinator { + private struct Entry { + var machine: SurfaceMachineID + var remoteWorkspaceID: String + var token: UUID + var desired: @MainActor () -> CloudLayoutSyncTree? + var generation = 0 + } + + /// Coalesces a divider drag or a burst of tab moves into one write. + var debounce: Duration = .milliseconds(150) + private var entries: [UUID: Entry] = [:] + private var tasks: [UUID: Task] = [:] + private var baselines: [UUID: CloudLayoutSyncTree] = [:] + /// Most recent result per workspace, for diagnostics and tests. + private(set) var outcomes: [UUID: CloudLayoutSyncStep] = [:] + + /// Records that the native arrangement of `workspaceID` changed. Call synchronously + /// from the edit so a graph event queued behind it cannot win the race. + /// + /// - Parameter desired: Reads the native tree of daemon-backed tabs at write time. + func layoutDidChange( + workspaceID: UUID, + machine: SurfaceMachineID, + remoteWorkspaceID: String, + catalog: SurfaceCatalog, + desired: @escaping @MainActor () -> CloudLayoutSyncTree? + ) { + if var entry = entries[workspaceID], entry.machine == machine, entry.remoteWorkspaceID == remoteWorkspaceID { + entry.desired = desired + entry.generation += 1 + entries[workspaceID] = entry + return + } + guard isUserEdit(desired(), workspaceID: workspaceID) else { return } + if let previous = entries[workspaceID] { finish(workspaceID, token: previous.token, catalog: catalog) } + let token = catalog.cloudWorkspaceProjectionCoordinator.beginLocalMutation(on: machine) + entries[workspaceID] = Entry(machine: machine, remoteWorkspaceID: remoteWorkspaceID, token: token, desired: desired) + tasks[workspaceID] = Task { @MainActor [weak self, weak catalog] in + guard let self, let catalog else { return } + await self.run(workspaceID, catalog: catalog) + self.finish(workspaceID, token: token, catalog: catalog) + } + } + + /// The machine's arrangement now shows natively as `tree`; later native changes are + /// compared with it. + func machineLayoutApplied(workspaceID: UUID, tree: CloudLayoutSyncTree?) { + baselines[workspaceID] = tree + } + + /// Forgets a closed or unbound workspace and releases its reconciliation hold. + func cancel(workspaceID: UUID, catalog: SurfaceCatalog) { + baselines[workspaceID] = nil + outcomes[workspaceID] = nil + if let entry = entries[workspaceID] { finish(workspaceID, token: entry.token, catalog: catalog) } + } + + func waitForIdle() async { + for task in Array(tasks.values) { await task.value } + } + + /// A tree differs from what the machine last produced here. Without a baseline the + /// workspace has not reconciled since launch or reconnect, and its tree is not an edit. + private func isUserEdit(_ tree: CloudLayoutSyncTree?, workspaceID: UUID) -> Bool { + guard let tree, let baseline = baselines[workspaceID] else { return false } + return !tree.isEquivalent(to: baseline) + } + + private func run(_ workspaceID: UUID, catalog: SurfaceCatalog) async { + var written = -1 + while let entry = entries[workspaceID], entry.generation != written { + let generation = entry.generation + try? await Task.sleep(for: debounce) + guard !Task.isCancelled, let current = entries[workspaceID] else { return } + // Another edit landed while waiting: wait for the burst to finish. + guard current.generation == generation else { continue } + // A closed or unbound workspace has nothing left to record. + guard let binding = catalog.cloudWorkspaceProjectionCoordinator.environment.bindings()[workspaceID], + binding.vmID == current.machine.rawValue, + binding.remoteWorkspaceID == current.remoteWorkspaceID else { return } + written = generation + let tree = current.desired() + guard let tree, isUserEdit(tree, workspaceID: workspaceID) else { continue } + let step: CloudLayoutSyncStep + if let state = catalog.cloudStates[current.machine], let snapshot = state.snapshotObject(), + CloudLayoutSyncPlanner(snapshot: snapshot, workspaceID: current.remoteWorkspaceID, desired: tree).step == .done { + step = .done + } else if let provider = catalog.provider(for: current.machine) as? any SurfaceWorkspaceLayoutSyncing { + do { + step = try await provider.syncWorkspaceLayout(tree, remoteWorkspaceID: current.remoteWorkspaceID) + } catch is CancellationError { + return + } catch { + step = .notReady(CloudMachineLink.errorText(error)) + } + } else { + return + } + outcomes[workspaceID] = step +#if DEBUG + cmuxDebugLog("cloudWorkspace.layoutSync workspace=\(workspaceID) remote=\(current.remoteWorkspaceID) step=\(step)") +#endif + if step == .done { baselines[workspaceID] = tree } + } + } + + /// Releases exactly the hold `token` names; a superseded task cannot end its successor. + private func finish(_ workspaceID: UUID, token: UUID, catalog: SurfaceCatalog) { + guard let entry = entries[workspaceID], entry.token == token else { return } + entries[workspaceID] = nil + tasks.removeValue(forKey: workspaceID)?.cancel() + catalog.cloudWorkspaceProjectionCoordinator.endLocalMutation(entry.token, on: entry.machine, catalog: catalog) + } +} diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index e3b64547672f..1f0b2da05989 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -145,6 +145,7 @@ final class CloudWorkspaceProjectionCoordinator { private func reconcile(state: CloudVMState, catalog: SurfaceCatalog) async { let machine = state.machine + let completeness = CloudVMGraphCompleteness(state: state, resources: catalog.snapshot.resources(on: machine)) for (workspaceID, binding) in environment.bindings() where binding.vmID == machine.rawValue { guard let remoteID = binding.remoteWorkspaceID else { continue } if catalog.cloudWorkspaceCreationCoordinator.isPending(localWorkspaceID: workspaceID) { continue } @@ -158,6 +159,12 @@ final class CloudWorkspaceProjectionCoordinator { if !Task.isCancelled { requested.insert(machine) } return } + // A move is a cross-workspace mutation. Do not let a complete source + // workspace retire the projection while the destination inventory is + // still incomplete and cannot reconcile the same tab yet. + guard completeness.isComplete() else { + continue + } let group = try? catalog.remoteWorkspaceGroup(machine: machine, workspaceID: remoteID) let desired = (group?.placements ?? []).filter { !catalog.cloudPlacementCoordinator.isPendingClose($0, on: machine) @@ -188,9 +195,11 @@ final class CloudWorkspaceProjectionCoordinator { environment.close(projection) catalog.endProjections(panelID: projection.panelID, reason: .replaced) } - if let layout = catalog.cloudWorkspaceLayout(machine: machine, workspaceID: remoteID), !desired.isEmpty { + let daemonDesired = desired.filter { $0.remoteTabID != nil } + if let layout = catalog.cloudWorkspaceLayout(machine: machine, workspaceID: remoteID), !daemonDesired.isEmpty, + Set(daemonDesired).isSubset(of: Set(layout.placements)) { let live = catalog.projections.filter { $0.workspaceID == workspaceID && $0.resource.machine == machine } - environment.applyLayout(workspaceID, layout.includingMissingPlacements(desired), Array(live)) + environment.applyLayout(workspaceID, layout, Array(live)) } failures[workspaceID] = nil } catch is CancellationError { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutSync.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutSync.swift new file mode 100644 index 000000000000..d607cf75b5e9 --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutSync.swift @@ -0,0 +1,124 @@ +import CmuxCloud +import CmuxCloudTui +import CmuxSurfaceCatalogModel +import Foundation + +/// Writes a native workspace arrangement to the machine, one revision-fenced step at a time. +@MainActor +extension CmuxTuiSurfaceProvider: SurfaceWorkspaceLayoutSyncing { + /// Enough for a full rearrangement of a large workspace; a plan that has not + /// converged by then is fighting a concurrent writer and yields to it. + static let layoutSyncStepLimit = 64 + /// Native reconciliation for the whole machine waits on this sync, so a slow + /// link yields instead of holding every bound workspace. + static let layoutSyncDeadline: Duration = .seconds(20) + + func syncWorkspaceLayout(_ desired: CloudLayoutSyncTree, remoteWorkspaceID: String) async throws -> CloudLayoutSyncStep { + let connected = try await links.connected(machineID: machineID) + guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + let deadline = ContinuousClock.now.advanced(by: Self.layoutSyncDeadline) + // Scratch terminals only hold a new pane open; none may outlive this sync. + var scratch: [String: String] = [:] + var mutated = false + var outcome: CloudLayoutSyncStep = .notReady("the machine did not converge") + do { + steps: for _ in 0.. (tabID: String, terminalID: String) { + var lastError: Error = ProviderError.terminalNotCreated(paneID) + for _ in 0..<3 { + do { + let response = try await link.run(arguments: request) + if let object = try? JSONSerialization.jsonObject(with: response) as? [String: Any], + let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: object), + let tabID = created.tabID { + return (tabID, created.terminalID) + } + } catch { + // A revision conflict proves nothing committed; anything else may have. + if Self.isRevisionConflict(error) || error is CancellationError { throw error } + lastError = error + } + } + throw lastError + } + + /// Closes scratch terminals even when the sync was cancelled: an unstructured task + /// does not inherit cancellation, and a leaked shell would become a real tab. + private static func closeScratchTerminals(_ terminals: [String], link: CloudMachineLink, socketPath: String) async { + guard !terminals.isEmpty else { return } + await Task { + for terminalID in terminals { + _ = try? await link.run(arguments: CloudTuiRequests.closeTerminalArguments(socketPath: socketPath, terminalID: terminalID)) + } + }.value + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 6314051d6387..035158b65973 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -911,15 +911,21 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// the machine is unreachable, not that the terminal ended. private func closePanesForVanishedRemoteTerminals(observation: CloudVMStateObservation) { guard !manualMirrorSessions.isEmpty else { return } - let live = Set( - catalog.authoritativeSnapshot.resources(on: machine) - .filter { $0.id.kind == .terminal } - .map(\.id.key) - ) + let resources = catalog.authoritativeSnapshot.resources(on: machine) + let live = Set(resources.filter { $0.kind == .terminal }.map(\.id.key)) + let boundTerminalIDs = Set(manualMirrorSessions.values.map(\.terminalID)) + let hasMissingTerminalCandidate = !boundTerminalIDs.isSubset(of: live) + // A complete-graph scan is only needed when cleanup has a terminal it + // might close. Row-local publications can retire pending overlays, so a + // cached completeness result would be stale precisely in this case. + let graphComplete = !hasMissingTerminalCandidate || cloudState.map { + CloudVMGraphCompleteness(state: $0, resources: resources).isComplete() + } ?? false let closing = CloudTerminalPaneClosure.panelsToClose( boundTerminals: manualMirrorSessions.mapValues(\.terminalID), liveTerminalKeys: live, - freshness: observation.freshness + freshness: observation.freshness, + graphComplete: graphComplete ) for panelID in closing { guard let terminalID = manualMirrorSessions[panelID]?.terminalID else { continue } diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 07e3fee812ac..b1d94cd94d07 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -55,6 +55,8 @@ final class SurfaceCatalog { @ObservationIgnored lazy var cloudWorkspaceCreationCoordinator = CloudWorkspaceCreationCoordinator(catalog: self) let cloudWorkspaceProjectionCoordinator: CloudWorkspaceProjectionCoordinator + /// Writes native Cloud arrangements back to the machine's layout document. + let cloudWorkspaceLayoutSyncCoordinator = CloudWorkspaceLayoutSyncCoordinator() /// Optimistic Cloud workspace deletes are catalog state so every sidebar and /// socket reader sees the same pending/tombstoned tree. let cloudWorkspaceDeletionLedger = CloudWorkspaceDeletionLedger() diff --git a/Sources/Surfaces/SurfaceWorkspaceLayoutSyncing.swift b/Sources/Surfaces/SurfaceWorkspaceLayoutSyncing.swift new file mode 100644 index 000000000000..83d3e04b250e --- /dev/null +++ b/Sources/Surfaces/SurfaceWorkspaceLayoutSyncing.swift @@ -0,0 +1,14 @@ +import CmuxSurfaceCatalogModel +import Foundation + +/// A provider that can record a native workspace's split tree on its machine. +/// +/// A bound Cloud workspace is re-derived from the machine's layout document whenever +/// its graph changes. A local arrangement that never reaches the machine is therefore +/// undone by the next unrelated update, so every native layout edit is written here. +@MainActor +protocol SurfaceWorkspaceLayoutSyncing: AnyObject { + /// Converges `remoteWorkspaceID` to `desired` and returns the final planner step: + /// ``CloudLayoutSyncStep/done`` on success, or the reason nothing (more) was written. + func syncWorkspaceLayout(_ desired: CloudLayoutSyncTree, remoteWorkspaceID: String) async throws -> CloudLayoutSyncStep +} diff --git a/Sources/Surfaces/Workspace+CloudLayoutProjection.swift b/Sources/Surfaces/Workspace+CloudLayoutProjection.swift index b2bc26d8240d..91fdf50aa1ae 100644 --- a/Sources/Surfaces/Workspace+CloudLayoutProjection.swift +++ b/Sources/Surfaces/Workspace+CloudLayoutProjection.swift @@ -1,5 +1,6 @@ import Bonsplit import CmuxSurfaceCatalogModel +import CmuxPanes import Foundation @MainActor @@ -15,21 +16,50 @@ extension Workspace { projections.allSatisfy({ $0.remoteTabID != nil }), Set(projections.map(\.panelID)) == Set(panels.keys) else { return } var tabs: [SurfaceResourcePlacement: TabID] = [:] + var panelIDs: [SurfaceResourcePlacement: UUID] = [:] for projection in projections { guard let tab = surfaceIdFromPanelId(projection.panelID) else { return } - tabs[SurfaceResourcePlacement(resource: projection.resource, remoteWorkspaceID: projection.remoteWorkspaceID, - remoteTabID: projection.remoteTabID)] = tab + let placement = SurfaceResourcePlacement( + resource: projection.resource, + remoteWorkspaceID: projection.remoteWorkspaceID, + remoteTabID: projection.remoteTabID + ) + tabs[placement] = tab + panelIDs[placement] = projection.panelID } + guard tabs.count == projections.count else { return } + // A layout document is lossy when the daemon has published a tab before + // its resource inventory (or while a delta is still being assembled). Do + // not let that partial document turn a valid local split into a flat pane. + // The next complete graph will retry the same projection. A complete + // one-pane document remains valid and is allowed to represent a real + // remote collapse. + let expectedPlacements = Set(projections.map { + SurfaceResourcePlacement( + resource: $0.resource, + remoteWorkspaceID: $0.remoteWorkspaceID, + remoteTabID: $0.remoteTabID + ) + }) + guard expectedPlacements.isSubset(of: Set(layout.placements)) else { return } guard layout.placements.allSatisfy({ tabs[$0] != nil }) else { return } + guard let sessionLayout = sessionLayout(for: layout, panelIDs: panelIDs) else { return } if cloudLayoutMatches(layout, live: bonsplitController.treeSnapshot(), tabs: tabs) { // External ratios suppress Bonsplit's geometry callback. Reconcile // AppKit and Ghostty even when the terminal membership is unchanged. if applyCloudDividerRatios(layout, live: bonsplitController.treeSnapshot()) { scheduleTerminalGeometryReconcile() } + recordCloudLayoutBaseline(projections) return } + // Selecting a tab also focuses its pane. Without a focused panel, the focused + // pane's visible tab is what the user was looking at and must end focused. let focused = focusedPanelId.flatMap { surfaceIdFromPanelId($0) } + ?? bonsplitController.focusedPaneId.flatMap { bonsplitController.selectedTab(inPane: $0)?.id } + // The codec regroups tabs by moving them, which changes each pane's selection. + // Every pane keeps the tab the user was looking at, not only the focused one. + let selected = Set(bonsplitController.allPaneIds.compactMap { bonsplitController.selectedTab(inPane: $0)?.id }) // The existing remote-projection transaction preserves window/workspace // focus and suppresses activation while tabs move. It is shared with SSH. performRemoteTmuxMirrorMutation { @@ -37,22 +67,60 @@ extension Workspace { let wasProgrammatic = isProgrammaticSplit isProgrammaticSplit = true defer { isProgrammaticSplit = wasProgrammatic } - guard let root = bonsplitController.allPaneIds.first else { return } - let originalRootTabs = Set(bonsplitController.tabs(inPane: root).map(\.id)) - for placement in layout.placements { - guard let tab = tabs[placement] else { continue } - if !originalRootTabs.contains(tab) { - _ = bonsplitController.moveTab(tab, toPane: root) + _ = SessionSplitContainerLayoutCodec(controller: bonsplitController).restoreExistingLayout( + sessionLayout, + panelIDMap: [:], + tabIDForPanelID: surfaceIdFromPanelId + ) + for pane in bonsplitController.allPaneIds { + if let tab = bonsplitController.tabs(inPane: pane).first(where: { selected.contains($0.id) }) { + bonsplitController.selectTab(tab.id) } } - buildCloudLayout(layout, in: root, tabs: tabs) - applyCloudDividerRatios(layout, live: bonsplitController.treeSnapshot()) if let focused, bonsplitController.tab(focused) != nil { bonsplitController.selectTab(focused) } } } + recordCloudLayoutBaseline(projections) scheduleTerminalGeometryReconcile() } + /// The machine's arrangement now shows natively; later native edits are measured from it. + private func recordCloudLayoutBaseline(_ projections: [SurfaceProjection]) { + guard let machine = projections.first?.resource.machine else { return } + SurfaceCatalog.shared.cloudWorkspaceLayoutSyncCoordinator.machineLayoutApplied( + workspaceID: id, tree: cloudLayoutSyncTree(projections: projections, machine: machine) + ) + } + + private func sessionLayout( + for layout: SurfaceProjectionLayout, + panelIDs: [SurfaceResourcePlacement: UUID] + ) -> SessionWorkspaceLayoutSnapshot? { + switch layout { + case .leaf(let placements): + let ids = placements.compactMap { panelIDs[$0] } + guard ids.count == placements.count else { return nil } + let fullWidth = placements.first + .flatMap { panelIDs[$0] } + .flatMap { paneId(forPanelId: $0) } + .map { bonsplitController.isFullWidthTabMode(inPane: $0) } + return .pane(SessionPaneLayoutSnapshot( + panelIds: ids, + selectedPanelId: nil, + isFullWidthTabMode: fullWidth + )) + case .split(let direction, let ratio, let first, let second): + guard let first = sessionLayout(for: first, panelIDs: panelIDs), + let second = sessionLayout(for: second, panelIDs: panelIDs) else { return nil } + return .split(SessionSplitLayoutSnapshot( + orientation: direction == .right || direction == .left ? .horizontal : .vertical, + dividerPosition: ratio, + first: first, + second: second + )) + } + } + private func cloudLayoutMatches(_ layout: SurfaceProjectionLayout, live: ExternalTreeNode, tabs: [SurfaceResourcePlacement: TabID]) -> Bool { switch (layout, live) { @@ -66,25 +134,6 @@ extension Workspace { } } - private func buildCloudLayout(_ layout: SurfaceProjectionLayout, in pane: PaneID, - tabs: [SurfaceResourcePlacement: TabID]) { - switch layout { - case .leaf(let placements): - for (index, placement) in placements.enumerated() { - if let tab = tabs[placement] { _ = bonsplitController.moveTab(tab, toPane: pane, atIndex: index) } - } - case .split(let direction, _, let first, let second): - guard let placement = second.placements.first, let tab = tabs[placement], - let next = bonsplitController.splitPane(pane, orientation: direction == .right || direction == .left ? .horizontal : .vertical, - movingTab: tab, insertFirst: false) else { return } - for placement in second.placements.dropFirst() { - if let tab = tabs[placement] { _ = bonsplitController.moveTab(tab, toPane: next) } - } - buildCloudLayout(first, in: pane, tabs: tabs) - buildCloudLayout(second, in: next, tabs: tabs) - } - } - @discardableResult private func applyCloudDividerRatios(_ layout: SurfaceProjectionLayout, live: ExternalTreeNode) -> Bool { guard case .split(_, let ratio, let first, let second) = layout, case .split(let split) = live else { return false } diff --git a/Sources/Surfaces/Workspace+CloudLayoutSync.swift b/Sources/Surfaces/Workspace+CloudLayoutSync.swift new file mode 100644 index 000000000000..ffae7e48dc4a --- /dev/null +++ b/Sources/Surfaces/Workspace+CloudLayoutSync.swift @@ -0,0 +1,61 @@ +import Bonsplit +import CmuxSurfaceCatalogModel +import Foundation + +@MainActor +extension Workspace { + /// Writes a native arrangement change of a bound Cloud workspace to its machine. + /// + /// Called from every Bonsplit layout callback. Changes this workspace makes while + /// applying the machine's own layout are programmatic and are not echoed back. + func cloudLayoutDidChange() { + guard !isProgrammaticSplit, !isRemoteTmuxMirror, + let binding = cloudVMBinding, let remoteWorkspaceID = binding.remoteWorkspaceID else { return } + let machine = SurfaceMachineID(rawValue: binding.vmID) + let catalog = SurfaceCatalog.shared + guard !machine.isLocal, !machine.isDevice, catalog.cloudStates[machine] != nil, + catalog.provider(for: machine) is any SurfaceWorkspaceLayoutSyncing else { return } + catalog.cloudWorkspaceLayoutSyncCoordinator.layoutDidChange( + workspaceID: id, machine: machine, remoteWorkspaceID: remoteWorkspaceID, catalog: catalog + ) { [weak self] in + self?.cloudLayoutSyncTree(machine: machine, catalog: catalog) + } + } + + /// The native split tree of this workspace's daemon tabs. Local views (a Cloud + /// Desktop, a port preview, a pane still being created) are not the machine's to + /// arrange, so they are left out and a pane holding only them collapses. + func cloudLayoutSyncTree(machine: SurfaceMachineID, catalog: SurfaceCatalog) -> CloudLayoutSyncTree? { + cloudLayoutSyncTree(projections: panels.keys.compactMap { catalog.projection(forPanel: $0) }, machine: machine) + } + + /// The same tree from explicit projections, for callers that already hold them. + func cloudLayoutSyncTree(projections: [SurfaceProjection], machine: SurfaceMachineID) -> CloudLayoutSyncTree? { + var remoteTabs: [String: String] = [:] + for projection in projections where projection.workspaceID == id && projection.resource.machine == machine { + guard panels[projection.panelID] != nil, let tab = surfaceIdFromPanelId(projection.panelID), + let remoteTabID = projection.remoteTabID else { continue } + remoteTabs[tab.uuid.uuidString] = remoteTabID + } + return Self.cloudLayoutSyncTree(bonsplitController.treeSnapshot(), remoteTabs: remoteTabs) + } + + private static func cloudLayoutSyncTree(_ node: ExternalTreeNode, remoteTabs: [String: String]) -> CloudLayoutSyncTree? { + switch node { + case .pane(let pane): + let tabIDs = pane.tabs.compactMap { remoteTabs[$0.id] } + guard !tabIDs.isEmpty else { return nil } + return .leaf(tabIDs: tabIDs, activeTabID: pane.selectedTabId.flatMap { remoteTabs[$0] }) + case .split(let split): + switch (cloudLayoutSyncTree(split.first, remoteTabs: remoteTabs), + cloudLayoutSyncTree(split.second, remoteTabs: remoteTabs)) { + case let (first?, second?): + return .split(horizontal: split.orientation == "horizontal", ratio: split.dividerPosition, first: first, second: second) + case let (only?, nil), let (nil, only?): + return only + case (nil, nil): + return nil + } + } + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8caeaaf2b31b..f5603247a5d9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14598,6 +14598,7 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didReorderTabsInPane pane: PaneID, orderedTabIds: [TabID]) { + cloudLayoutDidChange() // A remote tmux mirror tab reorder propagates to tmux window order. // Mirror transactions send their desired order explicitly. Their local // mutations, including rollback and remote updates, must not echo it. @@ -14657,6 +14658,7 @@ extension Workspace: BonsplitDelegate { normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() + cloudLayoutDidChange() if !isDetachingCloseTransaction { scheduleFocusReconcile() } @@ -15239,6 +15241,8 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { let deviceLayoutExternal = remoteTmuxMirrorMutations.suppressesFocusActivation + // Before deferral: a graph event queued behind this edit must not re-apply the old tree. + if !deviceLayoutExternal { cloudLayoutDidChange() } // Capture the user's arrangement before deferred delivery: an incoming // remote snapshot must not replace the intent while this event waits. let deviceLayoutSnapshot = !deviceLayoutExternal diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 96eafe7ab068..0864fa7934e9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1085,6 +1085,7 @@ C12575000000000000000032 /* CloudWorkspaceDeletionLedgerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000033 /* CloudWorkspaceDeletionLedgerTests.swift */; }; 826E2F85AF36405A9A358B50 /* CloudWorkspaceDestinationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FF9A4B0D9B449959FCB5E54 /* CloudWorkspaceDestinationTests.swift */; }; 5D999EE2FD784319A73C3945 /* CloudWorkspaceGroupDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 097212CF5CB04F3B8D671C46 /* CloudWorkspaceGroupDestination.swift */; }; + C83CA1F0A3C5379B34208C9C /* CloudWorkspaceLayoutSyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31413C6407EE4BC234B66B6 /* CloudWorkspaceLayoutSyncCoordinator.swift */; }; 0B2534A7872AF46D59819D49 /* CloudWorkspaceLayoutTranslator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 183FE43D3F71A474726D6AA6 /* CloudWorkspaceLayoutTranslator.swift */; }; 341E0463C183CD4C0CB655DE /* CloudWorkspaceLayoutTranslatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCBB7EB399B58A6D076EAF1E /* CloudWorkspaceLayoutTranslatorTests.swift */; }; 04EA20A26351429BA2023F92 /* CloudWorkspaceLiveProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4088DB52FA564E2A8E670921 /* CloudWorkspaceLiveProjectionTests.swift */; }; @@ -1570,6 +1571,7 @@ 9F121ECF23BB326F3B0DAE77 /* CmuxTuiSurfaceProvider+GuestURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51835ECCEF581663B12E497 /* CmuxTuiSurfaceProvider+GuestURL.swift */; }; EA48BFF3E91706F21FEB02CF /* CmuxTuiSurfaceProvider+Hosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97A8A59948A6E78A1170AA3 /* CmuxTuiSurfaceProvider+Hosting.swift */; }; C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */; }; + 6F98C7D1EF8F3F6C1F44DDC1 /* CmuxTuiSurfaceProvider+LayoutSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8683148CAE5FA8638862B2D /* CmuxTuiSurfaceProvider+LayoutSync.swift */; }; F6F617E3D7DB4012B1032667 /* CmuxTuiSurfaceProvider+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71433FB540BA482FBBB90C12 /* CmuxTuiSurfaceProvider+Lifecycle.swift */; }; C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */; }; CF94B79529046C2B10388C90 /* CmuxTuiSurfaceProvider+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF71108A70863A2942D129D2 /* CmuxTuiSurfaceProvider+Notifications.swift */; }; @@ -3564,6 +3566,7 @@ 6F8CE4B13D081CF9C5C370B3 /* SurfaceSocketCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278BEFB3583AED9627966DFF /* SurfaceSocketCommands.swift */; }; 97003C82A6CDC90F8D20503F /* SurfaceSocketCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107EF903AAF714086F52BC4 /* SurfaceSocketCommandTests.swift */; }; C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; }; + 6CDB46F9F4D5380D8631CD23 /* SurfaceWorkspaceLayoutSyncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */; }; 9666A2019666A2019666A201 /* SwiftTestingAssertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9666A2029666A2029666A202 /* SwiftTestingAssertions.swift */; }; 372153DEDA910ADC47F88E87 /* SyntheticProcessSnapshotReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4BCCFBEFDD1E77E23E7D94 /* SyntheticProcessSnapshotReader.swift */; }; A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206A020000000000000001 /* SystemAppearanceObserver.swift */; }; @@ -4182,6 +4185,7 @@ E6F61F31711B4439A8C4C4A8 /* Workspace+CloudDisplayOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F78AF3938A4C58A82EA25C /* Workspace+CloudDisplayOwnership.swift */; }; C13304000000000000000003 /* Workspace+CloudDragSplitRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13304000000000000000004 /* Workspace+CloudDragSplitRouting.swift */; }; 4F2E42F6D7164832B891F079 /* Workspace+CloudLayoutProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 085BB53F552B4C6A8728941E /* Workspace+CloudLayoutProjection.swift */; }; + 504B5EA6BC93A7169B88B7BD /* Workspace+CloudLayoutSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55827284C72BF7D4B7E52559 /* Workspace+CloudLayoutSync.swift */; }; 6744319688BC4C31AA910FEE /* Workspace+CloudMachineLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FCC29018E3742939CBCAE90 /* Workspace+CloudMachineLoading.swift */; }; 3D7A10E43743B783CA690E1F /* Workspace+CloudMachineTeams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C07E63C17AB8028C71E77 /* Workspace+CloudMachineTeams.swift */; }; C11323010000000000000001 /* Workspace+CloudManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323020000000000000001 /* Workspace+CloudManualMirror.swift */; }; @@ -5590,6 +5594,7 @@ C12575000000000000000033 /* CloudWorkspaceDeletionLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWorkspaceDeletionLedgerTests.swift; sourceTree = ""; }; 0FF9A4B0D9B449959FCB5E54 /* CloudWorkspaceDestinationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWorkspaceDestinationTests.swift; sourceTree = ""; }; 097212CF5CB04F3B8D671C46 /* CloudWorkspaceGroupDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudWorkspaceGroupDestination.swift"; sourceTree = ""; }; + B31413C6407EE4BC234B66B6 /* CloudWorkspaceLayoutSyncCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudWorkspaceLayoutSyncCoordinator.swift; sourceTree = ""; }; 183FE43D3F71A474726D6AA6 /* CloudWorkspaceLayoutTranslator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudWorkspaceLayoutTranslator.swift; sourceTree = ""; }; FCBB7EB399B58A6D076EAF1E /* CloudWorkspaceLayoutTranslatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudWorkspaceLayoutTranslatorTests.swift; sourceTree = ""; }; 4088DB52FA564E2A8E670921 /* CloudWorkspaceLiveProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudWorkspaceLiveProjectionTests.swift"; sourceTree = ""; }; @@ -5957,6 +5962,7 @@ B51835ECCEF581663B12E497 /* CmuxTuiSurfaceProvider+GuestURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+GuestURL.swift"; sourceTree = ""; }; F97A8A59948A6E78A1170AA3 /* CmuxTuiSurfaceProvider+Hosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Surfaces/CmuxTuiSurfaceProvider+Hosting.swift"; sourceTree = ""; }; 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+LayoutCreation.swift"; sourceTree = ""; }; + E8683148CAE5FA8638862B2D /* CmuxTuiSurfaceProvider+LayoutSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+LayoutSync.swift"; sourceTree = ""; }; 71433FB540BA482FBBB90C12 /* CmuxTuiSurfaceProvider+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Lifecycle.swift"; sourceTree = ""; }; C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirror.swift"; sourceTree = ""; }; EF71108A70863A2942D129D2 /* CmuxTuiSurfaceProvider+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Notifications.swift"; sourceTree = ""; }; @@ -7915,6 +7921,7 @@ 278BEFB3583AED9627966DFF /* SurfaceSocketCommands.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceSocketCommands.swift; sourceTree = ""; }; E107EF903AAF714086F52BC4 /* SurfaceSocketCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceSocketCommandTests.swift; sourceTree = ""; }; C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = ""; }; + 72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceWorkspaceLayoutSyncing.swift; sourceTree = ""; }; 9666A2029666A2029666A202 /* SwiftTestingAssertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTestingAssertions.swift; sourceTree = ""; }; 6E4BCCFBEFDD1E77E23E7D94 /* SyntheticProcessSnapshotReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyntheticProcessSnapshotReader.swift"; sourceTree = ""; }; A7206A020000000000000001 /* SystemAppearanceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserver.swift; sourceTree = ""; }; @@ -8529,6 +8536,7 @@ 14F78AF3938A4C58A82EA25C /* Workspace+CloudDisplayOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudDisplayOwnership.swift"; sourceTree = ""; }; C13304000000000000000004 /* Workspace+CloudDragSplitRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudDragSplitRouting.swift"; sourceTree = ""; }; 085BB53F552B4C6A8728941E /* Workspace+CloudLayoutProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudLayoutProjection.swift"; sourceTree = ""; }; + 55827284C72BF7D4B7E52559 /* Workspace+CloudLayoutSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudLayoutSync.swift"; sourceTree = ""; }; 7FCC29018E3742939CBCAE90 /* Workspace+CloudMachineLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudMachineLoading.swift"; sourceTree = ""; }; 680C07E63C17AB8028C71E77 /* Workspace+CloudMachineTeams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudMachineTeams.swift"; sourceTree = ""; }; C11323020000000000000001 /* Workspace+CloudManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudManualMirror.swift"; sourceTree = ""; }; @@ -9388,6 +9396,10 @@ F1BE71D511FF27AB7D0279A8 /* TerminalController+CloudMachinePayload.swift */, E52470C2BDCDB44AA3AF4B48 /* CloudWorkspaceCreationReveals.swift */, 16F81541F06BB9C88654913B /* SurfaceCatalog+MachineDeletion.swift */, + B31413C6407EE4BC234B66B6 /* CloudWorkspaceLayoutSyncCoordinator.swift */, + E8683148CAE5FA8638862B2D /* CmuxTuiSurfaceProvider+LayoutSync.swift */, + 72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */, + 55827284C72BF7D4B7E52559 /* Workspace+CloudLayoutSync.swift */, 680C07E63C17AB8028C71E77 /* Workspace+CloudMachineTeams.swift */, D5E23BA1DDF8DA4B0BB766CA /* CloudDisplayMembershipSyncing.swift */, 981BED9BD8FFEE7B0CBFCD4B /* CloudPlacementCoordinator+CloudDisplayMembership.swift */, @@ -14684,6 +14696,7 @@ A0F71E4667E9DAACC2E73A03 /* CloudWorkspaceCreationReveals.swift in Sources */, C12575000000000000000030 /* CloudWorkspaceDeletionLedger.swift in Sources */, 5D999EE2FD784319A73C3945 /* CloudWorkspaceGroupDestination.swift in Sources */, + C83CA1F0A3C5379B34208C9C /* CloudWorkspaceLayoutSyncCoordinator.swift in Sources */, 0B2534A7872AF46D59819D49 /* CloudWorkspaceLayoutTranslator.swift in Sources */, 775ECED002DE499C96F792E7 /* CloudWorkspaceOperationController+TerminalNavigation.swift in Sources */, 15E5F36E592C4D2894AF93CF /* CloudWorkspaceOperationController.swift in Sources */, @@ -14798,6 +14811,7 @@ 9F121ECF23BB326F3B0DAE77 /* CmuxTuiSurfaceProvider+GuestURL.swift in Sources */, EA48BFF3E91706F21FEB02CF /* CmuxTuiSurfaceProvider+Hosting.swift in Sources */, C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */, + 6F98C7D1EF8F3F6C1F44DDC1 /* CmuxTuiSurfaceProvider+LayoutSync.swift in Sources */, F6F617E3D7DB4012B1032667 /* CmuxTuiSurfaceProvider+Lifecycle.swift in Sources */, C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */, CF94B79529046C2B10388C90 /* CmuxTuiSurfaceProvider+Notifications.swift in Sources */, @@ -16074,6 +16088,7 @@ DE0A507C430CF37E256D7E29 /* SurfaceSocketCommands+VMFileDelivery.swift in Sources */, 6F8CE4B13D081CF9C5C370B3 /* SurfaceSocketCommands.swift in Sources */, C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */, + 6CDB46F9F4D5380D8631CD23 /* SurfaceWorkspaceLayoutSyncing.swift in Sources */, A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */, A7206F010000000000000001 /* SystemAppearanceObserverEnvironment.swift in Sources */, C7B0FACE000000000000000C /* SystemCommandRunner.swift in Sources */, @@ -16546,6 +16561,7 @@ E6F61F31711B4439A8C4C4A8 /* Workspace+CloudDisplayOwnership.swift in Sources */, C13304000000000000000003 /* Workspace+CloudDragSplitRouting.swift in Sources */, 4F2E42F6D7164832B891F079 /* Workspace+CloudLayoutProjection.swift in Sources */, + 504B5EA6BC93A7169B88B7BD /* Workspace+CloudLayoutSync.swift in Sources */, 6744319688BC4C31AA910FEE /* Workspace+CloudMachineLoading.swift in Sources */, 3D7A10E43743B783CA690E1F /* Workspace+CloudMachineTeams.swift in Sources */, C11323010000000000000001 /* Workspace+CloudManualMirror.swift in Sources */, diff --git a/cmuxTests/CloudDirectoryLifecycleTests.swift b/cmuxTests/CloudDirectoryLifecycleTests.swift index ab61af4f5b80..1f9061871f3c 100644 --- a/cmuxTests/CloudDirectoryLifecycleTests.swift +++ b/cmuxTests/CloudDirectoryLifecycleTests.swift @@ -182,6 +182,49 @@ struct CloudDirectoryLifecycleTests { #expect(!text.contains("/home/cmux/second")) } + @Test("An expired creation overlay cannot close a pane while its live tab is unresolved") + func pendingCreationOverlayExpiryFencesPaneCleanup() throws { + let fixture = try CloudDirectoryTestFixture() + defer { fixture.close() } + let panel = fixture.panels[1] + let terminalID = fixture.resourceID(1) + let session = CloudTuiManualMirrorSession( + machineID: fixture.machine.rawValue, + terminalID: terminalID.key, + remoteSurfaceID: 0, + onNeedsReconnect: {} + ) + fixture.provider.manualMirrorSessions[panel] = session + defer { session.stop() } + + let resource = try #require(fixture.catalog.resources[terminalID]) + fixture.provider.pendingRemoteCreations[terminalID] = .init( + resource: resource, + receipt: .init(generation: "daemon", revision: 2), + tabID: "tab_1" + ) + var snapshot = try #require(fixture.provider.cloudState?.snapshotObject()) + snapshot["cursor"] = ["generation": "daemon", "revision": "2"] + // Keep the live tab row while omitting its terminal row. This is an + // incomplete accepted graph; the pending overlay is the only catalog + // row that had been making the placement look complete. + snapshot["terminals"] = [[ + "id": "term_0", "title": "bash", "lifecycle": "running" + ]] + let next = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: snapshot, machine: fixture.machine)) + #expect(fixture.provider.installSnapshotIfNewer(next)) + fixture.provider.publishDelta( + next, + impact: .init(resourceIDs: [fixture.resourceID(0)], requiresFullResourceRebuild: false), + ports: [], + reconcileTitles: false + ) + + #expect(fixture.catalog.resources[terminalID] == nil) + #expect(fixture.provider.manualMirrorSessions[panel] === session) + #expect(fixture.workspace.panels[panel] != nil) + } + @Test("Older and equal-cursor conflicting snapshots cannot overwrite a live cd") func outOfOrderReports() throws { let fixture = try CloudDirectoryTestFixture() diff --git a/cmuxTests/CloudMachinesHeaderCountTests.swift b/cmuxTests/CloudMachinesHeaderCountTests.swift index 95d36433a0a5..53c88312becd 100644 --- a/cmuxTests/CloudMachinesHeaderCountTests.swift +++ b/cmuxTests/CloudMachinesHeaderCountTests.swift @@ -55,8 +55,8 @@ struct CloudMachinesHeaderCountTests { #expect(inline <= Self.barContentWidth(420), "The header action row (\(inline)pt) overflows a 420pt sidebar") let overflow = try await idealRowWidth(.overflowMenu, teamName: "Team A") - #expect(overflow == inline, - "The Invite-only header should not change width between layout candidates") + #expect(overflow <= inline, + "The overflow menu should be no wider than the inline action row") } @Test("A free plan at its limit turns orange and names the upgrade", arguments: [ diff --git a/cmuxTests/CloudNativeLayoutProjectionTests.swift b/cmuxTests/CloudNativeLayoutProjectionTests.swift index 7b988a61363c..162fd60287ce 100644 --- a/cmuxTests/CloudNativeLayoutProjectionTests.swift +++ b/cmuxTests/CloudNativeLayoutProjectionTests.swift @@ -957,4 +957,55 @@ struct CloudNativeLayoutProjectionTests { #expect(workspace.bonsplitController.tabs(inPane: finalPane).map(\.id) == [third, second, first].compactMap { workspace.surfaceIdFromPanelId($0) }) #expect(Set(workspace.panels.keys) == originalPanels) } + + @Test("An incomplete Cloud layout cannot flatten an existing Bonsplit tree") + func incompleteCloudLayoutPreservesExistingTree() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try #require(manager.selectedWorkspace) + defer { for panel in workspace.panels.values { panel.close() }; manager.tabs = [] } + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let first = try #require(workspace.focusedPanelId) + var panels = [first] + for _ in 0..<3 { + panels.append(try #require(workspace.newTerminalSurface(inPane: pane, focus: false)?.id)) + } + let machine = SurfaceMachineID.cloud("incomplete-layout") + let projections = panels.enumerated().map { index, panel in + SurfaceProjection( + resource: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_\(index)"), + workspaceID: workspace.id, panelID: panel, + remoteWorkspaceID: "remote", remoteTabID: "tab_\(index)" + ) + } + let placements = projections.map { + SurfaceResourcePlacement( + resource: $0.resource, + remoteWorkspaceID: $0.remoteWorkspaceID, + remoteTabID: $0.remoteTabID + ) + } + let complete = SurfaceProjectionLayout.split( + direction: .right, ratio: 0.65, + first: .leaf(placements: Array(placements[0...1])), + second: .split( + direction: .down, ratio: 0.3, + first: .leaf(placements: [placements[2]]), + second: .leaf(placements: [placements[3]]) + ) + ) + workspace.applyCloudWorkspaceLayout(complete, projections: projections) + let before = workspace.bonsplitController.treeSnapshot() + + // This models a daemon snapshot whose resource inventory has not caught + // up with the right-most tab. Applying it would otherwise move every + // tab to the root and silently destroy the user's nested split. + let incomplete = SurfaceProjectionLayout.split( + direction: .right, ratio: 0.65, + first: .leaf(placements: Array(placements[0...1])), + second: .leaf(placements: [placements[2]]) + ) + workspace.applyCloudWorkspaceLayout(incomplete, projections: projections) + #expect(workspace.bonsplitController.treeSnapshot() == before) + #expect(workspace.bonsplitController.allPaneIds.count == 3) + } } diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index 19a67208b195..4d60ee0a1f1e 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -14,8 +14,14 @@ import WebKit struct CloudWorkspaceLiveProjectionTests { private let machine = SurfaceMachineID.cloud("live-fixture") - private func graph(_ placement: [String: String], revision: Int, generation: String = "live") throws -> CloudVMState { + private func graph( + _ placement: [String: String], + revision: Int, + generation: String = "live", + contentIDs: [String: String] = [:] + ) throws -> CloudVMState { let tabs = placement.keys.sorted() + let terminalIDs = Set(tabs.map { contentIDs[$0] ?? ($0 == "third" ? "term_other" : "term_shared") }).sorted() let document: [String: Any] = [ "cursor": ["generation": generation, "revision": String(revision)], "workspaces": ["a", "b"].enumerated().map { ["id": $0.element, "name": "Workspace " + $0.element, "index": $0.offset] as [String: Any] }, @@ -26,20 +32,29 @@ struct CloudWorkspaceLiveProjectionTests { "panes": ["a", "b"].map { ["id": "pane_" + $0, "screen_id": "screen_" + $0] }, "tabs": tabs.enumerated().map { index, id in ["id": id, "pane_id": "pane_" + placement[id]!, "name": "Name " + id, "index": index, - "content_kind": "terminal", "content_id": id == "third" ? "term_other" : "term_shared"] as [String: Any] + "content_kind": "terminal", "content_id": contentIDs[id] ?? (id == "third" ? "term_other" : "term_shared")] as [String: Any] }, - "terminals": ["term_shared", "term_other"].map { ["id": $0, "title": "Process " + $0, "lifecycle": "running"] }, + "terminals": terminalIDs.map { ["id": $0, "title": "Process " + $0, "lifecycle": "running"] }, "browsers": [], "agents": [] ] return try #require(CmuxTuiSnapshotParser.state(fromSnapshot: document, machine: machine)) } - private func install(_ state: CloudVMState, catalog: SurfaceCatalog, extraResources: [SurfaceResource] = []) { + private func install( + _ state: CloudVMState, + catalog: SurfaceCatalog, + extraResources: [SurfaceResource] = [], + resourceOverride: [SurfaceResource]? = nil + ) { let info = SurfaceMachineInfo(id: machine, name: "Fixture", status: "running", image: nil, hasDesktop: false, memoryMb: nil, diskMb: nil, linkState: .connected, linkError: nil, cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil, remoteWorkspaces: state.workspaces.map { SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) }) - catalog.replaceCloudState(state, resources: CmuxTuiSnapshotParser.resources(from: state) + extraResources, info: info) + catalog.replaceCloudState( + state, + resources: resourceOverride ?? (CmuxTuiSnapshotParser.resources(from: state) + extraResources), + info: info + ) catalog.reconcileCloudRemoteState(machine: machine, state: state) } @@ -221,6 +236,103 @@ struct CloudWorkspaceLiveProjectionTests { #expect(catalog.projections == [native]) } + @Test("A partial resource inventory does not retire a live Cloud projection") + func partialResourceInventoryDoesNotRetireProjection() async throws { + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let workspace = live.add() + let pane = try #require(workspace.bonsplitController.allPaneIds.first) + let firstPanel = try #require(workspace.focusedPanelId) + let missingPanel = try #require(workspace.newTerminalSurface(inPane: pane, focus: false)?.id) + let binding = WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "a") + var closed: [SurfaceProjection] = [] + let coordinator = CloudWorkspaceProjectionCoordinator(environment: .init( + bindings: { [workspace.id: binding] }, close: { closed.append($0) } + )) + let catalog = SurfaceCatalog( + live: live, + cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { _ in binding }), + cloudWorkspaceProjectionCoordinator: coordinator + ) + catalog.register(CloudPlacementTestProvider(machine: machine)) + let firstResource = SurfaceResourceID(machine: machine, kind: .terminal, key: "term_shared") + let missingResource = SurfaceResourceID(machine: machine, kind: .terminal, key: "term_missing") + catalog.record(SurfaceProjection( + resource: firstResource, workspaceID: workspace.id, panelID: firstPanel, + remoteWorkspaceID: "a", remoteTabID: "first" + )) + catalog.record(SurfaceProjection( + resource: missingResource, workspaceID: workspace.id, panelID: missingPanel, + remoteWorkspaceID: "a", remoteTabID: "missing" + )) + let state = try graph( + ["first": "a", "missing": "a"], revision: 1, + contentIDs: ["missing": "term_missing"] + ) + let resources = CmuxTuiSnapshotParser.resources(from: state).filter { $0.id != missingResource } + install(state, catalog: catalog, resourceOverride: resources) + await coordinator.waitForIdle() + #expect(closed.isEmpty) + #expect(catalog.projection(forPanel: missingPanel)?.remoteTabID == "missing") + + var missingViewResources = CmuxTuiSnapshotParser.resources(from: state) + let missingIndex = try #require(missingViewResources.firstIndex { $0.id == missingResource }) + missingViewResources[missingIndex].remoteViews = [] + install(state, catalog: catalog, resourceOverride: missingViewResources) + await coordinator.waitForIdle() + #expect(closed.isEmpty) + #expect(catalog.projection(forPanel: missingPanel)?.remoteTabID == "missing") + + // A subsequent complete graph may deliberately close the other view. + install(try graph(["first": "a"], revision: 2), catalog: catalog) + await coordinator.waitForIdle() + #expect(closed.map(\.panelID) == [missingPanel]) + #expect(catalog.projection(forPanel: missingPanel) == nil) + + } + + @Test("An incomplete destination inventory does not retire a moved projection") + func incompleteDestinationInventoryPreservesSourceProjection() async throws { + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let source = live.add() + let destination = live.add() + let bindings = [ + source.id: WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "a"), + destination.id: WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "b") + ] + var closed: [SurfaceProjection] = [] + let coordinator = CloudWorkspaceProjectionCoordinator(environment: .init( + bindings: { bindings }, close: { closed.append($0) } + )) + let catalog = SurfaceCatalog( + live: live, + cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { bindings[$0] }), + cloudWorkspaceProjectionCoordinator: coordinator + ) + catalog.register(CloudPlacementTestProvider(machine: machine)) + + install(try graph(["first": "a"], revision: 1), catalog: catalog) + await coordinator.waitForIdle() + let sourceProjection = try #require(catalog.projections.first { $0.remoteTabID == "first" }) + + let moved = try graph(["first": "b"], revision: 2) + var incompleteResources = CmuxTuiSnapshotParser.resources(from: moved) + let terminalIndex = try #require(incompleteResources.firstIndex { $0.id.kind == .terminal }) + incompleteResources[terminalIndex].remoteViews = [] + install(moved, catalog: catalog, resourceOverride: incompleteResources) + await coordinator.waitForIdle() + + #expect(closed.isEmpty) + #expect(catalog.projection(forPanel: sourceProjection.panelID)?.workspaceID == source.id) + #expect(catalog.projection(forPanel: sourceProjection.panelID)?.remoteWorkspaceID == "a") + + install(moved, catalog: catalog) + await coordinator.waitForIdle() + #expect(catalog.projection(forPanel: sourceProjection.panelID) == nil) + #expect(catalog.projections.contains { $0.remoteTabID == "first" && $0.workspaceID == destination.id }) + } + @Test("Opening one remote terminal repeatedly reuses its exact local projection") func openingOneTerminalRepeatedlyReusesProjection() async throws { let live = LiveWorkspaceFixture() diff --git a/cmuxTests/SSHDeepSleepReattachTests.swift b/cmuxTests/SSHDeepSleepReattachTests.swift index 6fc67e364949..0592f801a3fb 100644 --- a/cmuxTests/SSHDeepSleepReattachTests.swift +++ b/cmuxTests/SSHDeepSleepReattachTests.swift @@ -294,7 +294,12 @@ struct SSHDeepSleepReattachTests { detail: "Connected to Cloud VM", target: "cloud-vm" ) - #expect(workspace.reconnectRemoteConnection(surfaceId: panel.id)) + // The public reconnect command is correctly gated when Cloud is disabled + // on a CI runner. Exercise the persistent reattach operation directly. + #expect(workspace.reattachPersistentRemotePTYPanels( + requestedSurfaceId: panel.id, + restartEndedSessions: true + ).contains(panel.id)) let restarted = try #require(workspace.terminalPanel(for: panel.id)) #expect(restarted.surface !== panel.surface) diff --git a/tests/test_issue_10926_watcher_pid_reuse_guard.py b/tests/test_issue_10926_watcher_pid_reuse_guard.py index b50048813263..0f762d04f931 100644 --- a/tests/test_issue_10926_watcher_pid_reuse_guard.py +++ b/tests/test_issue_10926_watcher_pid_reuse_guard.py @@ -27,7 +27,7 @@ import subprocess import tempfile import time -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -135,7 +135,7 @@ def ps_epoch_start_time(pid: int, env: dict[str, str]) -> str: if len(words) != 5: return "" try: - started = datetime.strptime(" ".join(words), "%a %b %d %H:%M:%S %Y").replace(tzinfo=UTC) + started = datetime.strptime(" ".join(words), "%a %b %d %H:%M:%S %Y").replace(tzinfo=timezone.utc) except ValueError: return "" return str(int(started.timestamp()))