From 9bffba251740693e29349271bd8640f26cab3aa4 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 00:37:18 +1300 Subject: [PATCH 01/56] cloud sidebar: hide link tabs while a machine connects, line its connecting row up with new workspace, hover on the ports action - while a machine connects, ports, terminals and displays have nothing current, so the tab row keeps only resources (fleet telemetry) - the connecting spinner takes new workspace's chevron column - the ports status action (refresh, set up vpn) is a quiet rounded chip with the tree's fills; its title brightens and fill deepens on hover and press --- Sources/Cloud/CloudPortsStatusContent.swift | 78 +++++++++++++++++-- .../Cloud/CloudTreeMachineDetailLayout.swift | 11 +++ Sources/Cloud/CloudTreeNSOutlineView.swift | 13 ++++ .../CloudTreeMachineDetailLayoutTests.swift | 20 +++++ 4 files changed, 117 insertions(+), 5 deletions(-) diff --git a/Sources/Cloud/CloudPortsStatusContent.swift b/Sources/Cloud/CloudPortsStatusContent.swift index 89e0437d564f..837da14981f8 100644 --- a/Sources/Cloud/CloudPortsStatusContent.swift +++ b/Sources/Cloud/CloudPortsStatusContent.swift @@ -8,7 +8,7 @@ import CmuxSurfaceCatalogModel final class CloudPortsStatusContent: NSView { private let titleLabel = NSTextField(wrappingLabelWithString: "") private let messageLabel = NSTextField(wrappingLabelWithString: "") - private let actionButton = NSButton(title: "", target: nil, action: nil) + private let actionButton = CloudPortsStatusActionButton() private var presentation: CloudPortsStatusPresentation? private var style = CloudTreeStyle.defaultStyle private var actionHandler: (() -> Void)? @@ -29,7 +29,6 @@ final class CloudPortsStatusContent: NSView { messageLabel.maximumNumberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textColor = .secondaryLabelColor - actionButton.bezelStyle = .inline actionButton.target = self actionButton.action = #selector(performAction) actionButton.setAccessibilityRole(.button) @@ -53,7 +52,7 @@ final class CloudPortsStatusContent: NSView { actionHandler = action titleLabel.stringValue = presentation.title messageLabel.stringValue = presentation.message - actionButton.title = presentation.actionTitle ?? "" + actionButton.setTitle(presentation.actionTitle ?? "", fontSize: GlobalFontMagnification.scaledSize(max(10, style.detailSize))) actionButton.isHidden = presentation.action == .none || presentation.actionTitle == nil actionButton.setAccessibilityLabel(presentation.actionTitle ?? presentation.title) let fontSize = GlobalFontMagnification.scaledSize(max(10, style.detailSize)) @@ -78,13 +77,13 @@ final class CloudPortsStatusContent: NSView { if actionButton.isHidden { actionButton.frame = .zero } else { - // The inline bezel hugs its title; give it room on both sides. + // The button hugs its title; give it room on both sides. let padding = GlobalFontMagnification.scaledSize(Self.actionHorizontalPadding) * 2 actionButton.frame = NSRect(x: inset, y: messageLabel.frame.maxY + 4, width: min(width - inset * 2, actionButton.fittingSize.width + padding), height: 22) } } - private static let actionHorizontalPadding: CGFloat = 8 + private static let actionHorizontalPadding: CGFloat = 10 override var intrinsicContentSize: NSSize { guard let presentation else { return NSSize(width: NSView.noIntrinsicMetric, height: 0) } @@ -123,3 +122,72 @@ final class CloudPortsStatusContent: NSView { @objc private func performAction() { actionHandler?() } } + +/// The Ports status action ("Refresh", "Set Up VPN…"): a quiet rounded chip +/// in the Cloud tree's shape and fills (`CloudTreeHoverStyle`). Its title +/// brightens and its fill deepens under the pointer, and again while pressed. +@MainActor +final class CloudPortsStatusActionButton: NSButton { + private var isHovered = false { didSet { if isHovered != oldValue { refreshAppearance() } } } + private var hoverTracking: NSTrackingArea? + private var chipTitle = "" + private static let restFill = CloudTreeHoverStyle.selectedOpacity + private static let hoverFill = CloudTreeHoverStyle.selectedOpacity + CloudTreeHoverStyle.hoverOpacity + private static let pressedFill = CloudTreeHoverStyle.selectedOpacity + CloudTreeHoverStyle.pressedOpacity + private var fontSize: CGFloat = 11 + + init() { + super.init(frame: .zero) + isBordered = false + wantsLayer = true + layer?.cornerRadius = CloudTreeHoverStyle.cornerRadius + layer?.cornerCurve = .continuous + setButtonType(.momentaryChange) + refreshAppearance() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func setTitle(_ title: String, fontSize: CGFloat) { + chipTitle = title + self.fontSize = fontSize + refreshAppearance() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverTracking { removeTrackingArea(hoverTracking) } + let area = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], owner: self) + addTrackingArea(area) + hoverTracking = area + } + + override func mouseEntered(with event: NSEvent) { isHovered = true } + override func mouseExited(with event: NSEvent) { isHovered = false } + + override var isHighlighted: Bool { + didSet { refreshAppearance() } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + refreshAppearance() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { isHovered = false } + } + + private func refreshAppearance() { + let opacity = isHighlighted ? Self.pressedFill : (isHovered ? Self.hoverFill : Self.restFill) + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(opacity).cgColor + } + attributedTitle = NSAttributedString(string: chipTitle, attributes: [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .medium), + .foregroundColor: isHovered || isHighlighted ? NSColor.labelColor : NSColor.secondaryLabelColor, + ]) + } +} diff --git a/Sources/Cloud/CloudTreeMachineDetailLayout.swift b/Sources/Cloud/CloudTreeMachineDetailLayout.swift index 56b3d4b41d88..ed1081a36820 100644 --- a/Sources/Cloud/CloudTreeMachineDetailLayout.swift +++ b/Sources/Cloud/CloudTreeMachineDetailLayout.swift @@ -81,6 +81,12 @@ struct CloudTreeMachineDetailLayout { } } node.children.forEach(collect) + // While the machine connects, Ports, Terminals and Displays have + // nothing current to show, so only Resources (fleet telemetry, which + // does not need the link) keeps its tab. + if rows.contains(where: Self.isConnectingPlaceholder) { + for tab in [CloudTreeMachineDetailTab.ports, .terminals, .displays] { pools[tab] = nil } + } let tabs = CloudTreeMachineDetailTab.allCases.filter { pools[$0] != nil } if !tabs.isEmpty { let row = tabRow(machine: machine, tabs: tabs, pools: pools) @@ -162,5 +168,10 @@ struct CloudTreeMachineDetailLayout { return CloudTreeNode(id: node.id, kind: .terminal(row), children: node.children, isPinned: node.isPinned) } + private static func isConnectingPlaceholder(_ node: CloudTreeNode) -> Bool { + if case .placeholder(_, let placeholder) = node.kind { return placeholder.style == .connecting } + return false + } + private static func baseID(_ machine: SurfaceMachineID) -> String { "machine:\(machine.rawValue)" } } diff --git a/Sources/Cloud/CloudTreeNSOutlineView.swift b/Sources/Cloud/CloudTreeNSOutlineView.swift index 3c48c7e7146c..959bf2555573 100644 --- a/Sources/Cloud/CloudTreeNSOutlineView.swift +++ b/Sources/Cloud/CloudTreeNSOutlineView.swift @@ -477,11 +477,24 @@ final class CloudTreeNSOutlineView: NSOutlineView { } let trailing = frame.maxX frame.origin.x = panelContentLeading(atRow: row) + ?? connectingLeading(atRow: row) ?? CloudTreeLayoutMetrics().contentLeading(level: level(forRow: row), style: treeStyle) frame.size.width = max(0, trailing - frame.minX) return frame } + /// A machine's "Connecting…" row stands where its New Workspace will be, + /// so its spinner takes the same chevron column as New Workspace's "+" + /// (`CloudTreeCellView.createRowContentInset`); nil for every other row. + func connectingLeading(atRow row: Int) -> CGFloat? { + guard let node = item(atRow: row) as? CloudTreeNode, + case .placeholder(_, let placeholder) = node.kind, placeholder.style == .connecting, + let parent = parent(forItem: node) as? CloudTreeNode, parent.isMachineRow else { return nil } + let slot = max(treeStyle.iconSlot, 12) + return disclosureLeading(atRow: row) + + GlobalFontMagnification.scaledSize(treeStyle.rowGrid.disclosureSlot / 2 - slot / 2) + } + /// An open machine tab's rows start under the first tab, not one indent /// deeper than the tab row; nil for every other row. func panelContentLeading(atRow row: Int) -> CGFloat? { diff --git a/cmuxTests/CloudTreeMachineDetailLayoutTests.swift b/cmuxTests/CloudTreeMachineDetailLayoutTests.swift index 2fdf7b1b037d..8ac42323018b 100644 --- a/cmuxTests/CloudTreeMachineDetailLayoutTests.swift +++ b/cmuxTests/CloudTreeMachineDetailLayoutTests.swift @@ -50,6 +50,26 @@ struct CloudTreeMachineDetailLayoutTests { #expect(tabsRow.children.count == 2) } + @Test("A connecting machine shows Connecting… and none of the link's tabs") + func connectingMachineHidesLinkTabs() throws { + let fixture = CloudSidebarOrderingFixture() + defer { fixture.close() } + let snapshot = SurfaceCatalogSnapshot(machines: [SurfaceMachineInfo( + id: fixture.machine, name: "Fixture", status: "running", image: nil, hasDesktop: false, + memoryMb: nil, diskMb: nil, linkState: .connecting, linkError: nil, remoteWorkspaces: [] + )], resources: [], projections: []) + let machine = try Self.machine(in: CloudTreeMachineDetailLayout().present(CloudTreeNodeBuilder.nodes( + machines: [], snapshot: snapshot, localWorkspaces: [], includeLocalMachine: false + ))) + #expect(machine.children.contains { node in + if case .placeholder(_, let placeholder) = node.kind { return placeholder.style == .connecting } + return false + }) + // Ports, Terminals and Displays wait for the link; with no fleet + // telemetry there is no Resources either, so no tab row at all. + #expect(!machine.children.contains { $0.structureTag == "machineDetailTabs" }) + } + @Test("Opening Terminals shows New Terminal, then every terminal labelled with its workspace") func terminalsTabShowsItsRows() throws { let fixture = CloudSidebarOrderingFixture() From 29e961049f61f596badbe170fc3cfc8c3be2dcb5 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 10:03:37 +1300 Subject: [PATCH 02/56] test: clicking a ports status row's text should not refresh or wake --- cmuxTests/CloudPortsVPNAffordanceTests.swift | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/cmuxTests/CloudPortsVPNAffordanceTests.swift b/cmuxTests/CloudPortsVPNAffordanceTests.swift index 91127d5f2ba3..5f9d5b47ae4a 100644 --- a/cmuxTests/CloudPortsVPNAffordanceTests.swift +++ b/cmuxTests/CloudPortsVPNAffordanceTests.swift @@ -320,6 +320,38 @@ struct CloudPortsVPNAffordanceTests { #expect(refreshed == [.cloud("paid")]) } + @Test("Clicking a Ports status row's text does nothing; only its button acts") + func statusRowClickIsInert() throws { + var terminals: [SurfaceMachineID] = [] + var refreshed: [SurfaceMachineID] = [] + var actions = nodeActions(newTerminal: { terminals.append($0) }) + actions.refreshMachine = { refreshed.append($0) } + let coordinator = CloudTreeOutlineView.Coordinator( + machineActions: machineActions(), + nodeActions: actions, + expansionStore: CloudTreeExpansionStore(defaults: try #require(UserDefaults(suiteName: "ports-status-click-\(UUID())"))), + tabDragTransferRegistry: { nil }) + coordinator.nodes = [machineNode(id: "paid")] + func status(link: SurfaceLinkState, discovery: CloudPortDiscoveryState) -> CloudTreeNode { + CloudMachineSurfacePresentation.emptyPorts(info: SurfaceMachineInfo( + id: .cloud("paid"), name: "paid", status: "running", image: "base", hasDesktop: false, + memoryMb: nil, diskMb: nil, linkState: link, linkError: nil, + cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil, portDiscoveryState: discovery)) + } + // "No reachable ports" (Refresh) and an asleep machine (Wake Machine). + let noPorts = status(link: .connected, discovery: .empty(.noListeningService)) + let asleep = status(link: .asleep, discovery: .notRequested) + guard case .placeholder(_, let noPortsRow) = noPorts.kind, case .placeholder(_, let asleepRow) = asleep.kind else { + Issue.record("status rows must be placeholders"); return + } + #expect(noPortsRow.portStatus?.action == .refresh) + #expect(asleepRow.portStatus?.action == .openMachine) + coordinator.open(noPorts) + coordinator.open(asleep) + #expect(refreshed.isEmpty) + #expect(terminals.isEmpty) + } + private func machineNode(id: String, expired: Bool = false) -> CloudTreeNode { let machine = SurfaceMachineID.cloud(id) var snapshot = MachineSnapshot(id: id, provider: "freestyle", image: "base", isDesktop: false, activity: .ready, createdAt: nil, label: nil) From ec601a1e8203275f44d769bdea6c5aad5b42fb0f Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 10:03:38 +1300 Subject: [PATCH 03/56] cloud sidebar: only the ports status button acts, clicking the row's text does nothing --- Sources/Cloud/CloudTreeOutlineView+Ports.swift | 2 +- Sources/Cloud/CloudTreeOutlineView.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/Cloud/CloudTreeOutlineView+Ports.swift b/Sources/Cloud/CloudTreeOutlineView+Ports.swift index 8bf3e66c5ffd..4f61421623f7 100644 --- a/Sources/Cloud/CloudTreeOutlineView+Ports.swift +++ b/Sources/Cloud/CloudTreeOutlineView+Ports.swift @@ -3,7 +3,7 @@ import CmuxCloud import CmuxSurfaceCatalogModel extension CloudTreeOutlineView.Coordinator { - /// Mouse, keyboard and status-button activation share the machine's current plan gate. + /// The Ports status button's action. It shares the machine's current plan gate. func performPortAction(_ action: CloudPortsStatusAction, machineID: SurfaceMachineID) { switch action { case .none: break diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 0775f6a1b5f8..940988f31408 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -585,9 +585,12 @@ struct CloudTreeOutlineView: NSViewRepresentable { nodeActions.newMachine() return } + // A Ports status row (No reachable ports, asleep, Discovering…) is + // text with its own button; only that button acts, so a click on the + // text never refreshes or wakes. + guard placeholder.portStatus == nil else { return } // "Asleep — open to wake": a fresh terminal on the machine is what wakes it. - if let status = placeholder.portStatus { performPortAction(status.action, machineID: machineID) } - else if placeholder.opensMachine, let machine = machine(id: machineID) { openMachine(machine) } + if placeholder.opensMachine, let machine = machine(id: machineID) { openMachine(machine) } } } private func toggle(_ node: CloudTreeNode) { From 687e3f959c7b04d8be6367d1a0803be87cd5e05a Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 13:46:52 +1300 Subject: [PATCH 04/56] test: machines under the cloud machines section reorder by drag the live cloud tab lists machines under the cloud machines section, and dropping one there is refused today. this fails until the fix. --- cmuxTests/CloudMachineOrderingFixture.swift | 29 ++++++++++++++++----- cmuxTests/CloudMachineOrderingTests.swift | 22 ++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/cmuxTests/CloudMachineOrderingFixture.swift b/cmuxTests/CloudMachineOrderingFixture.swift index b1b19aced810..7977b42e991d 100644 --- a/cmuxTests/CloudMachineOrderingFixture.swift +++ b/cmuxTests/CloudMachineOrderingFixture.swift @@ -31,9 +31,13 @@ final class CloudMachineOrderingFixture { var coordinator: CloudTreeOutlineView.Coordinator { base.coordinator } var pending: [MachineCreateOperation] = [] var adoptedIDs: [String: UUID] = [:] + /// The live Cloud tab's shape: machines under the Cloud Machines + /// section, My Devices after it, no This Mac row. + let sectioned: Bool private var boards: [NSPasteboard] = [] - init(ids: [String] = ["a", "b", "c", "d"]) { + init(ids: [String] = ["a", "b", "c", "d"], sectioned: Bool = false) { + self.sectioned = sectioned let input = input store = CloudMachinePinStore(defaults: base.defaults, scopeProvider: { input.scope }) model = MachinesPanelViewModel( @@ -87,18 +91,31 @@ final class CloudMachineOrderingFixture { let local = CloudTreeNode(id: "machine:local", kind: .localMachine( CloudTreeLocalMachineRow(name: "This Mac", terminalCount: 0, browserCount: 0) )) - coordinator.apply(nodes: [local] + CloudTreeNodeBuilder.nodes( + let fleet = CloudTreeNodeBuilder.nodes( machines: model.sidebarMachines, pendingCreates: pending, adoptedOperationIDs: adoptedIDs, - snapshot: model.catalog, localWorkspaces: [], includeLocalMachine: false - )) + snapshot: model.catalog, localWorkspaces: [], includeLocalMachine: false, + source: sectioned ? .cloudWithDevicesSection : .cloud + ) + coordinator.apply(nodes: sectioned ? fleet : [local] + fleet) base.container.layoutSubtreeIfNeeded() } func root(_ id: String) throws -> CloudTreeNode { - try #require(coordinator.nodes.first { $0.machineOrderID == id }) + try #require(machines.first { $0.machineOrderID == id }) + } + + /// The machine rows, at the root or under the Cloud Machines section. + var machines: [CloudTreeNode] { + guard sectioned else { return coordinator.nodes } + return coordinator.nodes.first { if case .cloudMachinesSection = $0.kind { return true }; return false }? + .children ?? [] + } + + var section: CloudTreeNode? { + coordinator.nodes.first { if case .cloudMachinesSection = $0.kind { return true }; return false } } - var order: [String] { coordinator.nodes.compactMap(\.machineOrderID) } + var order: [String] { machines.compactMap(\.machineOrderID) } func begin(_ id: String) throws -> Drag { let source = try root(id) diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index 82371ce7b803..2500191cde05 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -76,6 +76,28 @@ struct CloudMachineOrderingTests { #expect(fixture.base.provider.refreshCount == 0) } + @Test("Machines under the Cloud Machines section reorder by drag", arguments: [false, true]) + func sectionedDrag(toEnd: Bool) throws { + let fixture = CloudMachineOrderingFixture(sectioned: true) + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + let section = try #require(fixture.section) + let drag = try fixture.begin(toEnd ? "a" : "d") + // Between two machines AppKit proposes the section with a child index. + let index = toEnd ? 4 : 0 + #expect(coordinator.outlineView(outline, validateDrop: drag.info, + proposedItem: section, proposedChildIndex: index) == .move) + #expect(coordinator.outlineView(outline, acceptDrop: drag.info, item: section, childIndex: index)) + #expect(fixture.order == (toEnd ? ["b", "c", "d", "a"] : ["d", "a", "b", "c"])) + #expect(coordinator.nodes.first?.id == section.id, "machines stay under the section") + #expect(fixture.machines.contains { if case .machineEndSpacer = $0.kind { return true }; return false }, + "the section keeps its closing gap") + try fixture.end(drag) + let restored = CloudMachinePinStore(defaults: fixture.base.defaults, scopeProvider: { "user:a|team:one" }) + #expect(restored.orderedMachineIDs(["a", "b", "c", "d"]) == fixture.order) + } + @Test("Crossing the pin boundary clamps the move without drawing a hint", arguments: [false, true]) func pinBoundary(pinnedSource: Bool) throws { let fixture = CloudMachineOrderingFixture() From 49bf865b904dcc6039157e8b99bb9e2d329efcb8 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 14:02:08 +1300 Subject: [PATCH 05/56] cloud sidebar: continuous machine and tab reorder, refresh icons in the section headers machine drags under the cloud machines section were refused because the drop only looked at top-level rows; drops and menu moves now find the machines wherever they sit. the real machine row is the drag visual: open machines close for the drag, peers spring aside against frozen geometry, and the drop lands every row from where it is on screen. mode tabs drag the same way and hand off to a native drag when pulled out of the bar, so they still open as panes. switching tabs slides one selection highlight, and an icon-only tab centers its icon. refresh moves from the bottom of the panel to the cloud machines and my devices headers. --- .../Tree/CloudTreeDevicesSection.swift | 6 +- .../Tree/CloudTreeReorderLiftLayout.swift | 126 ++++++ .../Tree/CloudTreeSectionRefresh.swift | 11 + .../CloudTreeReorderLiftLayoutTests.swift | 131 ++++++ .../Cloud/CloudRefreshMachinesButton.swift | 56 --- Sources/Cloud/CloudTreeBuildInputs.swift | 2 + .../Cloud/CloudTreeCreateActionBuilder.swift | 2 +- Sources/Cloud/CloudTreeNSOutlineView.swift | 31 +- Sources/Cloud/CloudTreeNode.swift | 4 +- Sources/Cloud/CloudTreeNodeActions.swift | 10 +- .../Cloud/CloudTreeNodeBuilder+Machines.swift | 5 +- ...oudTreeOutlineView+MachineDetailTabs.swift | 25 ++ .../Cloud/CloudTreeOutlineView+RowMenu.swift | 9 + Sources/Cloud/CloudTreeOutlineView.swift | 15 +- Sources/Cloud/CloudTreeRowContentView.swift | 2 +- Sources/Cloud/CloudTreeRowHoverButtons.swift | 48 ++- Sources/Cloud/CloudTreeRowToolTip.swift | 2 +- Sources/Cloud/MachinesPanelView.swift | 12 +- .../Sidebar/CloudMachineReorderDrop.swift | 25 +- .../Sidebar/CloudMachineReorderScope.swift | 32 ++ .../CloudSidebarOrganizationDrop.swift | 28 +- .../Sidebar/CloudTreeMachineLiftStyle.swift | 107 +++++ .../Sidebar/CloudTreeMachineReorderLift.swift | 387 ++++++++++++++++++ .../CloudTreeOutlineView+MachineLift.swift | 72 ++++ ...CloudTreeOutlineView+MachineOrdering.swift | 31 +- .../CloudTreeOutlineView+Organization.swift | 19 +- Sources/RightSidebarChromeStyle.swift | 51 ++- Sources/RightSidebarModeBarDrag.swift | 277 +++++++++++++ Sources/RightSidebarModeBarDragLayout.swift | 96 +++++ Sources/RightSidebarModeDragPayload.swift | 116 ++++-- Sources/RightSidebarPanelView.swift | 85 +--- cmux.xcodeproj/project.pbxproj | 28 +- cmuxTests/CloudMachineOrderingFixture.swift | 2 + cmuxTests/CloudMachineOrderingTests.swift | 82 ++-- cmuxTests/CloudTreeHeaderActionsTests.swift | 13 + .../RightSidebarTabCustomizationTests.swift | 74 +++- 36 files changed, 1727 insertions(+), 295 deletions(-) create mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeReorderLiftLayout.swift create mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift create mode 100644 Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTreeReorderLiftLayoutTests.swift delete mode 100644 Sources/Cloud/CloudRefreshMachinesButton.swift create mode 100644 Sources/Cloud/Sidebar/CloudMachineReorderScope.swift create mode 100644 Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift create mode 100644 Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift create mode 100644 Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift create mode 100644 Sources/RightSidebarModeBarDrag.swift create mode 100644 Sources/RightSidebarModeBarDragLayout.swift diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift index 503f35eeef9b..c832d1956a4e 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift @@ -8,7 +8,8 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { incomingAccessEnabled: Bool = false, discoveryManaged: Bool = false, incomingAccessManaged: Bool = false, - available: Bool = true + available: Bool = true, + isRefreshing: Bool = false ) { self.count = count self.discoveryEnabled = discoveryEnabled @@ -16,6 +17,7 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { self.discoveryManaged = discoveryManaged self.incomingAccessManaged = incomingAccessManaged self.available = available + self.isRefreshing = isRefreshing } public var count: Int = 0 @@ -25,6 +27,8 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { public var incomingAccessManaged: Bool = false /// Whether Cloud/Beta availability permits either device preference. public var available: Bool = true + /// A refresh of the devices list is running; the header's refresh icon spins. + public var isRefreshing: Bool = false /// Both independent actions stay visible below the devices, preceded by /// "No other Macs yet" when the list is empty. diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeReorderLiftLayout.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeReorderLiftLayout.swift new file mode 100644 index 000000000000..ed8afe3b1296 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeReorderLiftLayout.swift @@ -0,0 +1,126 @@ +import CoreGraphics + +/// Geometry for a continuous machine drag in the Cloud tree. +/// +/// The dragged machine (its row plus any open rows under it) is one block +/// that follows the pointer. Its peers part around it, and nothing in the +/// model moves until the drop. Everything is computed against row frames +/// frozen when the drag starts, so slot boundaries cannot move mid-drag and +/// the rows cannot oscillate. +public struct CloudTreeReorderLiftLayout: Equatable, Sendable { + /// A run of rows that moves as one: a machine and its visible descendants. + public struct Block: Equatable, Sendable { + public let rows: Range + /// A machine the source can trade places with. Other blocks inside the + /// span (a pending create, a machine in another pin tier) keep their slot. + public let isPeer: Bool + + public init(rows: Range, isPeer: Bool) { + self.rows = rows + self.isPeer = isPeer + } + } + + /// Where everything stands for one pointer position. + public struct Placement: Equatable, Sendable { + /// The source's index among the other peers once dropped, 0...peerCount. + public let slot: Int + /// The source block's translation: the pointer's, held inside the span. + public let sourceOffset: CGFloat + /// The translation that would put the source block in `slot`. + public let sourceSlotOffset: CGFloat + /// The translation of every other row in the span, zero for rows that stay. + public let rowOffsets: [Int: CGFloat] + } + + /// How far past the span the block can be pulled before it stops, in points. + public static let overscrollLimit: CGFloat = 24 + + private let frames: [CGRect] + /// The blocks from the first peer or source to the last, in display order. + private let span: [Block] + /// The source's index in `span`. + private let source: Int + + /// The number of peers the source can be placed among. + public var peerCount: Int { span.filter(\.isPeer).count - (span[source].isPeer ? 1 : 0) } + + /// - Parameters: + /// - frames: Every row's frame, indexed by row, in a flipped (top-down) space. + /// - blocks: The source's sibling blocks in display order. + /// - sourceIndex: The source's index in `blocks`. + public init?(frames: [CGRect], blocks: [Block], sourceIndex: Int) { + guard blocks.indices.contains(sourceIndex), + blocks.allSatisfy({ !$0.rows.isEmpty && $0.rows.lowerBound >= 0 && $0.rows.upperBound <= frames.count }) + else { return nil } + let movable = blocks.indices.filter { blocks[$0].isPeer || $0 == sourceIndex } + guard let first = movable.first, let last = movable.last else { return nil } + self.frames = frames + span = Array(blocks[first...last]) + source = sourceIndex - first + } + + public func placement(dragOffset: CGFloat) -> Placement { + let sourceBlock = span[source] + let sourceTop = top(sourceBlock) + let sourceHeight = height(sourceBlock) + let minOffset = top(span[0]) - sourceTop + let maxOffset = bottom(span[span.count - 1]) - bottom(sourceBlock) + let held = min(max(dragOffset, minOffset), maxOffset) + let dragTop = sourceTop + held + let dragBottom = dragTop + sourceHeight + + // A peer gives way once the block covers half of the smaller of the + // two, so a tall open machine doesn't have to be crossed halfway + // before a one-line neighbour moves, and vice versa. + var slot = 0 + for index in span.indices where span[index].isPeer && index != source { + let peer = span[index] + let reach = min(height(peer), sourceHeight) / 2 + if index < source { + if dragTop >= bottom(peer) - reach { slot += 1 } + } else if dragBottom > top(peer) + reach { + slot += 1 + } + } + + // Re-lay the span in drop order. Fixed blocks keep their positions; + // the peers and the source fill the others. + var order = span.indices.filter { span[$0].isPeer && $0 != source } + order.insert(source, at: slot) + var nextMovable = order.makeIterator() + var offsets: [Int: CGFloat] = [:] + var y = top(span[0]) + for position in span.indices { + let index = span[position].isPeer || position == source ? nextMovable.next() ?? position : position + offsets[index] = y - top(span[index]) + y += height(span[index]) + if position + 1 < span.count { + y += top(span[position + 1]) - bottom(span[position]) + } + } + + var rowOffsets: [Int: CGFloat] = [:] + for index in span.indices where index != source { + for row in span[index].rows { rowOffsets[row] = offsets[index] ?? 0 } + } + return Placement( + slot: slot, + sourceOffset: held + Self.overscroll(dragOffset - held), + sourceSlotOffset: offsets[source] ?? 0, + rowOffsets: rowOffsets + ) + } + + /// Resistance past the span's ends: the block follows a little, then stops. + static func overscroll(_ excess: CGFloat) -> CGFloat { + guard excess != 0 else { return 0 } + let limit = overscrollLimit + let pulled = (1 - 1 / (abs(excess) * 0.55 / limit + 1)) * limit + return excess < 0 ? -pulled : pulled + } + + private func top(_ block: Block) -> CGFloat { frames[block.rows.lowerBound].minY } + private func bottom(_ block: Block) -> CGFloat { frames[block.rows.upperBound - 1].maxY } + private func height(_ block: Block) -> CGFloat { bottom(block) - top(block) } +} diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift new file mode 100644 index 000000000000..9d729ab2f9ce --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A section header's refresh icon: present while the section has something +/// to refresh, with a spinner in its place while a refresh runs. +public struct CloudTreeSectionRefresh: Equatable, Sendable { + public var isRefreshing: Bool + + public init(isRefreshing: Bool = false) { + self.isRefreshing = isRefreshing + } +} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTreeReorderLiftLayoutTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTreeReorderLiftLayoutTests.swift new file mode 100644 index 000000000000..0cb757d4c321 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudTreeReorderLiftLayoutTests.swift @@ -0,0 +1,131 @@ +import CmuxCloud +import CoreGraphics +import Testing + +@Suite("Cloud tree reorder lift layout") +struct CloudTreeReorderLiftLayoutTests { + /// Rows stacked top-down from y = 0 with the given heights. + private func frames(_ heights: [CGFloat]) -> [CGRect] { + var y: CGFloat = 0 + return heights.map { height in + defer { y += height } + return CGRect(x: 0, y: y, width: 200, height: height) + } + } + + /// Four one-row machines, 30pt each. + private func fourMachines(source: Int) throws -> CloudTreeReorderLiftLayout { + try #require(CloudTreeReorderLiftLayout( + frames: frames([30, 30, 30, 30]), + blocks: (0..<4).map { .init(rows: $0..<($0 + 1), isPeer: true) }, + sourceIndex: source + )) + } + + @Test("At rest nothing moves and the source keeps its slot") + func rest() throws { + let placement = try fourMachines(source: 1).placement(dragOffset: 0) + #expect(placement.slot == 1) + #expect(placement.sourceOffset == 0) + #expect(placement.sourceSlotOffset == 0) + #expect(placement.rowOffsets.values.allSatisfy { $0 == 0 }) + } + + @Test("A neighbour gives way once half of it is covered, not before") + func neighbourThreshold() throws { + let layout = try fourMachines(source: 0) + let short = layout.placement(dragOffset: 15) + #expect(short.slot == 0) + #expect(short.rowOffsets[1] == 0) + let past = layout.placement(dragOffset: 16) + #expect(past.slot == 1) + #expect(past.rowOffsets[1] == -30) + #expect(past.rowOffsets[2] == 0) + #expect(past.sourceOffset == 16, "the block stays under the pointer") + #expect(past.sourceSlotOffset == 30) + } + + @Test("Dragging up moves the rows above down") + func dragUp() throws { + let placement = try fourMachines(source: 3).placement(dragOffset: -76) + #expect(placement.slot == 0) + #expect(placement.rowOffsets[0] == 30) + #expect(placement.rowOffsets[1] == 30) + #expect(placement.rowOffsets[2] == 30) + #expect(placement.sourceSlotOffset == -90) + } + + @Test("The block resists past the span's ends and the slot stays at the end") + func overscroll() throws { + let layout = try fourMachines(source: 1) + let pulled = layout.placement(dragOffset: -500) + #expect(pulled.slot == 0) + #expect(pulled.sourceOffset < -30) + #expect(pulled.sourceOffset > -30 - CloudTreeReorderLiftLayout.overscrollLimit) + let down = layout.placement(dragOffset: 500) + #expect(down.slot == 3) + #expect(down.sourceOffset > 60) + #expect(down.sourceOffset < 60 + CloudTreeReorderLiftLayout.overscrollLimit) + } + + @Test("An open machine moves as one block and peers clear its full height") + func openMachineBlock() throws { + // Machine 0 is open with three rows under it; machines 1 and 2 are closed. + let layout = try #require(CloudTreeReorderLiftLayout( + frames: frames([30, 24, 24, 24, 30, 30]), + blocks: [.init(rows: 0..<4, isPeer: true), .init(rows: 4..<5, isPeer: true), .init(rows: 5..<6, isPeer: true)], + sourceIndex: 0 + )) + // The open block is 102pt; the one-line neighbour moves once its half is covered. + let placement = layout.placement(dragOffset: 16) + #expect(placement.slot == 1) + #expect(placement.rowOffsets[4] == -102) + #expect(placement.rowOffsets[5] == 0) + #expect(placement.sourceSlotOffset == 30) + #expect(placement.rowOffsets[1] == nil, "the source's own rows travel with the block") + } + + @Test("A one-line machine passes an open one after covering half of itself") + func passingOpenMachine() throws { + // Machine 1 is open (30 + 3 x 24); the source is the closed machine 0. + let layout = try #require(CloudTreeReorderLiftLayout( + frames: frames([30, 30, 24, 24, 24]), + blocks: [.init(rows: 0..<1, isPeer: true), .init(rows: 1..<5, isPeer: true)], + sourceIndex: 0 + )) + #expect(layout.placement(dragOffset: 15).slot == 0) + let placement = layout.placement(dragOffset: 16) + #expect(placement.slot == 1) + for row in 1..<5 { #expect(placement.rowOffsets[row] == -30) } + #expect(placement.sourceSlotOffset == 102) + } + + @Test("Blocks outside the source's tier and fixed rows inside it hold still") + func fixedBlocks() throws { + // 0 pinned (other tier), 1 source, 2 a pending create (fixed), 3 peer. + let layout = try #require(CloudTreeReorderLiftLayout( + frames: frames([30, 30, 30, 30]), + blocks: [ + .init(rows: 0..<1, isPeer: false), .init(rows: 1..<2, isPeer: true), + .init(rows: 2..<3, isPeer: false), .init(rows: 3..<4, isPeer: true) + ], + sourceIndex: 1 + )) + #expect(layout.peerCount == 1) + let up = layout.placement(dragOffset: -200) + #expect(up.slot == 0, "the other tier is outside the span") + #expect(up.sourceOffset > -CloudTreeReorderLiftLayout.overscrollLimit) + let down = layout.placement(dragOffset: 200) + #expect(down.slot == 1) + #expect(down.rowOffsets[2] == 0, "the fixed row keeps its slot") + #expect(down.rowOffsets[3] == -60) + #expect(down.rowOffsets[0] == nil) + #expect(down.sourceSlotOffset == 60) + } + + @Test("Bad input is rejected") + func invalid() { + #expect(CloudTreeReorderLiftLayout(frames: frames([30]), blocks: [.init(rows: 0..<2, isPeer: true)], sourceIndex: 0) == nil) + #expect(CloudTreeReorderLiftLayout(frames: frames([30]), blocks: [.init(rows: 0..<1, isPeer: true)], sourceIndex: 1) == nil) + } +} diff --git a/Sources/Cloud/CloudRefreshMachinesButton.swift b/Sources/Cloud/CloudRefreshMachinesButton.swift deleted file mode 100644 index 51c93eade589..000000000000 --- a/Sources/Cloud/CloudRefreshMachinesButton.swift +++ /dev/null @@ -1,56 +0,0 @@ -import CmuxFoundation -import SwiftUI - -/// Refresh Cloud Machines, at the bottom right of the Cloud panel. As wide as -/// its label, with the tree's hover fill, so it reads like a quiet row action. -/// While a refresh runs it shows a spinner in place of its icon and ignores -/// clicks. -struct CloudRefreshMachinesButton: View { - let isRefreshing: Bool - let action: () -> Void - @State private var isHovered = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private var title: String { - String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines") - } - - var body: some View { - HStack(spacing: 0) { - Spacer(minLength: 0) - Button(action: action) { - HStack(spacing: 5) { - Group { - if isRefreshing { - ProgressView().controlSize(.mini) - } else { - Image(systemName: "arrow.clockwise") - .font(.system(size: 10, weight: .medium)) - } - } - .frame(width: 12, height: 12) - Text(title) - .cmuxFont(size: 11.5) - .lineLimit(1) - } - .foregroundStyle(isHovered && !isRefreshing ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) - .padding(.horizontal, 8) - .frame(height: 22) - .background( - RoundedRectangle(cornerRadius: CloudTreeHoverStyle.cornerRadius, style: .continuous) - .fill(Color.primary.opacity(isHovered && !isRefreshing ? CloudTreeHoverStyle.hoverOpacity : 0)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isRefreshing) - .onHover { isHovered = $0 } - .animation(reduceMotion ? nil : .easeOut(duration: isHovered ? CloudTreeHoverStyle.fadeIn : CloudTreeHoverStyle.fadeOut), value: isHovered) - .help(title) - .accessibilityLabel(title) - .accessibilityIdentifier("CloudRefreshMachinesButton") - } - .padding(.horizontal, RightSidebarChromeMetrics.barHorizontalPadding - 2) - .padding(.vertical, 6) - } -} diff --git a/Sources/Cloud/CloudTreeBuildInputs.swift b/Sources/Cloud/CloudTreeBuildInputs.swift index cecddba2b545..6bd40f3928f6 100644 --- a/Sources/Cloud/CloudTreeBuildInputs.swift +++ b/Sources/Cloud/CloudTreeBuildInputs.swift @@ -17,6 +17,7 @@ struct CloudTreeBuildInputs: Equatable { var showsCloudVPNWarning = false var canCreateCloudMachine = false var cloudMachinesUsage: CloudMachinesUsage? = nil + var cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var localeIdentifier: String = Locale.current.identifier func nodes(now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init()) -> [CloudTreeNode] { @@ -31,6 +32,7 @@ struct CloudTreeBuildInputs: Equatable { showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: cloudMachinesUsage, + cloudMachinesRefresh: cloudMachinesRefresh, now: now, resourceNodeBuilder: resourceNodeBuilder ) } diff --git a/Sources/Cloud/CloudTreeCreateActionBuilder.swift b/Sources/Cloud/CloudTreeCreateActionBuilder.swift index cec2f8618114..224bbcbbacaf 100644 --- a/Sources/Cloud/CloudTreeCreateActionBuilder.swift +++ b/Sources/Cloud/CloudTreeCreateActionBuilder.swift @@ -8,7 +8,7 @@ enum CloudTreeCreateActionBuilder { for node in nodes { node.children = add(to: node.children) switch node.kind { - case .cloudMachinesSection(let canCreateMachine, _): + case .cloudMachinesSection(let canCreateMachine, _, _): // New Cloud Machine is the button above the section // (`CloudNewMachineButton`), so the empty fleet's // double-click-only "New Machine" placeholder goes. diff --git a/Sources/Cloud/CloudTreeNSOutlineView.swift b/Sources/Cloud/CloudTreeNSOutlineView.swift index 3c48c7e7146c..7a88d2ddbe66 100644 --- a/Sources/Cloud/CloudTreeNSOutlineView.swift +++ b/Sources/Cloud/CloudTreeNSOutlineView.swift @@ -9,6 +9,9 @@ final class CloudTreeNSOutlineView: NSOutlineView { static let leadingMargin: CGFloat = 8 private var dragDestinationSequenceNumber: Int? lazy var reorderPresentation = CloudTreeReorderPresentation(outline: self) + lazy var machineLift = CloudTreeMachineReorderLift(outline: self) + /// The last press, in outline coordinates: a lifted machine's grab point. + private(set) var lastMouseDownPoint: NSPoint? func trackDragDestination(sequenceNumber: Int) { dragDestinationSequenceNumber = sequenceNumber @@ -70,8 +73,8 @@ final class CloudTreeNSOutlineView: NSOutlineView { /// enter/exit state across tracking-area replacement, scrolling, or reloads. private func updateHover(at point: NSPoint?, keepingNode: Bool = false) { var next: CloudTreeCellView? - if let menuPinnedNodeID { - next = visibleCell(forNodeID: menuPinnedNodeID) + if let pinnedID = machineLift.sourceNodeID ?? menuPinnedNodeID { + next = visibleCell(forNodeID: pinnedID) } else if let point, visibleRect.contains(point) { // The whole row line counts, so the chevron and the indent before // the cell hover the row too. @@ -169,6 +172,7 @@ final class CloudTreeNSOutlineView: NSOutlineView { } updateHover(at: nil) reorderPresentation.clear() + machineLift.discard() NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: window) NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeKeyNotification, object: window) super.viewWillMove(toWindow: newWindow) @@ -264,6 +268,7 @@ final class CloudTreeNSOutlineView: NSOutlineView { } override func mouseDown(with event: NSEvent) { + lastMouseDownPoint = convert(event.locationInWindow, from: nil) clearDragDestination() reorderPresentation.clear() onNativeDragPointerBoundary?() @@ -423,6 +428,7 @@ final class CloudTreeNSOutlineView: NSOutlineView { override func reloadData() { clearDragDestination() reorderPresentation.clear() + machineLift.discard() updateHover(at: nil, keepingNode: true) super.reloadData() needsLayout = true @@ -482,27 +488,6 @@ final class CloudTreeNSOutlineView: NSOutlineView { return frame } - /// An open machine tab's rows start under the first tab, not one indent - /// deeper than the tab row; nil for every other row. - func panelContentLeading(atRow row: Int) -> CGFloat? { - guard let level = tabRowLevel(ofChildAt: row) else { return nil } - let leading = CloudTreeMachineDetailTabsView.panelContentLeading(tabRowLevel: level, style: treeStyle) - // Resource readings have no icon, so their text starts where the - // other rows' glyphs do: on the first tab's title. - if let node = item(atRow: row) as? CloudTreeNode, case .resource = node.kind { - return leading + GlobalFontMagnification.scaledSize(max(0, treeStyle.iconSlot - treeStyle.iconSize) / 2) - } - return leading - } - - /// The level of the machine tab row that owns the row at `row`, if any. - func tabRowLevel(ofChildAt row: Int) -> Int? { - guard let item = item(atRow: row), - let parent = parent(forItem: item) as? CloudTreeNode, - case .machineDetailTabs = parent.kind else { return nil } - return level(forItem: parent) - } - private func redrawVisibleRows() { setNeedsDisplay(bounds) let visibleRows = rows(in: visibleRect) diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 61334bd9419a..09230efbe0c0 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -68,8 +68,8 @@ final class CloudTreeNode: NSObject { case devicesSection(CloudTreeDevicesSection) /// The collapsible Cloud Machines section header. `canCreateMachine` shows its /// hover "+" (New Machine, Cmd-Y), false while Cloud Machines is off and the - /// header stands alone; `usage` is the plan's machine count, nil until it loads. - case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil) + /// header stands alone; `usage` is the plan's count (nil until it loads), `refresh` its refresh icon. + case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil, refresh: CloudTreeSectionRefresh? = nil) case createAction(CloudTreeCreateAction) /// My Devices guidance and independent discovery actions, also shown with peers. case devicesEmpty(CloudTreeDevicesSection) diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index 08fd03aaa808..a05c46454a45 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -70,12 +70,12 @@ struct CloudTreeNodeActions { var newDisplay: @MainActor (_ machine: SurfaceMachineID) -> Void = { _ in } /// Opens the New Machine flow through the same action as Cmd-Y. var newMachine: @MainActor () -> Void = {} - /// Pops up a row's context menu from its trailing "⋯" button. The outline - /// coordinator binds it per cell, so the button and a right-click show the - /// same menu. + /// The section headers' refresh icons: the fleet, and the account's other Macs. + var refreshCloudMachines: @MainActor () -> Void = {}, refreshDevices: @MainActor () -> Void = {} + /// Pops up a row's context menu from its trailing "⋯" button. Bound per + /// cell, so the button and a right-click show the same menu. var showRowMenu: @MainActor (_ nodeID: String) -> Void = { _ in } - /// Opens a machine's detail tab, or closes it when it is open. Bound per - /// cell by the outline coordinator. + /// Opens a machine's detail tab, or closes it when open. Bound per cell. var selectMachineDetailTab: @MainActor (_ machine: SurfaceMachineID, _ tab: CloudTreeMachineDetailTab) -> Void = { _, _ in } var organize: @MainActor (CloudSidebarOrganizationAction, String, [CloudTreeNode]) -> Bool = { _, _, _ in false } /// Navigates a nested terminal through its owning Cloud workspace. diff --git a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift index 1c4ac61ee6a3..551b9c495138 100644 --- a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift +++ b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift @@ -19,6 +19,7 @@ extension CloudTreeNodeBuilder { showsCloudVPNWarning: Bool = false, canCreateCloudMachine: Bool = false, cloudMachinesUsage: CloudMachinesUsage? = nil, + cloudMachinesRefresh: CloudTreeSectionRefresh? = nil, now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init() ) -> [CloudTreeNode] { @@ -120,7 +121,9 @@ extension CloudTreeNodeBuilder { : nodes nodes = [CloudTreeNode( id: "cloud-machines-section", - kind: .cloudMachinesSection(canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage), + kind: .cloudMachinesSection( + canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage, refresh: cloudMachinesRefresh + ), children: cloudChildren )] } diff --git a/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift b/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift index 79b7a8f95b1f..45d51a4dc227 100644 --- a/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift +++ b/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift @@ -1,3 +1,5 @@ +import CmuxFoundation +import AppKit import CmuxSurfaceCatalogModel extension CloudTreeOutlineView.Coordinator { @@ -15,3 +17,26 @@ extension CloudTreeOutlineView.Coordinator { } } } + +extension CloudTreeNSOutlineView { + /// An open machine tab's rows start under the first tab, not one indent + /// deeper than the tab row; nil for every other row. + func panelContentLeading(atRow row: Int) -> CGFloat? { + guard let level = tabRowLevel(ofChildAt: row) else { return nil } + let leading = CloudTreeMachineDetailTabsView.panelContentLeading(tabRowLevel: level, style: treeStyle) + // Resource readings have no icon, so their text starts where the + // other rows' glyphs do: on the first tab's title. + if let node = item(atRow: row) as? CloudTreeNode, case .resource = node.kind { + return leading + GlobalFontMagnification.scaledSize(max(0, treeStyle.iconSlot - treeStyle.iconSize) / 2) + } + return leading + } + + /// The level of the machine tab row that owns the row at `row`, if any. + func tabRowLevel(ofChildAt row: Int) -> Int? { + guard let item = item(atRow: row), + let parent = parent(forItem: item) as? CloudTreeNode, + case .machineDetailTabs = parent.kind else { return nil } + return level(forItem: parent) + } +} diff --git a/Sources/Cloud/CloudTreeOutlineView+RowMenu.swift b/Sources/Cloud/CloudTreeOutlineView+RowMenu.swift index 0c630f2dcc8d..56231a5b7990 100644 --- a/Sources/Cloud/CloudTreeOutlineView+RowMenu.swift +++ b/Sources/Cloud/CloudTreeOutlineView+RowMenu.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxCloud extension CloudTreeOutlineView.Coordinator { /// Shows a row's context menu under its trailing "⋯" button, the same @@ -13,3 +14,11 @@ extension CloudTreeOutlineView.Coordinator { menu.popUp(positioning: nil, at: anchor, in: cell) } } + +extension CloudTreeOutlineView.Coordinator { + func item(_ title: String, action: @escaping @MainActor () -> Void) -> NSMenuItem { + let item = CloudTreeMenuItem(title: title, action: action) + item.target = item + return item + } +} diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 0775f6a1b5f8..dfce24783dd1 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -37,6 +37,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { /// The Cloud Machines header's New Machine "+" and its plan count (nil until the plan loads). var canCreateCloudMachine: Bool = false var cloudMachinesUsage: CloudMachinesUsage? = nil + var cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var reveal: CloudTreeRevealRequest? = nil var creationReveal: CloudWorkspaceCreationReveal? = nil var nodeBuilder: ((CloudTreeBuildInputs) -> [CloudTreeNode])? = nil @@ -76,7 +77,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { devicesSection: devicesSection, showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, - cloudMachinesUsage: cloudMachinesUsage + cloudMachinesUsage: cloudMachinesUsage, cloudMachinesRefresh: cloudMachinesRefresh )) context.coordinator.reveal(reveal) context.coordinator.reveal(creation: creationReveal) @@ -124,6 +125,8 @@ struct CloudTreeOutlineView: NSViewRepresentable { self?.pendingDragWriterDidDeallocate(tokenID: tokenID) } private(set) var isDragging = false + /// Machine drags lift the real row; proposal-level tests turn it off. + var machineLiftEnabled = true var deferredNodes: [CloudTreeNode]? private var deferredReload = false var onDragStateChange: @MainActor (Bool) -> Void = { _ in } @@ -230,6 +233,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { // authoritative pointer boundary for the retained old source. return } + finishMachineLift() if let activeDragSession = activeDragSession ?? sourceView.activeNativeDragSession { supersededDragSession = activeDragSession } @@ -899,12 +903,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { return items } - func item(_ title: String, action: @escaping @MainActor () -> Void) -> NSMenuItem { - let item = CloudTreeMenuItem(title: title, action: action) - item.target = item - return item - } - // MARK: Drag source /// Only the current native writer can reorder machines. Its captured @@ -949,7 +947,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, willBeginAt screenPoint: NSPoint, forItems draggedItems: [Any]) { _ = screenPoint - _ = draggedItems if activeDrag != nil || isDragging { if let activeSession = activeDragSession, activeSession === session { @@ -1009,6 +1006,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { } activeDragSequenceNumber = session.draggingSequenceNumber setDragging(true) + liftMachineDrag(session, draggedItems: draggedItems, in: outlineView) } func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { @@ -1041,6 +1039,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { // the registration for a newer surface drag. return } + finishMachineLift() defer { if let outlineView = outlineView as? CloudTreeNSOutlineView, outlineView.activeNativeDragSession === session { diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 8af48284ff90..057e0b94c4f9 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -158,7 +158,7 @@ struct CloudTreeRowContentView: View { static func groupCount(for kind: CloudTreeNode.Kind) -> CloudTreeGroupCount? { switch kind { case .devicesSection(let section): CloudTreeGroupCount(section.count) - case .cloudMachinesSection(_, let usage?): CloudTreeGroupCount(usage: usage) + case .cloudMachinesSection(_, let usage?, _): CloudTreeGroupCount(usage: usage) case .terminalsPool(_, let count), .displaysPool(_, let count, _): CloudTreeGroupCount(count) default: nil } diff --git a/Sources/Cloud/CloudTreeRowHoverButtons.swift b/Sources/Cloud/CloudTreeRowHoverButtons.swift index 488787cb1b06..e8aefff156da 100644 --- a/Sources/Cloud/CloudTreeRowHoverButtons.swift +++ b/Sources/Cloud/CloudTreeRowHoverButtons.swift @@ -13,13 +13,30 @@ struct CloudTreeRowHoverButtons: View { var body: some View { switch kind { case .devicesSection(let section): - CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) - case .cloudMachinesSection(let canCreateMachine, _): - if canCreateMachine { - plus(String(localized: "machines.new", defaultValue: "New Machine")) { - nodeActions.newMachine() + HStack(spacing: 2) { + refresh(String(localized: "cloudTree.menu.refresh", defaultValue: "Refresh"), isRefreshing: section.isRefreshing) { + nodeActions.refreshDevices() + } + .accessibilityIdentifier("DevicesRefreshButton") + CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) + } + case .cloudMachinesSection(let canCreateMachine, _, let sectionRefresh): + HStack(spacing: 2) { + if let sectionRefresh { + refresh( + String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines"), + isRefreshing: sectionRefresh.isRefreshing + ) { + nodeActions.refreshCloudMachines() + } + .accessibilityIdentifier("CloudRefreshMachinesButton") + } + if canCreateMachine { + plus(String(localized: "machines.new", defaultValue: "New Machine")) { + nodeActions.newMachine() + } + .accessibilityIdentifier("CloudMachinesNewMachineButton") } - .accessibilityIdentifier("CloudMachinesNewMachineButton") } case .machine(let machine, _): // Always visible: New Workspace, and the machine's full context @@ -122,8 +139,8 @@ struct CloudTreeRowHoverButtons: View { switch kind { case .machine, .localMachine, .terminalsPool, .displaysPool, .workspacesGroup, .workspace, .devicesSection: return true - case .cloudMachinesSection(let canCreateMachine, _): - return canCreateMachine + case .cloudMachinesSection(let canCreateMachine, _, let refresh): + return canCreateMachine || refresh != nil case .pendingMachine: return true case .device(let row): @@ -138,8 +155,13 @@ struct CloudTreeRowHoverButtons: View { /// True when the row's buttons stay visible without hover. Machine rows /// keep + and ⋯ on screen so their actions are discoverable at rest. static func showsAtRest(for kind: CloudTreeNode.Kind) -> Bool { - if case .machine = kind { return true } - return false + switch kind { + case .machine: return true + // A running refresh keeps its spinner on screen without hover. + case .cloudMachinesSection(_, _, let refresh): return refresh?.isRefreshing == true + case .devicesSection(let section): return section.isRefreshing + default: return false + } } /// The Displays affordance remains visible while guest discovery is pending @@ -155,6 +177,12 @@ struct CloudTreeRowHoverButtons: View { MachinesChromeIconButton(symbolName: "plus", accessibilityLabel: label, isBusy: false, action: action) } + /// Refresh, with a spinner in its place (and clicks ignored) while it runs. + private func refresh(_ label: String, isRefreshing: Bool, action: @escaping () -> Void) -> some View { + MachinesChromeIconButton(symbolName: "arrow.clockwise", accessibilityLabel: label, isBusy: isRefreshing, action: action) + .disabled(isRefreshing) + } + private func xmark(_ label: String, action: @escaping () -> Void) -> some View { MachinesChromeIconButton(symbolName: "xmark", accessibilityLabel: label, isBusy: false, action: action) } diff --git a/Sources/Cloud/CloudTreeRowToolTip.swift b/Sources/Cloud/CloudTreeRowToolTip.swift index 704c4744d2a1..52a4b52ce817 100644 --- a/Sources/Cloud/CloudTreeRowToolTip.swift +++ b/Sources/Cloud/CloudTreeRowToolTip.swift @@ -83,7 +83,7 @@ enum CloudTreeRowToolTip { toolTip: joined([placeholder.text], beyond: node.searchableTitle), accessibilityLabel: node.searchableTitle ) - case .cloudMachinesSection(_, let usage?): + case .cloudMachinesSection(_, let usage?, _): // The count's display host never hit-tests, so the plan's help rides // on the row, and the row's label keeps VoiceOver from reading the // visible "1/50" as "1 slash 50". diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 5a0efd215e71..f917f1d34d6c 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -246,12 +246,6 @@ struct MachinesPanelView: View { teamScopeLoading } else { content - if includesCloud { - CloudRefreshMachinesButton( - isRefreshing: viewModel.isLoading || devicesModel.isRefreshing, - action: refreshMachines - ) - } } } @@ -486,6 +480,8 @@ struct MachinesPanelView: View { nodeActions.setDeviceIncomingAccess = { [weak devicesModel] enabled in Task { await devicesModel?.preferences?.setIncomingAccessEnabled(enabled) } } + nodeActions.refreshCloudMachines = { [weak viewModel] in viewModel?.refresh(tree: true) } + nodeActions.refreshDevices = { [weak devicesModel] in devicesModel?.refresh() } // The header "+" is Cmd-Y from this window: same gates, sheet, optimistic create, and no workspace until the sheet completes. nodeActions.newMachine = { [weak tabManager] in _ = AppDelegate.shared?.performNewCloudMachineAction( @@ -511,11 +507,13 @@ struct MachinesPanelView: View { discoveryEnabled: includesDevices, incomingAccessEnabled: devicesModel.preferences?.incomingAccessEnabled ?? false, discoveryManaged: discoveryManaged, - incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable() + incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable(), + isRefreshing: devicesModel.isRefreshing ), showsCloudVPNWarning: tunnelStatus.status?.state == .off, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, + cloudMachinesRefresh: includesCloud ? CloudTreeSectionRefresh(isRefreshing: viewModel.isLoading) : nil, reveal: devicesModel.revealRequest ?? selectionReveal, creationReveal: SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.reveals.reveal(for: tabManager) ) diff --git a/Sources/Cloud/Sidebar/CloudMachineReorderDrop.swift b/Sources/Cloud/Sidebar/CloudMachineReorderDrop.swift index e9ac841c2edd..d2fa405ef4a6 100644 --- a/Sources/Cloud/Sidebar/CloudMachineReorderDrop.swift +++ b/Sources/Cloud/Sidebar/CloudMachineReorderDrop.swift @@ -1,8 +1,9 @@ import AppKit import CmuxCloudMachines -/// Resolves a machine drag against the frozen, displayed roots. Descendants -/// are hover targets for their machine's trailing edge, never new parents. +/// Resolves a machine drag against the frozen, displayed machine rows (the +/// roots, or the Cloud Machines section's children). Descendants are hover +/// targets for their machine's trailing edge, never new parents. struct CloudMachineReorderDrop { let machineID: String let move: CloudMachineMove @@ -12,8 +13,6 @@ struct CloudMachineReorderDrop { sourceID: String, nodes: [CloudTreeNode], proposedItem: CloudTreeNode?, proposedChildIndex: Int, dropAfterItem: Bool ) { - guard let source = nodes.first(where: { $0.id == sourceID }), - let machineID = source.machineOrderID else { return nil } let proposedIndex: Int if let proposedItem { guard let rootIndex = nodes.firstIndex(where: { @@ -26,13 +25,29 @@ struct CloudMachineReorderDrop { guard (0...nodes.count).contains(proposedChildIndex) else { return nil } proposedIndex = proposedChildIndex } + self.init(sourceID: sourceID, nodes: nodes, proposedIndex: proposedIndex) + } + + /// A drop at `slot` among the source's peers (its pin tier, without the + /// source), as the lifted drag lays them out. + init?(sourceID: String, nodes: [CloudTreeNode], slot: Int) { + guard let source = nodes.first(where: { $0.id == sourceID }) else { return nil } + let peers = nodes.indices.filter { + nodes[$0].canReorderMachine && nodes[$0].isPinned == source.isPinned && nodes[$0].id != sourceID + } + guard (0...peers.count).contains(slot), let last = peers.last else { return nil } + self.init(sourceID: sourceID, nodes: nodes, proposedIndex: slot < peers.count ? peers[slot] : last + 1) + } + private init?(sourceID: String, nodes: [CloudTreeNode], proposedIndex: Int) { + guard let source = nodes.first(where: { $0.id == sourceID }), + let machineID = source.machineOrderID else { return nil } let peerIndices = nodes.indices.filter { nodes[$0].canReorderMachine && nodes[$0].isPinned == source.isPinned } guard let first = peerIndices.first, let last = peerIndices.last else { return nil } // Match workspace reordering: crossing a pin boundary clamps the - // destination into the original tier, including the painted line. + // destination into the original tier. let index = min(max(proposedIndex, first), last + 1) let peers = peerIndices.map { nodes[$0] } let remaining = peers.filter { $0.id != sourceID } diff --git a/Sources/Cloud/Sidebar/CloudMachineReorderScope.swift b/Sources/Cloud/Sidebar/CloudMachineReorderScope.swift new file mode 100644 index 000000000000..8a14f19d2692 --- /dev/null +++ b/Sources/Cloud/Sidebar/CloudMachineReorderScope.swift @@ -0,0 +1,32 @@ +/// The rows a machine reorders among. A tree with only the fleet lists +/// machines at the root; the live Cloud tab lists them under the Cloud +/// Machines section, ahead of My Devices. +struct CloudMachineReorderScope { + /// The section that holds the machines, nil when they are roots. + let parent: CloudTreeNode? + let siblings: [CloudTreeNode] + + init?(machineNodeID id: String, roots: [CloudTreeNode]) { + if roots.contains(where: { $0.id == id && $0.canReorderMachine }) { + parent = nil + siblings = roots + return + } + guard let section = roots.first(where: { root in + root.children.contains { $0.id == id && $0.canReorderMachine } + }) else { return nil } + parent = section + siblings = section.children + } + + /// Rebuilds the machine rows of `roots` with `reorder`, wherever they sit. + static func replacingMachines( + in roots: [CloudTreeNode], with reorder: ([CloudTreeNode]) -> [CloudTreeNode] + ) -> [CloudTreeNode] { + if roots.contains(where: \.canReorderMachine) { return reorder(roots) } + for root in roots where root.children.contains(where: \.canReorderMachine) { + root.children = reorder(root.children) + } + return roots + } +} diff --git a/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift b/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift index 718c46e23017..1148444e7dc6 100644 --- a/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift +++ b/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift @@ -16,16 +16,28 @@ struct CloudSidebarOrganizationDrop { state: CloudSidebarOrganizationState, proposedItem: CloudTreeNode?, proposedChildIndex: Int, - dropAfterItem: Bool + dropAfterItem: Bool, + machineSlot: Int? = nil ) { - if nodes.contains(where: { $0.id == sourceID && $0.canReorderMachine }) { - guard let drop = CloudMachineReorderDrop( - sourceID: sourceID, nodes: nodes, proposedItem: proposedItem, - proposedChildIndex: proposedChildIndex, dropAfterItem: dropAfterItem - ) else { return nil } + if let scope = CloudMachineReorderScope(machineNodeID: sourceID, roots: nodes) { + let drop: CloudMachineReorderDrop? + if let machineSlot { + // A lifted drag drops where it shows the machine, not where + // AppKit's row proposal happens to point. + drop = CloudMachineReorderDrop(sourceID: sourceID, nodes: scope.siblings, slot: machineSlot) + } else { + // Between two machines in the section, AppKit proposes the + // section itself with a child index. + let item = proposedItem === scope.parent ? nil : proposedItem + drop = CloudMachineReorderDrop( + sourceID: sourceID, nodes: scope.siblings, proposedItem: item, + proposedChildIndex: proposedChildIndex, dropAfterItem: dropAfterItem + ) + } + guard let drop else { return nil } self.sourceID = sourceID - parent = nil - children = nodes + parent = scope.parent + children = scope.siblings childIndex = drop.childIndex operation = .machine(drop.machineID, drop.move) return diff --git a/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift b/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift new file mode 100644 index 000000000000..c2de61aa992f --- /dev/null +++ b/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift @@ -0,0 +1,107 @@ +import AppKit +import QuartzCore + +/// The look of a machine row while the hand holds it: a solid card in the +/// hover highlight's shape with a soft shadow, so the rows it passes over +/// never show through it. +@MainActor +enum CloudTreeMachineLiftStyle { + private static let layerName = "cmux.machineLift.card" + private static let fadeIn: TimeInterval = 0.12 + private static let fadeOut: TimeInterval = 0.28 + private static let shadowOpacity: Float = 0.22 + + static func apply(to rowView: NSTableRowView, animated: Bool) { + guard let host = rowView.layer else { return } + let card = existingCard(in: host) ?? makeCard(in: host) + CATransaction.begin() + CATransaction.setDisableActions(true) + card.frame = cardRect(in: rowView) + card.shadowPath = CGPath( + roundedRect: card.bounds, cornerWidth: CloudTreeHoverStyle.cornerRadius, + cornerHeight: CloudTreeHoverStyle.cornerRadius, transform: nil + ) + card.backgroundColor = fill(for: rowView) + CATransaction.commit() + guard card.opacity != 1 else { return } + if animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + fade(card, to: 1, duration: fadeIn) + } else { + card.removeAllAnimations() + CATransaction.begin() + CATransaction.setDisableActions(true) + card.opacity = 1 + CATransaction.commit() + } + } + + static func remove(from rowView: NSTableRowView, animated: Bool) { + guard let host = rowView.layer, let card = existingCard(in: host), card.opacity != 0 else { return } + if animated { + fade(card, to: 0, duration: fadeOut) + } else { + card.removeAllAnimations() + CATransaction.begin() + CATransaction.setDisableActions(true) + card.opacity = 0 + CATransaction.commit() + } + } + + private static func existingCard(in host: CALayer) -> CALayer? { + host.sublayers?.first { $0.name == layerName } + } + + private static func makeCard(in host: CALayer) -> CALayer { + let card = CALayer() + card.name = layerName + card.opacity = 0 + card.cornerRadius = CloudTreeHoverStyle.cornerRadius + card.cornerCurve = .continuous + card.shadowColor = NSColor.black.cgColor + card.shadowOpacity = shadowOpacity + card.shadowRadius = 8 + card.shadowOffset = .zero + // Below the hover fill and the cell, above nothing: the card replaces + // the row's transparent ground while it is lifted. + host.insertSublayer(card, at: 0) + return card + } + + /// The hover highlight's horizontal extent over the full row height, so + /// the card reads as the row's own highlight, raised. + private static func cardRect(in rowView: NSTableRowView) -> CGRect { + let bounds = rowView.bounds + let leading = (rowView as? CloudTreeRowView)?.highlightLeading ?? CloudTreeHoverStyle.horizontalInset + let x = min(leading, max(0, bounds.width - CloudTreeHoverStyle.horizontalInset)) + return CGRect( + x: bounds.minX + x, y: bounds.minY, + width: max(0, bounds.width - x - CloudTreeHoverStyle.horizontalInset), height: bounds.height + ) + } + + /// The window ground with the row's own highlight mixed in, so a selected + /// machine keeps reading as selected while it is lifted. + private static func fill(for rowView: NSTableRowView) -> CGColor { + var color = NSColor.windowBackgroundColor.cgColor + rowView.effectiveAppearance.performAsCurrentDrawingAppearance { + let tint = rowView.isSelected ? CloudTreeHoverStyle.selectedOpacity : CloudTreeHoverStyle.hoverOpacity + let base = NSColor.windowBackgroundColor.usingColorSpace(.deviceRGB) ?? .windowBackgroundColor + color = base.blended(withFraction: tint, of: .labelColor)?.cgColor ?? base.cgColor + } + return color + } + + private static func fade(_ card: CALayer, to opacity: Float, duration: TimeInterval) { + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = (card.presentation() ?? card).opacity + animation.toValue = opacity + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + card.add(animation, forKey: "cmux.machineLift.fade") + CATransaction.begin() + CATransaction.setDisableActions(true) + card.opacity = opacity + CATransaction.commit() + } +} diff --git a/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift new file mode 100644 index 000000000000..639f8dd6a41b --- /dev/null +++ b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift @@ -0,0 +1,387 @@ +import AppKit +import CmuxCloud +import QuartzCore + +/// The visuals of a continuous machine drag in the Cloud tree. +/// +/// There is no drag image and no insertion line: the real machine row is +/// what the hand holds. Open machines close for the drag so every machine is +/// one row, the peers part around the lifted row on springs, and the release +/// lands each row from wherever it is on screen. The model is untouched until +/// the drop; during the drag everything is a layer transform over an +/// unchanged outline, laid out by `CloudTreeReorderLiftLayout`. +@MainActor +final class CloudTreeMachineReorderLift: NSObject { + private weak var outline: CloudTreeNSOutlineView? + + private struct Session { + let sequence: Int + let sourceID: String + let layout: CloudTreeReorderLiftLayout + let sourceRows: Range + /// The pointer's y, in the collapsed outline, that leaves the row at rest. + let grabY: CGFloat + /// Machines closed for the drag, opened again when it ends. + let collapsedIDs: [String] + var placement: CloudTreeReorderLiftLayout.Placement + /// The translation each row was last sent toward, so a row only + /// starts a new glide when its target flips. + var targets: [Int: CGFloat] = [:] + } + + private var session: Session? + private var displayLink: CADisplayLink? + /// Row views this drag has moved or styled, reset when it ends. + private let touched = NSHashTable.weakObjects() + private var isFinishing = false + + private static let shiftKey = "cmux.machineLift.shift" + private static let liftZ: CGFloat = 10 + + init(outline: CloudTreeNSOutlineView) { + self.outline = outline + } + + /// The node the hand holds, so hover can stay on it. + var sourceNodeID: String? { session?.sourceID } + + func isActive(sequence: Int) -> Bool { session?.sequence == sequence } + + // MARK: Begin + + /// Lifts `source` out of `siblings`. `collapse` closes the given machines + /// without recording it as the person's choice. + /// `pressY` is where the press landed, before anything closed; it + /// defaults to the outline's last mouse-down. + func begin( + sequence: Int, source: CloudTreeNode, siblings: [CloudTreeNode], pressY: CGFloat? = nil, + collapse: ([CloudTreeNode]) -> Void + ) { + guard let outline else { return } + discard() + let before = visualTops() + let closing = siblings.filter { node in + if case .machine = node.kind { return outline.isItemExpanded(node) } + return false + } + let ghosts = closing.isEmpty ? [] : makeGhosts(under: Set(closing.map(\.id))) + if !closing.isEmpty { collapse(closing) } + + let frames = (0..? + for sibling in siblings { + let row = outline.row(forItem: sibling) + guard row >= 0 else { continue } + let level = outline.level(forRow: row) + var end = row + 1 + while end < outline.numberOfRows, outline.level(forRow: end) > level { end += 1 } + if sibling === source { + sourceIndex = blocks.count + sourceRows = row.. Int? { + guard var session, let outline else { return nil } + let placement = session.layout.placement(dragOffset: pointerY - session.grabY) + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + outline.enumerateAvailableRowViews { rowView, row in + rowView.wantsLayer = true + guard let layer = rowView.layer else { return } + touched.add(rowView) + if session.sourceRows.contains(row) { + // Direct manipulation: never smoothed, or the row lags the hand. + layer.removeAnimation(forKey: Self.shiftKey) + Self.setShift(placement.sourceOffset, on: layer) + layer.zPosition = Self.liftZ + CloudTreeMachineLiftStyle.apply(to: rowView, animated: true) + return + } + CloudTreeMachineLiftStyle.remove(from: rowView, animated: false) + layer.zPosition = 0 + let target = placement.rowOffsets[row] ?? 0 + if session.targets[row, default: 0] != target { + session.targets[row] = target + if reduceMotion { + layer.removeAnimation(forKey: Self.shiftKey) + Self.setShift(target, on: layer) + } else { + Self.glide(layer, to: target) + } + } else if layer.animation(forKey: Self.shiftKey) == nil { + // A row view reused while scrolling arrives with its last + // tenant's transform; hold every visible row on its target. + Self.setShift(target, on: layer) + } + } + session.placement = placement + self.session = session + return placement.slot + } + + /// The slot the lifted row shows, for the drop to commit. + var slot: Int? { session?.placement.slot } + + // MARK: End + + /// Ends the drag. `mutate` lands the new order (a drop), or nil for a + /// cancel; either way the machines closed for the drag open again and + /// every row springs from where it is on screen to where it belongs. + func finish(reopen: ([String]) -> Void, mutate: (() -> Bool)? = nil) -> Bool { + guard let session, outline != nil else { return false } + displayLink?.invalidate() + displayLink = nil + let before = visualTops() + resetTouched() + self.session = nil + isFinishing = true + let result = mutate?() ?? false + reopen(session.collapsedIDs) + isFinishing = false + land(from: before, excluding: nil, fadingIn: true, liftedID: session.sourceID) + return result + } + + /// Drops the visuals at once: the outline is about to reload under the + /// drag, or it is leaving its window. + func discard() { + guard !isFinishing else { return } + displayLink?.invalidate() + displayLink = nil + session = nil + resetTouched() + } + + // MARK: Geometry + + private func pointerY() -> CGFloat? { + guard let outline, let window = outline.window else { return nil } + let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) + return outline.convert(windowPoint, from: nil).y + } + + /// Where each visible row is on screen right now, by node id: its frame + /// plus whatever translation it is showing mid-flight. + private func visualTops() -> [String: CGFloat] { + guard let outline else { return [:] } + var tops: [String: CGFloat] = [:] + outline.enumerateAvailableRowViews { rowView, row in + guard let node = outline.item(atRow: row) as? CloudTreeNode else { return } + let shift = (rowView.layer?.presentation() ?? rowView.layer) + .flatMap { $0.value(forKeyPath: "transform.translation.y") as? CGFloat } ?? 0 + tops[node.id] = outline.rect(ofRow: row).minY + shift + } + return tops + } + + /// Springs every visible row from its old on-screen top to its frame. + /// A row that was not on screen before (a machine's rows opening again) + /// travels with its machine and fades in. + private func land( + from before: [String: CGFloat], excluding excludedID: String?, fadingIn: Bool, liftedID: String? = nil + ) { + guard let outline else { return } + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + // The lifted row stays above its neighbours until every glide in + // this transaction has landed. + CATransaction.begin() + CATransaction.setCompletionBlock { [weak self] in + MainActor.assumeIsolated { + guard let self, self.session == nil else { return } + for rowView in self.touched.allObjects { rowView.layer?.zPosition = 0 } + } + } + defer { CATransaction.commit() } + outline.enumerateAvailableRowViews { rowView, row in + guard let node = outline.item(atRow: row) as? CloudTreeNode, node.id != excludedID else { return } + rowView.wantsLayer = true + guard let layer = rowView.layer else { return } + let top = outline.rect(ofRow: row).minY + var delta: CGFloat? + var appearing = false + if let old = before[node.id] { + delta = old - top + } else if fadingIn { + appearing = true + var parent = outline.parent(forItem: node) as? CloudTreeNode + while let candidate = parent, before[candidate.id] == nil { + parent = outline.parent(forItem: candidate) as? CloudTreeNode + } + if let parent, let old = before[parent.id] { + let parentRow = outline.row(forItem: parent) + if parentRow >= 0 { delta = old - outline.rect(ofRow: parentRow).minY } + } + } + touched.add(rowView) + if node.id == liftedID { + layer.zPosition = Self.liftZ + CloudTreeMachineLiftStyle.apply(to: rowView, animated: false) + CloudTreeMachineLiftStyle.remove(from: rowView, animated: !reduceMotion) + } + guard !reduceMotion else { return } + if let delta, abs(delta) > 0.5 { + Self.setShift(delta, on: layer) + Self.glide(layer, to: 0) + } + if appearing { + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = 0 + fade.toValue = 1 + fade.duration = 0.22 + fade.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.add(fade, forKey: "cmux.machineLift.fadeIn") + } + } + } + + private func resetTouched() { + for rowView in touched.allObjects { + guard let layer = rowView.layer else { continue } + layer.removeAnimation(forKey: Self.shiftKey) + Self.setShift(0, on: layer) + layer.zPosition = 0 + CloudTreeMachineLiftStyle.remove(from: rowView, animated: false) + } + touched.removeAllObjects() + } + + // MARK: Closing machines + + /// Pictures of the rows that are about to close, so they can fade out + /// into their machine instead of vanishing. + private struct Ghost { + let layer: CALayer + let machineID: String + } + + private func makeGhosts(under machineIDs: Set) -> [Ghost] { + guard let outline, let host = outline.layer, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return [] } + var ghosts: [Ghost] = [] + outline.enumerateAvailableRowViews { rowView, row in + guard var item = outline.item(atRow: row) else { return } + var machineID: String? + while let parent = outline.parent(forItem: item) { + if let node = parent as? CloudTreeNode, machineIDs.contains(node.id) { machineID = node.id } + item = parent + } + guard let machineID, + let bitmap = rowView.bitmapImageRepForCachingDisplay(in: rowView.bounds) else { return } + rowView.cacheDisplay(in: rowView.bounds, to: bitmap) + let image = NSImage(size: rowView.bounds.size) + image.addRepresentation(bitmap) + let layer = CALayer() + layer.contents = image + layer.contentsGravity = .resize + layer.frame = rowView.frame + host.addSublayer(layer) + ghosts.append(Ghost(layer: layer, machineID: machineID)) + } + return ghosts + } + + private func animateGhosts(_ ghosts: [Ghost], before: [String: CGFloat]) { + guard let outline, !ghosts.isEmpty else { return } + CATransaction.begin() + CATransaction.setCompletionBlock { + MainActor.assumeIsolated { ghosts.forEach { $0.layer.removeFromSuperlayer() } } + } + for ghost in ghosts { + // The rows fold toward where their machine now stands. + var travel: CGFloat = 0 + if let machine = outline.findItem(nodeID: ghost.machineID), let old = before[ghost.machineID] { + let row = outline.row(forItem: machine) + if row >= 0 { travel = outline.rect(ofRow: row).minY - old } + } + let move = CABasicAnimation(keyPath: "transform.translation.y") + move.fromValue = 0 + move.toValue = travel + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = 1 + fade.toValue = 0 + let group = CAAnimationGroup() + group.animations = [move, fade] + group.duration = 0.18 + group.timingFunction = CAMediaTimingFunction(name: .easeOut) + ghost.layer.opacity = 0 + ghost.layer.transform = CATransform3DMakeTranslation(0, travel, 0) + ghost.layer.add(group, forKey: "cmux.machineLift.ghost") + } + CATransaction.commit() + } + + // MARK: Layer motion + + private static func setShift(_ y: CGFloat, on layer: CALayer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DMakeTranslation(0, y, 0) + CATransaction.commit() + } + + /// Glides a row's vertical shift to `target` on a soft spring, starting + /// from where the row is on screen so a retarget mid-glide never jumps. + /// Explicit animation: a view's backing layer ignores implicit actions. + private static func glide(_ layer: CALayer, to target: CGFloat) { + let current = (layer.presentation() ?? layer).value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 + let spring = CASpringAnimation(keyPath: "transform.translation.y") + spring.fromValue = current + spring.toValue = target + spring.mass = 1 + spring.stiffness = 300 + spring.damping = 30 + spring.duration = spring.settlingDuration + layer.add(spring, forKey: shiftKey) + setShift(target, on: layer) + } +} + +extension NSOutlineView { + /// The displayed item for a Cloud node id, if it is on a visible row. + func findItem(nodeID: String) -> CloudTreeNode? { + for row in 0.. Int? { + guard let outline = outlineView as? CloudTreeNSOutlineView, + outline.machineLift.isActive(sequence: info.draggingSequenceNumber) else { return nil } + return outline.machineLift.update(pointerY: outline.convert(info.draggingLocation, from: nil).y) + } + + func isMachineLiftActive(_ outlineView: NSOutlineView, info: any NSDraggingInfo) -> Bool { + (outlineView as? CloudTreeNSOutlineView)?.machineLift.isActive(sequence: info.draggingSequenceNumber) == true + } + + /// Ends the lift: `commit` lands a drop, nil cancels (Escape, a release + /// outside the tree, a refused slot, or a press after a drag whose end + /// was never reported). Returns the commit's result. + @discardableResult + func finishMachineLift(commit: (() -> Bool)? = nil) -> Bool { + guard let outline = outlineView else { return false } + return outline.machineLift.finish(reopen: { [weak self] ids in + guard let self else { return } + withProgrammaticUpdate { + for id in ids { + if let machine = outline.findItem(nodeID: id), !outline.isItemExpanded(machine) { + outline.expandItem(machine) + } + } + restoreSelection(in: outline) + } + }, mutate: commit) + } +} diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineOrdering.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineOrdering.swift index 17af0eb850f6..183fba7ec01c 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineOrdering.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineOrdering.swift @@ -63,19 +63,24 @@ extension CloudTreeOutlineView.Coordinator { /// adopted creation node IDs, child objects, and the outline's selection /// and expansion restoration; it never projects or reconnects a machine. func applyMachineOrder(_ machines: [MachineSnapshot]) { - let current = organizationNodes - let roots = Dictionary(current.compactMap { node -> (String, CloudTreeNode)? in - guard let id = node.machineOrderID else { return nil } - return (id, node) - }, uniquingKeysWith: { first, _ in first }) - var updated = machines.compactMap { machine -> CloudTreeNode? in - guard let node = roots[machine.id], case .machine(_, let info) = node.kind else { return nil } - return CloudTreeNode( - id: node.id, kind: .machine(machine, info), children: node.children, isPinned: machine.isPinned - ) - }.makeIterator() - applyOrganization(nodes: current.compactMap { node in - node.canReorderMachine ? updated.next() : node + applyOrganization(nodes: CloudMachineReorderScope.replacingMachines(in: organizationNodes) { siblings in + let rows = Dictionary(siblings.compactMap { node -> (String, CloudTreeNode)? in + guard let id = node.machineOrderID else { return nil } + return (id, node) + }, uniquingKeysWith: { first, _ in first }) + var updated = machines.compactMap { machine -> CloudTreeNode? in + guard let node = rows[machine.id], case .machine(_, let info) = node.kind else { return nil } + let moved = CloudTreeNode( + id: node.id, kind: .machine(machine, info), children: node.children, isPinned: machine.isPinned + ) + // The tab row rebuilds from these pools when another tab opens. + moved.detailPools = node.detailPools + moved.resourceSection = node.resourceSection + return moved + }.makeIterator() + return siblings.compactMap { node in + node.canReorderMachine ? updated.next() : node + } }) } } diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift index 4c7e9df89eba..6d3ceead9bad 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift @@ -60,7 +60,12 @@ extension CloudTreeOutlineView.Coordinator { outlineView.setDropItem(drop.parent, dropChildIndex: drop.childIndex) if let cloudOutline = outlineView as? CloudTreeNSOutlineView { cloudOutline.trackDragDestination(sequenceNumber: info.draggingSequenceNumber) - cloudOutline.reorderPresentation.show(drop, sequence: info.draggingSequenceNumber) + // Machines show their destination by the rows parting, never a line. + if case .organization = drop.operation { + cloudOutline.reorderPresentation.show(drop, sequence: info.draggingSequenceNumber) + } else { + cloudOutline.reorderPresentation.clear(sequence: info.draggingSequenceNumber) + } } return .move } @@ -75,7 +80,14 @@ extension CloudTreeOutlineView.Coordinator { return organize(action, nodeID: drop.sourceID) case .machine(let id, let move): guard let actions = machineOrdering(for: info, nodeID: drop.sourceID) else { return false } - return moveMachine(id, move: move, using: actions) + guard isMachineLiftActive(outlineView, info: info) else { + return moveMachine(id, move: move, using: actions) + } + // The rows already stand in the new order; the commit reloads + // under them and the lift lands each one from where it is. + return finishMachineLift { [weak self] in + self?.moveMachine(id, move: move, using: actions) ?? false + } } } @@ -113,7 +125,8 @@ extension CloudTreeOutlineView.Coordinator { guard let drop = CloudSidebarOrganizationDrop( sourceID: id, nodes: nodes, state: organization.state, proposedItem: item as? CloudTreeNode, proposedChildIndex: index, - dropAfterItem: row >= 0 && point.y >= outlineView.rect(ofRow: row).midY + dropAfterItem: row >= 0 && point.y >= outlineView.rect(ofRow: row).midY, + machineSlot: machineLiftSlot(outlineView, info: info) ) else { return nil } if case .machine(let machineID, let move) = drop.operation { guard machineOrdering(for: info, nodeID: id)?.canMove(machineID, move) == true else { return nil } diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 0b26dd993d75..27e196ed7f33 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -135,6 +135,9 @@ struct RightSidebarChromePillModifier: ViewModifier { var isHovered: Bool var horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding var geometryKeyPrefix: String? + /// When set, the selected fill is one shared shape that moves between + /// the pills of this namespace instead of each pill painting its own. + var selectionNamespace: Namespace.ID? @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent func body(content: Content) -> some View { @@ -148,10 +151,20 @@ struct RightSidebarChromePillModifier: ViewModifier { keyPrefix: geometryKeyPrefix, isVisible: true ) - .background( - RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) - .fill(backgroundColor) - ) + .background { + let shape = RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) + if let selectionNamespace { + ZStack { + shape.fill(isSelected ? Color.clear : backgroundColor) + if isSelected { + shape.fill(backgroundColor) + .matchedGeometryEffect(id: "rightSidebarChromePillSelection", in: selectionNamespace) + } + } + } else { + shape.fill(backgroundColor) + } + } .contentShape( RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) ) @@ -279,14 +292,16 @@ extension View { isSelected: Bool, isHovered: Bool, horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding, - geometryKeyPrefix: String? = nil + geometryKeyPrefix: String? = nil, + selectionNamespace: Namespace.ID? = nil ) -> some View { modifier( RightSidebarChromePillModifier( isSelected: isSelected, isHovered: isHovered, horizontalPadding: horizontalPadding, - geometryKeyPrefix: geometryKeyPrefix + geometryKeyPrefix: geometryKeyPrefix, + selectionNamespace: selectionNamespace ) ) } @@ -362,6 +377,7 @@ struct ModeBarButton: View { let item: RightSidebarModeBarItem let isSelected: Bool var badgeCount: Int = 0 + var selectionNamespace: Namespace.ID? = nil let shortcutHint: StoredShortcut let showsShortcutHint: Bool let action: () -> Void @@ -371,10 +387,18 @@ struct ModeBarButton: View { /// ellipsis; the tab then shows only its icon. The label keeps its slot, /// so hiding it never changes the tab's width. @State private var labelFits = true + @State private var labelWidth: CGFloat = 0 + + /// With its label hidden, the icon (and badge) moves into the middle of + /// the label's empty slot, so it sits centered in the tab's highlight. + private var hiddenLabelShift: CGFloat { + labelFits ? 0 : (labelWidth + Self.contentSpacing) / 2 + } + private static let contentSpacing: CGFloat = 4 var body: some View { Button(action: action) { - HStack(spacing: 4) { + HStack(spacing: Self.contentSpacing) { CmuxSystemSymbolImage( systemName: item.symbolName, pointSize: RightSidebarChromeControlStyle.modeIconSize, @@ -386,6 +410,7 @@ struct ModeBarButton: View { keyPrefix: "rightSidebarModeIcon_\(item.id)", isVisible: true ) + .offset(x: badgeCount > 0 ? 0 : hiddenLabelShift) Text(item.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, @@ -394,9 +419,12 @@ struct ModeBarButton: View { .lineLimit(1) .truncationMode(.tail) .opacity(labelFits ? 1 : 0) - .onGeometryChange(for: Bool.self) { proxy in - proxy.size.width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) - } action: { labelFits = $0 } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { width in + labelWidth = width + labelFits = width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) + } if badgeCount > 0 { pendingChip } @@ -404,7 +432,8 @@ struct ModeBarButton: View { .rightSidebarChromePill( isSelected: isSelected, isHovered: isHovered, - geometryKeyPrefix: "rightSidebarModeControl_\(item.id)" + geometryKeyPrefix: "rightSidebarModeControl_\(item.id)", + selectionNamespace: selectionNamespace ) .overlay(alignment: .trailing) { if showsShortcutHint { diff --git a/Sources/RightSidebarModeBarDrag.swift b/Sources/RightSidebarModeBarDrag.swift new file mode 100644 index 000000000000..30ae1fc229bf --- /dev/null +++ b/Sources/RightSidebarModeBarDrag.swift @@ -0,0 +1,277 @@ +import AppKit +import Observation +import SwiftUI + +/// Owns a mode bar tab drag. Inside the bar the real tab follows the pointer +/// and the others glide aside (`RightSidebarModeBarDragLayout`); the order is +/// saved once, on release, and the dragged tab springs from where it is into +/// its slot. Carried out of the bar, the tab hands off to a native drag so it +/// can still open as a pane, and the bar settles back. +@MainActor +@Observable +final class RightSidebarModeBarDragController { + static let coordinateSpace = "RightSidebarModeBarTabs" + /// Gentle and nearly critically damped: the tabs part without bouncing. + static let spring = Animation.spring(response: 0.28, dampingFraction: 0.86) + + struct Session { + let mode: RightSidebarMode + let modes: [RightSidebarMode] + let layout: RightSidebarModeBarDragLayout + /// Identifies the gesture: a new press starts a new session. + let startLocation: CGPoint + var slot: Int + var offsets: [CGFloat] + /// The tab left the bar and a native drag carries it. + var isCarriedOut = false + } + + private(set) var session: Session? + /// The pointer's horizontal travel. Kept apart from `session` so the + /// dragged tab tracks the pointer without animation while the slot + /// changes animate. + private(set) var translation: CGFloat = 0 + + /// Every tab's frame in `coordinateSpace`, frozen into a session at drag start. + @ObservationIgnored var frames: [RightSidebarMode: CGRect] = [:] + let anchor = RightSidebarModeBarDragAnchor() + @ObservationIgnored private var paneDrag: RightSidebarModePaneDragSource? + @ObservationIgnored private var mouseUpMonitor: Any? + + func isLifted(_ mode: RightSidebarMode) -> Bool { + guard let session else { return false } + return session.mode == mode && !session.isCarriedOut + } + + func offset(for mode: RightSidebarMode) -> CGFloat { + guard let session, let index = session.modes.firstIndex(of: mode) else { return 0 } + if isLifted(mode) { return session.layout.draggedOffset(translation: translation) } + return session.offsets[index] + } + + func changed( + mode: RightSidebarMode, + displayed: [RightSidebarMode], + startLocation: CGPoint, + location: CGPoint, + translation travel: CGSize, + barHeight: CGFloat, + animation: Animation?, + dragImage: @MainActor (CGSize) -> NSImage? + ) { + if session?.mode != mode || session?.startLocation != startLocation { + guard begin(mode: mode, displayed: displayed, startLocation: startLocation) else { return } + } + guard var current = session, !current.isCarriedOut else { return } + let tabFrame = current.layout.frames[current.layout.source] + if mode.canOpenAsPane, + RightSidebarModeBarDragLayout.leavesBar(pointerY: location.y, tabFrame: tabFrame, barHeight: barHeight), + carryOut(current, tabFrame: tabFrame, travel: travel, animation: animation, dragImage: dragImage) { + return + } + translation = travel.width + let slot = current.layout.slot(translation: translation) + guard slot != current.slot else { return } + current.slot = slot + current.offsets = current.layout.offsets(slot: slot) + withAnimation(animation) { session = current } + } + + /// Saves the order the bar shows and lets every tab settle in one + /// transaction, so the dropped tab glides from the pointer into its slot. + func ended(mode: RightSidebarMode, startLocation: CGPoint, animation: Animation?) { + guard let current = session, current.mode == mode, current.startLocation == startLocation else { return } + commit(current, animation: animation) + } + + private func begin(mode: RightSidebarMode, displayed: [RightSidebarMode], startLocation: CGPoint) -> Bool { + // A newer press proves AppKit has left any earlier carried-out drag. + paneDrag?.finish() + removeMouseUpMonitor() + let frames = displayed.compactMap { self.frames[$0] } + guard frames.count == displayed.count, + let source = displayed.firstIndex(of: mode), + let layout = RightSidebarModeBarDragLayout(frames: frames, source: source) else { + session = nil + return false + } + translation = 0 + session = Session( + mode: mode, modes: displayed, layout: layout, startLocation: startLocation, + slot: source, offsets: Array(repeating: 0, count: displayed.count) + ) + // SwiftUI can drop a drag's end (the gesture is cancelled, or the + // release lands outside the window). The release itself always comes + // through the app, so it ends the session too. + mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in + MainActor.assumeIsolated { + if let self, let current = self.session, !current.isCarriedOut { + self.commit(current, animation: self.lastAnimation) + } + } + return event + } + return true + } + + /// The animation of the latest gesture update, for the release monitor. + @ObservationIgnored private var lastAnimation: Animation? = RightSidebarModeBarDragController.spring + + private func commit(_ current: Session, animation: Animation?) { + removeMouseUpMonitor() + guard !current.isCarriedOut else { return } + let order = current.layout.reordered(current.modes, slot: current.slot) + withAnimation(animation) { + // The bar reads the saved order in the same update that clears + // the offsets, so position and offset animate together. + if current.slot != current.layout.source { + RightSidebarTabPreferences.setDisplayedOrder(order) + } + session = nil + translation = 0 + } + } + + private func carryOut( + _ current: Session, + tabFrame: CGRect, + travel: CGSize, + animation: Animation?, + dragImage: @MainActor (CGSize) -> NSImage? + ) -> Bool { + guard let view = anchor.view, + let event = NSApp.currentEvent, event.type == .leftMouseDragged, + let image = dragImage(tabFrame.size) else { return false } + let frame = tabFrame.offsetBy(dx: current.layout.draggedOffset(translation: translation), dy: travel.height) + guard let source = RightSidebarModeDragPayload.beginPaneDrag( + mode: current.mode, from: view, event: event, frame: frame, image: image, + onEnd: { [weak self] in self?.paneDragEnded() } + ) else { return false } + removeMouseUpMonitor() + paneDrag = source + var next = current + next.isCarriedOut = true + next.slot = current.layout.source + next.offsets = Array(repeating: 0, count: current.modes.count) + withAnimation(animation) { + session = next + translation = 0 + } + return true + } + + private func paneDragEnded() { + paneDrag = nil + if session?.isCarriedOut == true { session = nil } + } + + private func removeMouseUpMonitor() { + if let mouseUpMonitor { NSEvent.removeMonitor(mouseUpMonitor) } + mouseUpMonitor = nil + } + + fileprivate func noteAnimation(_ animation: Animation?) { + lastAnimation = animation + } +} + +/// The view a carried-out tab's native drag starts from: the tab row's +/// background, flipped so its coordinates match `coordinateSpace`. +@MainActor +final class RightSidebarModeBarDragAnchor { + weak var view: NSView? +} + +struct RightSidebarModeBarDragAnchorView: NSViewRepresentable { + let anchor: RightSidebarModeBarDragAnchor + + func makeNSView(context: Context) -> NSView { + let view = AnchorView() + anchor.view = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + anchor.view = nsView + } + + private final class AnchorView: NSView { + override var isFlipped: Bool { true } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } +} + +/// Makes one mode bar tab draggable along the bar and out of it. +struct RightSidebarModeBarTabDrag: ViewModifier { + let mode: RightSidebarMode + let displayedModes: [RightSidebarMode] + let barHeight: CGFloat + let controller: RightSidebarModeBarDragController + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var colorScheme + + private var animation: Animation? { reduceMotion ? nil : RightSidebarModeBarDragController.spring } + + func body(content: Content) -> some View { + let lifted = controller.isLifted(mode) + content + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named(RightSidebarModeBarDragController.coordinateSpace)) + } action: { frame in + controller.frames[mode] = frame + } + .offset(x: controller.offset(for: mode)) + // The lifted tab is under the hand: it never animates while held. + .transaction { transaction in + if lifted { transaction.animation = nil } + } + .zIndex(lifted ? 1 : 0) + .highPriorityGesture( + DragGesture(minimumDistance: 3, coordinateSpace: .named(RightSidebarModeBarDragController.coordinateSpace)) + .onChanged { value in + controller.noteAnimation(animation) + controller.changed( + mode: mode, displayed: displayedModes, + startLocation: value.startLocation, location: value.location, + translation: value.translation, barHeight: barHeight, + animation: animation, dragImage: dragImage + ) + } + .onEnded { value in + controller.ended(mode: mode, startLocation: value.startLocation, animation: animation) + } + ) + } + + @MainActor + private func dragImage(size: CGSize) -> NSImage? { + let renderer = ImageRenderer( + content: RightSidebarModeBarDragPreview(mode: mode) + .frame(width: size.width, height: size.height) + .environment(\.colorScheme, colorScheme) + ) + renderer.scale = controller.anchor.view?.window?.backingScaleFactor ?? 2 + return renderer.nsImage + } +} + +/// The tab as it looks selected, for the image a carried-out drag shows. +private struct RightSidebarModeBarDragPreview: View { + let mode: RightSidebarMode + + var body: some View { + HStack(spacing: 4) { + CmuxSystemSymbolImage( + systemName: mode.symbolName, + pointSize: RightSidebarChromeControlStyle.modeIconSize, + weight: RightSidebarChromeControlStyle.iconWeight, + tint: RightSidebarChromeControlStyle.pillForegroundColor(isSelected: true, isHovered: true), + appliesGlobalFontMagnification: true + ) + Text(mode.label) + .cmuxFont(size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight) + .lineLimit(1) + } + .rightSidebarChromePill(isSelected: true, isHovered: true) + } +} diff --git a/Sources/RightSidebarModeBarDragLayout.swift b/Sources/RightSidebarModeBarDragLayout.swift new file mode 100644 index 000000000000..455df9d0287b --- /dev/null +++ b/Sources/RightSidebarModeBarDragLayout.swift @@ -0,0 +1,96 @@ +import CoreGraphics + +/// Geometry for dragging one of the right sidebar's mode tabs along the bar. +/// +/// The dragged tab follows the pointer and the others part around it. Every +/// decision is made against the tab frames frozen when the drag starts, so +/// slot boundaries cannot move mid-drag and the tabs cannot oscillate. +/// Nothing is saved until the drop. +struct RightSidebarModeBarDragLayout: Equatable { + /// How far past the bar's ends the tab can be pulled before it stops, in points. + static let overscrollLimit: CGFloat = 16 + + /// Tab frames in display order, frozen at drag start. + let frames: [CGRect] + /// The dragged tab's index in `frames`. + let source: Int + + init?(frames: [CGRect], source: Int) { + guard frames.indices.contains(source), frames.allSatisfy({ $0.width > 0 }) else { return nil } + self.frames = frames + self.source = source + } + + /// The dragged tab's translation: the pointer's, held inside the bar, + /// with a little resistance past either end. + func draggedOffset(translation: CGFloat) -> CGFloat { + let held = heldOffset(translation) + return held + Self.overscroll(translation - held) + } + + /// The index the dragged tab takes once dropped. A neighbour gives way + /// once the tab covers half of the narrower of the two, so a wide tab + /// does not have to be crossed halfway before a narrow one moves. + func slot(translation: CGFloat) -> Int { + let dragged = frames[source] + let minX = dragged.minX + heldOffset(translation) + let maxX = minX + dragged.width + var slot = 0 + for index in frames.indices where index != source { + let reach = min(frames[index].width, dragged.width) / 2 + if index < source { + if minX >= frames[index].maxX - reach { slot += 1 } + } else if maxX > frames[index].minX + reach { + slot += 1 + } + } + return slot + } + + /// Every tab's translation with the dragged tab dropped at `slot`. The + /// dragged tab's entry is where it would land; the gaps between + /// positions keep their widths. + func offsets(slot: Int) -> [CGFloat] { + var offsets = Array(repeating: CGFloat(0), count: frames.count) + var x = frames[0].minX + for (position, index) in reordered(Array(frames.indices), slot: slot).enumerated() { + offsets[index] = x - frames[index].minX + x += frames[index].width + if position + 1 < frames.count { + x += frames[position + 1].minX - frames[position].maxX + } + } + return offsets + } + + /// `items` (one per tab, in display order) with the dragged one at `slot`. + func reordered(_ items: [Item], slot: Int) -> [Item] { + guard items.count == frames.count else { return items } + var result = items + let item = result.remove(at: source) + result.insert(item, at: min(max(slot, 0), result.count)) + return result + } + + /// True once the pointer is far enough above or below the bar that the + /// tab is being carried out of it, toward a pane. + static func leavesBar(pointerY: CGFloat, tabFrame: CGRect, barHeight: CGFloat) -> Bool { + let margin = max(barHeight, tabFrame.height) / 2 + return pointerY < tabFrame.minY - margin || pointerY > tabFrame.maxY + margin + } + + private func heldOffset(_ translation: CGFloat) -> CGFloat { + let dragged = frames[source] + let minOffset = frames[0].minX - dragged.minX + let maxOffset = frames[frames.count - 1].maxX - dragged.maxX + return min(max(translation, minOffset), maxOffset) + } + + /// Resistance past the bar's ends: the tab follows a little, then stops. + static func overscroll(_ excess: CGFloat) -> CGFloat { + guard excess != 0 else { return 0 } + let limit = overscrollLimit + let pulled = (1 - 1 / (abs(excess) * 0.55 / limit + 1)) * limit + return excess < 0 ? -pulled : pulled + } +} diff --git a/Sources/RightSidebarModeDragPayload.swift b/Sources/RightSidebarModeDragPayload.swift index 43acc41c925e..99ea1fd2d9a2 100644 --- a/Sources/RightSidebarModeDragPayload.swift +++ b/Sources/RightSidebarModeDragPayload.swift @@ -2,37 +2,103 @@ import AppKit import Bonsplit import UniformTypeIdentifiers -/// Drag payload for reordering the mode bar's tabs in place. Same shape as -/// `SidebarTabDragPayload`: an in-process custom UTI (declared in -/// `Resources/Info.plist` under `UTExportedTypeDeclarations`) carrying the -/// dragged mode's raw value. +/// Native drag for a mode bar tab carried out of the bar toward a pane. +/// Reordering inside the bar never starts a drag session +/// (`RightSidebarModeBarDragController`); this only runs once the tab leaves +/// the bar. Same shape as a Vault row's drag (`SessionDragCoordinator`): the +/// pane destinations resolve the tool from Bonsplit's process-local capability, +/// and the in-process custom UTI (declared in `Resources/Info.plist` under +/// `UTExportedTypeDeclarations`) carries the mode's raw value. enum RightSidebarModeDragPayload { static let typeIdentifier = "com.cmux.right-sidebar-mode-reorder" static let dropContentType = UTType(exportedAs: typeIdentifier) + /// Starts the native drag from `sourceView`, whose coordinates `frame` is + /// in. Returns nil, leaving nothing registered, when the mode cannot open + /// as a pane or AppKit rejects the capability. @MainActor - static func provider(for mode: RightSidebarMode, registry: TabDragTransferRegistry? = nil) -> NSItemProvider { - let provider = NSItemProvider() - let data = Data(mode.rawValue.utf8) - provider.registerDataRepresentation( - forTypeIdentifier: typeIdentifier, - visibility: .ownProcess - ) { completion in - completion(data, nil) + static func beginPaneDrag( + mode: RightSidebarMode, + from sourceView: NSView, + event: NSEvent, + frame: NSRect, + image: NSImage, + onEnd: @escaping @MainActor () -> Void + ) -> RightSidebarModePaneDragSource? { + guard frame.width > 0, frame.height > 0, + let registry = AppDelegate.shared?.tabDragTransferRegistry, + let registration = RightSidebarToolDragPayload(mode: mode).register(with: registry) else { return nil } + let pasteboard = NSPasteboard(name: .drag) + pasteboard.clearContents() + guard registration.write(to: pasteboard) else { + registry.end(registration) + AppDelegate.shared?.liveTabDragCapabilityResolver.invalidate() return nil } - if let registry = registry ?? AppDelegate.shared?.tabDragTransferRegistry { - if let registration = RightSidebarToolDragPayload(mode: mode).register(with: registry), - let capability = registration.pasteboardItem.string(forType: TabDragTransferRegistry.pasteboardType) { - provider.registerDataRepresentation( - forTypeIdentifier: TabDragTransferRegistry.pasteboardType.rawValue, - visibility: .ownProcess - ) { completion in - withExtendedLifetime(registration) { completion(Data(capability.utf8), nil) } - return nil - } - } - } - return provider + registration.pasteboardItem.setData( + Data(mode.rawValue.utf8), + forType: NSPasteboard.PasteboardType(typeIdentifier) + ) + let source = RightSidebarModePaneDragSource(registration: registration, registry: registry, onEnd: onEnd) + let item = NSDraggingItem(pasteboardWriter: registration.pasteboardItem) + item.setDraggingFrame(frame, contents: image) + sourceView.beginDraggingSession(with: [item], event: event, source: source) + source.bind(sourceView: sourceView) + return source + } +} + +/// Retained native source whose terminal callback ends a carried-out tab drag. +@MainActor +final class RightSidebarModePaneDragSource: NSObject, NSDraggingSource { + private let registration: TabDragTransferRegistration + private let registry: TabDragTransferRegistry + private let onEnd: @MainActor () -> Void + private var isFinished = false + // AppKit's drag loop can outlive the SwiftUI view that started it. + private var sourceView: NSView? + + init( + registration: TabDragTransferRegistration, + registry: TabDragTransferRegistry, + onEnd: @escaping @MainActor () -> Void + ) { + self.registration = registration + self.registry = registry + self.onEnd = onEnd + } + + /// Retains the source view until AppKit delivers `endedAt`. + func bind(sourceView: NSView) { + guard !isFinished else { return } + self.sourceView = sourceView + } + + func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + context == .withinApplication ? .move : [] + } + + func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + finish() + // The drag pasteboard keeps this capability's type after the session + // ends; clear only this registration's value. + registration.clearResidualCapability(from: session.draggingPasteboard) + } + + /// Ends the drag. Also called when a newer tab drag starts, which proves + /// AppKit has left this one's drag loop even if `endedAt` never came. + func finish() { + guard !isFinished else { return } + isFinished = true + registry.end(registration) + sourceView = nil + onEnd() } } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index c208c12f2f35..a312320ddb1b 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -85,7 +85,10 @@ struct RightSidebarPanelView: View { @State private var focusShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var closeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var hasMountedRightSidebarContent = false - @State private var draggingModeBarMode: RightSidebarMode? + @State private var modeBarDrag = RightSidebarModeBarDragController() + /// One selection highlight that slides between the mode tabs. + @Namespace private var modeSelectionNamespace + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let alwaysShowShortcutHints = ShortcutHintDebugSettings().alwaysShowHints private let closeShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarCloseHintX @@ -235,6 +238,7 @@ struct RightSidebarPanelView: View { mode: fileExplorerState.mode ), badgeCount: item.mode == .feed ? feedPendingCount : 0, + selectionNamespace: modeSelectionNamespace, shortcutHint: shortcut, showsShortcutHint: ShortcutHintTitlebarPolicy.shouldShow( shortcut: shortcut, @@ -252,21 +256,19 @@ struct RightSidebarPanelView: View { selectMode(mode) } } - .onDrag { - draggingModeBarMode = item.mode - return RightSidebarModeDragPayload.provider(for: item.mode) - } - .onDrop( - of: [RightSidebarModeDragPayload.dropContentType], - delegate: RightSidebarModeBarDropDelegate( - targetMode: item.mode, - displayedModes: displayedModes, - draggingMode: $draggingModeBarMode - ) - ) + .modifier(RightSidebarModeBarTabDrag( + mode: item.mode, displayedModes: displayedModes, + barHeight: titlebarHeight, controller: modeBarDrag + )) .layoutValue(key: RightSidebarModeBarTabSelectedKey.self, value: item.isSelected(mode: fileExplorerState.mode)) } } + // Switching tabs slides the highlight and re-shares the widths + // on one spring: the new tab opens to its label as the old + // one narrows. + .animation(reduceMotion ? nil : RightSidebarModeBarDragController.spring, value: fileExplorerState.mode) + .background(RightSidebarModeBarDragAnchorView(anchor: modeBarDrag.anchor)) + .coordinateSpace(.named(RightSidebarModeBarDragController.coordinateSpace)) .layoutPriority(1) Spacer(minLength: 0) if fileExplorerState.mode.canOpenAsPane, fileExplorerState.mode.isAvailable() { @@ -671,60 +673,3 @@ extension NSView { return true } } - -/// Pure hover-reorder math for the mode bar, kept UI-free so unit tests cover -/// the move without a drag session. -enum RightSidebarModeBarReorderPolicy { - /// The displayed order after dragging `dragged` over `target`, or nil when - /// the hover changes nothing (same pill, or either mode absent). - static func displayedOrder( - moving dragged: RightSidebarMode, - over target: RightSidebarMode, - in displayed: [RightSidebarMode] - ) -> [RightSidebarMode]? { - guard dragged != target, - let from = displayed.firstIndex(of: dragged), - let to = displayed.firstIndex(of: target), - from != to else { - return nil - } - var next = displayed - next.remove(at: from) - next.insert(dragged, at: to) - return next - } -} - -/// Reorders the mode bar while a pill drags across its siblings. Like the -/// workspace-tab reorder, the order commits live on every hover step -/// (`RightSidebarTabPreferences` is the single mutation path and its change -/// notification re-renders the bar), so there is no separate cancel state to -/// reconcile. -struct RightSidebarModeBarDropDelegate: DropDelegate { - let targetMode: RightSidebarMode - let displayedModes: [RightSidebarMode] - @Binding var draggingMode: RightSidebarMode? - - func dropEntered(info: DropInfo) { - guard let dragging = draggingMode, - let next = RightSidebarModeBarReorderPolicy.displayedOrder( - moving: dragging, - over: targetMode, - in: displayedModes - ) else { - return - } - withAnimation(.easeInOut(duration: 0.15)) { - RightSidebarTabPreferences.setDisplayedOrder(next) - } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - draggingMode = nil - return true - } -} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2b08aaf1ac2c..3860372ca53c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -796,6 +796,7 @@ A13086000000000000000010 /* CloudMachineOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1308600000000000000000F /* CloudMachineOrderingTests.swift */; }; 68FCD647A92A0EA1CD1EB61D /* CloudMachineRenamePromptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40373485A3E6FCF954D901D5 /* CloudMachineRenamePromptTests.swift */; }; A1308600000000000000000A /* CloudMachineReorderDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A13086000000000000000009 /* CloudMachineReorderDrop.swift */; }; + 26543135285178E220CDCF3F /* CloudMachineReorderScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 290C7E86F16570B2DE8D1E6E /* CloudMachineReorderScope.swift */; }; C10DC1000000000000000011 /* CloudMachinesFeature+FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10DC1000000000000000012 /* CloudMachinesFeature+FeatureFlags.swift */; }; 82B344513980F6E85AFA611F /* CloudMachinesHeaderCountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE726F52C01F230DBD32B6E /* CloudMachinesHeaderCountTests.swift */; }; A1B2C3D4E5F6071827364951 /* CloudMachineSurfacePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6071827364950 /* CloudMachineSurfacePresentation.swift */; }; @@ -861,7 +862,6 @@ 9F046FC7A14BBDB098F471B3 /* CloudReadResponseGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */; }; C13151C00000000000000023 /* CloudRefreshAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000022 /* CloudRefreshAuthClient.swift */; }; C13151C00000000000000021 /* CloudRefreshFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000020 /* CloudRefreshFixture.swift */; }; - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */; }; C12625000000000000000004 /* CloudRefreshURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625000000000000000003 /* CloudRefreshURLProtocol.swift */; }; C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000051 /* CloudRenameOptimismTests.swift */; }; EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */; }; @@ -970,7 +970,9 @@ 6D0E3243A35EA509D9EEAB89 /* CloudTreeMachineDetailTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD715472753B222428BB056 /* CloudTreeMachineDetailTab.swift */; }; 5F9BE7249498F46FDB5D2CF0 /* CloudTreeMachineDetailTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3415449AFEC98A03CA268BB /* CloudTreeMachineDetailTabs.swift */; }; 3F3058F05F825FCCC5814D77 /* CloudTreeMachineDetailTabsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A376A62BBA1329D7F57F50F /* CloudTreeMachineDetailTabsView.swift */; }; + 99C855388D0D29748CC11F32 /* CloudTreeMachineLiftStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */; }; B5C68F2FAFD21A103AFF429D /* CloudTreeMachineMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADC9BE77DF3A402B8EC51665 /* CloudTreeMachineMenuTests.swift */; }; + 52870FED9D7DB0FB824F0426 /* CloudTreeMachineReorderLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */; }; F470CFEDFB4B13D69FC977D8 /* CloudTreeMachineResourceCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFC1574878902DAFFB8D99D /* CloudTreeMachineResourceCache.swift */; }; D4D50E39EEAA2D96B2D5C1AE /* CloudTreeMachineResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A644AD7E5E26526D5C226EA /* CloudTreeMachineResources.swift */; }; 520D733C88CD5823B036EAA4 /* CloudTreeMachineResourceSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACA3E3EF18BE12C7DF6A173 /* CloudTreeMachineResourceSection.swift */; }; @@ -997,6 +999,7 @@ A37A64310A11AD253970A86C /* CloudTreeOutlineView+Disclosure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19DEAD6E4E3C5032540D9D69 /* CloudTreeOutlineView+Disclosure.swift */; }; 99A348B1D074417BB9DC59D1 /* CloudTreeOutlineView+Displays.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12747253A744B63A30EFA27 /* CloudTreeOutlineView+Displays.swift */; }; C7D2AAC178FE439AFB93CDA1 /* CloudTreeOutlineView+MachineDetailTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F701F7BA409DB9FFC4271474 /* CloudTreeOutlineView+MachineDetailTabs.swift */; }; + 1DFA8FF6BFF48B174B6FCBEC /* CloudTreeOutlineView+MachineLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */; }; E639673A813D2A174CE3BAE1 /* CloudTreeOutlineView+MachineMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 412FF88FEF5819465A8157DE /* CloudTreeOutlineView+MachineMenu.swift */; }; A1308600000000000000000C /* CloudTreeOutlineView+MachineOrdering.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1308600000000000000000B /* CloudTreeOutlineView+MachineOrdering.swift */; }; FB6DB41BCE7B4EC987BF2F0B /* CloudTreeOutlineView+Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6150D65B1F43F683253998 /* CloudTreeOutlineView+Organization.swift */; }; @@ -2989,6 +2992,8 @@ C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */; }; B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000004 /* RightSidebarMode+Availability.swift */; }; 87272A2D0FCDDA4D798961D8 /* RightSidebarMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272B367E39A68B0DCDE9E182 /* RightSidebarMode.swift */; }; + 2472F1A7500F5B4FFF49740C /* RightSidebarModeBarDrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */; }; + 42232416D5AAA41CECB0129F /* RightSidebarModeBarDragLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D88A287C6181685BC9F4C4 /* RightSidebarModeBarDragLayout.swift */; }; D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */; }; D2A7955872F8600B29A7DF58 /* RightSidebarModeBarTabWidths.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */; }; 302334DCE6B277B59D309A6E /* RightSidebarModeBarTabWidthsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */; }; @@ -5289,6 +5294,7 @@ A1308600000000000000000F /* CloudMachineOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachineOrderingTests.swift"; sourceTree = ""; }; 40373485A3E6FCF954D901D5 /* CloudMachineRenamePromptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachineRenamePromptTests.swift"; sourceTree = ""; }; A13086000000000000000009 /* CloudMachineReorderDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudMachineReorderDrop.swift"; sourceTree = ""; }; + 290C7E86F16570B2DE8D1E6E /* CloudMachineReorderScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/CloudMachineReorderScope.swift; sourceTree = ""; }; C10DC1000000000000000012 /* CloudMachinesFeature+FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachinesFeature+FeatureFlags.swift"; sourceTree = ""; }; DCE726F52C01F230DBD32B6E /* CloudMachinesHeaderCountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachinesHeaderCountTests.swift"; sourceTree = ""; }; A1B2C3D4E5F6071827364950 /* CloudMachineSurfacePresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudMachineSurfacePresentation.swift; sourceTree = ""; }; @@ -5354,7 +5360,6 @@ E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudReadResponseGate.swift"; sourceTree = ""; }; C13151C00000000000000022 /* CloudRefreshAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshAuthClient.swift; sourceTree = ""; }; C13151C00000000000000020 /* CloudRefreshFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshFixture.swift; sourceTree = ""; }; - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshMachinesButton.swift; sourceTree = ""; }; C12625000000000000000003 /* CloudRefreshURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshURLProtocol.swift; sourceTree = ""; }; C12575000000000000000051 /* CloudRenameOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRenameOptimismTests.swift; sourceTree = ""; }; 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayFixture.swift"; sourceTree = ""; }; @@ -5463,7 +5468,9 @@ 9CD715472753B222428BB056 /* CloudTreeMachineDetailTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineDetailTab.swift; sourceTree = ""; }; C3415449AFEC98A03CA268BB /* CloudTreeMachineDetailTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineDetailTabs.swift; sourceTree = ""; }; 0A376A62BBA1329D7F57F50F /* CloudTreeMachineDetailTabsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineDetailTabsView.swift; sourceTree = ""; }; + 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/CloudTreeMachineLiftStyle.swift; sourceTree = ""; }; ADC9BE77DF3A402B8EC51665 /* CloudTreeMachineMenuTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineMenuTests.swift; sourceTree = ""; }; + 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/CloudTreeMachineReorderLift.swift; sourceTree = ""; }; BDFC1574878902DAFFB8D99D /* CloudTreeMachineResourceCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineResourceCache.swift; sourceTree = ""; }; 6A644AD7E5E26526D5C226EA /* CloudTreeMachineResources.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineResources.swift; sourceTree = ""; }; 2ACA3E3EF18BE12C7DF6A173 /* CloudTreeMachineResourceSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeMachineResourceSection.swift; sourceTree = ""; }; @@ -5490,6 +5497,7 @@ 19DEAD6E4E3C5032540D9D69 /* CloudTreeOutlineView+Disclosure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+Disclosure.swift"; sourceTree = ""; }; E12747253A744B63A30EFA27 /* CloudTreeOutlineView+Displays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+Displays.swift"; sourceTree = ""; }; F701F7BA409DB9FFC4271474 /* CloudTreeOutlineView+MachineDetailTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineDetailTabs.swift"; sourceTree = ""; }; + 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+MachineLift.swift"; sourceTree = ""; }; 412FF88FEF5819465A8157DE /* CloudTreeOutlineView+MachineMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineMenu.swift"; sourceTree = ""; }; A1308600000000000000000B /* CloudTreeOutlineView+MachineOrdering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+MachineOrdering.swift"; sourceTree = ""; }; 5A6150D65B1F43F683253998 /* CloudTreeOutlineView+Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+Organization.swift"; sourceTree = ""; }; @@ -7336,6 +7344,8 @@ C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarCommandPaletteTests.swift; sourceTree = ""; }; B37A00000000000000000004 /* RightSidebarMode+Availability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode+Availability.swift"; sourceTree = ""; }; 272B367E39A68B0DCDE9E182 /* RightSidebarMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode.swift"; sourceTree = ""; }; + DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarDrag.swift; sourceTree = ""; }; + C8D88A287C6181685BC9F4C4 /* RightSidebarModeBarDragLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarDragLayout.swift; sourceTree = ""; }; 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarTabsLayout.swift; sourceTree = ""; }; DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarTabWidths.swift; sourceTree = ""; }; CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarModeBarTabWidthsTests.swift"; sourceTree = ""; }; @@ -9571,7 +9581,10 @@ F701F7BA409DB9FFC4271474 /* CloudTreeOutlineView+MachineDetailTabs.swift */, B435CE26452A265153CE0A35 /* CloudNewMachineButton.swift */, 816419434ABFAE3CA43F258C /* CloudTreeHoverStyle.swift */, - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */, + 290C7E86F16570B2DE8D1E6E /* CloudMachineReorderScope.swift */, + 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */, + 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */, + 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */, ); name = Cloud; path = Cloud; @@ -11775,6 +11788,8 @@ FC151062CCF4CC6D451CD24C /* ContentView+AgentMessagesCommandPalette.swift */, DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */, 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */, + DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */, + C8D88A287C6181685BC9F4C4 /* RightSidebarModeBarDragLayout.swift */, ); path = Sources; sourceTree = ""; @@ -14490,6 +14505,7 @@ C6428DFF1BF84994BE7F09B1 /* CloudMachineLoadingReservation.swift in Sources */, 9725D4F54BA8B82A24941D3E /* CloudMachineMenuVerbs.swift in Sources */, A1308600000000000000000A /* CloudMachineReorderDrop.swift in Sources */, + 26543135285178E220CDCF3F /* CloudMachineReorderScope.swift in Sources */, C10DC1000000000000000011 /* CloudMachinesFeature+FeatureFlags.swift in Sources */, A1B2C3D4E5F6071827364951 /* CloudMachineSurfacePresentation.swift in Sources */, C11324810000000000000001 /* CloudManualMirrorMaterialization.swift in Sources */, @@ -14517,7 +14533,6 @@ 7A0CE100000000000000071C /* CloudPortRoutePlan.swift in Sources */, EA1141167D5A710EAD47E9F5 /* CloudPortsDiscoveryDemand.swift in Sources */, C13234000000000000000001 /* CloudPortsStatusContent.swift in Sources */, - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */, B5F7FD4791E006CF41AFDE98 /* CloudSecurityExplainer.swift in Sources */, B12357000000000000000004 /* CloudSidebarDebugLabWindow.swift in Sources */, C70E8DD0BC4B4A2C9C58E093 /* CloudSidebarOrganizationDrop.swift in Sources */, @@ -14567,6 +14582,8 @@ 6D0E3243A35EA509D9EEAB89 /* CloudTreeMachineDetailTab.swift in Sources */, 5F9BE7249498F46FDB5D2CF0 /* CloudTreeMachineDetailTabs.swift in Sources */, 3F3058F05F825FCCC5814D77 /* CloudTreeMachineDetailTabsView.swift in Sources */, + 99C855388D0D29748CC11F32 /* CloudTreeMachineLiftStyle.swift in Sources */, + 52870FED9D7DB0FB824F0426 /* CloudTreeMachineReorderLift.swift in Sources */, F470CFEDFB4B13D69FC977D8 /* CloudTreeMachineResourceCache.swift in Sources */, D4D50E39EEAA2D96B2D5C1AE /* CloudTreeMachineResources.swift in Sources */, 520D733C88CD5823B036EAA4 /* CloudTreeMachineResourceSection.swift in Sources */, @@ -14589,6 +14606,7 @@ A37A64310A11AD253970A86C /* CloudTreeOutlineView+Disclosure.swift in Sources */, 99A348B1D074417BB9DC59D1 /* CloudTreeOutlineView+Displays.swift in Sources */, C7D2AAC178FE439AFB93CDA1 /* CloudTreeOutlineView+MachineDetailTabs.swift in Sources */, + 1DFA8FF6BFF48B174B6FCBEC /* CloudTreeOutlineView+MachineLift.swift in Sources */, E639673A813D2A174CE3BAE1 /* CloudTreeOutlineView+MachineMenu.swift in Sources */, A1308600000000000000000C /* CloudTreeOutlineView+MachineOrdering.swift in Sources */, FB6DB41BCE7B4EC987BF2F0B /* CloudTreeOutlineView+Organization.swift in Sources */, @@ -15649,6 +15667,8 @@ B7F9A604B7F9A604B7F9A604 /* RightSidebarChromeStyle.swift in Sources */, B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */, 87272A2D0FCDDA4D798961D8 /* RightSidebarMode.swift in Sources */, + 2472F1A7500F5B4FFF49740C /* RightSidebarModeBarDrag.swift in Sources */, + 42232416D5AAA41CECB0129F /* RightSidebarModeBarDragLayout.swift in Sources */, D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */, D2A7955872F8600B29A7DF58 /* RightSidebarModeBarTabWidths.swift in Sources */, AD1258D161734BE094CD8BEF /* RightSidebarModeDragPayload.swift in Sources */, diff --git a/cmuxTests/CloudMachineOrderingFixture.swift b/cmuxTests/CloudMachineOrderingFixture.swift index 7977b42e991d..57c266897588 100644 --- a/cmuxTests/CloudMachineOrderingFixture.swift +++ b/cmuxTests/CloudMachineOrderingFixture.swift @@ -46,6 +46,8 @@ final class CloudMachineOrderingFixture { ) model.localWorkspacesProvider = { [] } coordinator.onDragStateChange = { [model] in model.setTreeDragging($0) } + // These fixtures drive AppKit's row proposals; lift tests begin it themselves. + coordinator.machineLiftEnabled = false coordinator.nodeActions.organize = { _, _, _ in Issue.record("A machine move must not reach descendant organization") return false diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index 2500191cde05..e86bfaf3a004 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -13,17 +13,7 @@ import Testing @MainActor @Suite("Cloud machine ordering", .serialized) struct CloudMachineOrderingTests { - /// Machine reorder destinations show an insertion line; unrelated sidebar and file drops remain hint-free. - private func expectReorderIndicator(_ outline: NSOutlineView, atY expectedY: CGFloat? = nil) { - let indicator = outline.subviews.first { - $0 is SidebarReorderIndicatorView && !$0.isHidden - } - #expect(indicator != nil) - if let indicator, let expectedY { - #expect(abs(indicator.frame.minY - expectedY) < 0.5) - } - } - + /// Machine drags show their destination by the rows parting, never a line. private func expectNoReorderIndicator(_ outline: NSOutlineView) { #expect(!outline.subviews.contains { $0 is SidebarReorderIndicatorView && !$0.isHidden }) } @@ -48,19 +38,7 @@ struct CloudMachineOrderingTests { let before = fixture.base.defaults.data(forKey: CloudMachinePinStore.defaultsKey) #expect(coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: target, proposedChildIndex: NSOutlineViewDropOnItemIndex) == .move) - let expectedY: CGFloat - if after { - var lastRow = outline.row(forItem: target) - let targetLevel = outline.level(forRow: lastRow) - while lastRow + 1 < outline.numberOfRows, - outline.level(forRow: lastRow + 1) > targetLevel { - lastRow += 1 - } - expectedY = outline.rect(ofRow: lastRow).maxY - SidebarReorderIndicatorView.thickness - } else { - expectedY = rect.minY - } - expectReorderIndicator(outline, atY: expectedY) + expectNoReorderIndicator(outline) #expect(fixture.base.defaults.data(forKey: CloudMachinePinStore.defaultsKey) == before) #expect(coordinator.outlineView(outline, acceptDrop: drag.info, item: nil, childIndex: after ? 5 : 1)) #expect(fixture.order == (after ? ["b", "c", "d", "a"] : ["d", "a", "b", "c"])) @@ -98,6 +76,60 @@ struct CloudMachineOrderingTests { #expect(restored.orderedMachineIDs(["a", "b", "c", "d"]) == fixture.order) } + @Test("A lifted drag closes open machines, drops where it shows the row, and reopens them") + func liftedDrag() throws { + let fixture = CloudMachineOrderingFixture(sectioned: true) + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + outline.expandItem(try #require(fixture.section)) + outline.expandItem(try fixture.root("b")) + let source = try fixture.root("a") + let drag = try fixture.begin("a") + let press = outline.rect(ofRow: outline.row(forItem: source)).midY + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) + #expect(outline.machineLift.isActive(sequence: drag.session.draggingSequenceNumber)) + #expect(!outline.isItemExpanded(try fixture.root("b")), "open machines close for the drag") + #expect(outline.machineLift.sourceNodeID == source.id) + + // Carry the row to just above c's bottom edge: past b and c, short of d. + let start = outline.rect(ofRow: outline.row(forItem: source)) + let c = outline.rect(ofRow: outline.row(forItem: try fixture.root("c"))) + drag.info.draggingLocation = outline.convert(NSPoint(x: start.midX, y: c.maxY - 2), to: nil) + // AppKit's own proposal is ignored while the lift owns the drag. + #expect(coordinator.outlineView(outline, validateDrop: drag.info, + proposedItem: try fixture.root("d"), proposedChildIndex: NSOutlineViewDropOnItemIndex) == .move) + expectNoReorderIndicator(outline) + #expect(outline.machineLift.slot == 2) + #expect(coordinator.outlineView(outline, acceptDrop: drag.info, item: fixture.section, childIndex: 0)) + #expect(fixture.order == ["b", "c", "a", "d"]) + #expect(!outline.machineLift.isActive(sequence: drag.session.draggingSequenceNumber)) + #expect(outline.isItemExpanded(try fixture.root("b")), "open machines come back open") + try fixture.end(drag) + } + + @Test("A lifted drag released on its own slot moves nothing and reopens machines") + func liftedCancel() throws { + let fixture = CloudMachineOrderingFixture(sectioned: true) + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + outline.expandItem(try #require(fixture.section)) + outline.expandItem(try fixture.root("c")) + let source = try fixture.root("b") + let drag = try fixture.begin("b") + let press = outline.rect(ofRow: outline.row(forItem: source)).midY + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) + #expect(!outline.isItemExpanded(try fixture.root("c"))) + drag.info.draggingLocation = outline.convert(NSPoint(x: 10, y: press + 3), to: nil) + #expect(coordinator.outlineView(outline, validateDrop: drag.info, + proposedItem: nil, proposedChildIndex: 0).isEmpty) + try fixture.end(drag) + #expect(fixture.order == ["a", "b", "c", "d"]) + #expect(outline.machineLift.sourceNodeID == nil) + #expect(outline.isItemExpanded(try fixture.root("c"))) + } + @Test("Crossing the pin boundary clamps the move without drawing a hint", arguments: [false, true]) func pinBoundary(pinnedSource: Bool) throws { let fixture = CloudMachineOrderingFixture() @@ -110,7 +142,7 @@ struct CloudMachineOrderingTests { let drag = try fixture.begin(pinnedSource ? "a" : "d") #expect(coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: nil, proposedChildIndex: pinnedSource ? 5 : 0) == .move) - expectReorderIndicator(outline) + expectNoReorderIndicator(outline) #expect(coordinator.outlineView(outline, acceptDrop: drag.info, item: nil, childIndex: 3)) #expect(fixture.order == (pinnedSource ? ["b", "a", "c", "d"] : ["a", "b", "d", "c"])) #expect(fixture.store.pinnedMachineIDs == ["a", "b"]) diff --git a/cmuxTests/CloudTreeHeaderActionsTests.swift b/cmuxTests/CloudTreeHeaderActionsTests.swift index 174c1dc177d7..329e43207923 100644 --- a/cmuxTests/CloudTreeHeaderActionsTests.swift +++ b/cmuxTests/CloudTreeHeaderActionsTests.swift @@ -103,6 +103,19 @@ struct CloudTreeHeaderActionsTests { #expect(title.frame == restingTitleFrame) } + @Test("Section refresh icons show with the fleet and stay on screen while refreshing") + func sectionRefresh() { + let idle = CloudTreeNode.Kind.cloudMachinesSection(canCreateMachine: false, refresh: CloudTreeSectionRefresh()) + #expect(CloudTreeRowHoverButtons.hasButtons(for: idle), "a plan at its limit still refreshes") + #expect(!CloudTreeRowHoverButtons.showsAtRest(for: idle)) + let running = CloudTreeNode.Kind.cloudMachinesSection( + canCreateMachine: true, refresh: CloudTreeSectionRefresh(isRefreshing: true) + ) + #expect(CloudTreeRowHoverButtons.showsAtRest(for: running)) + #expect(!CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection()))) + #expect(CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection(isRefreshing: true)))) + } + /// The header renders while Cloud Machines is off too; there it has nothing /// to create, so it carries no "+". @Test("Cloud Machines' + is present only when a machine can be created") diff --git a/cmuxTests/RightSidebarTabCustomizationTests.swift b/cmuxTests/RightSidebarTabCustomizationTests.swift index b415df6ea76d..b49e3d77acc8 100644 --- a/cmuxTests/RightSidebarTabCustomizationTests.swift +++ b/cmuxTests/RightSidebarTabCustomizationTests.swift @@ -113,25 +113,69 @@ final class RightSidebarTabCustomizationTests: XCTestCase { ) } - func testModeBarReorderPolicyMovesDraggedPillOverTarget() { - let displayed: [RightSidebarMode] = [.files, .find, .sessions, .machines] - XCTAssertEqual( - RightSidebarModeBarReorderPolicy.displayedOrder(moving: .machines, over: .files, in: displayed), - [.machines, .files, .find, .sessions] - ) + /// Four tabs: 40, 60, 40, 50 wide, 4pt apart, from x = 0. + private func modeBarFrames() -> [CGRect] { + var x: CGFloat = 0 + return [40, 60, 40, 50].map { width in + defer { x += width + 4 } + return CGRect(x: x, y: 0, width: width, height: 24) + } + } + + func testModeBarDragLayoutAtRestMovesNothing() throws { + let layout = try XCTUnwrap(RightSidebarModeBarDragLayout(frames: modeBarFrames(), source: 1)) + XCTAssertEqual(layout.slot(translation: 0), 1) + XCTAssertEqual(layout.draggedOffset(translation: 0), 0) + XCTAssertEqual(layout.offsets(slot: 1), [0, 0, 0, 0]) + XCTAssertEqual(layout.reordered(["a", "b", "c", "d"], slot: 1), ["a", "b", "c", "d"]) + } + + func testModeBarDragLayoutNeighbourGivesWayAtHalfTheNarrowerTab() throws { + // Dragging the 60pt tab right over the 40pt one: it moves once 20pt is covered. + let layout = try XCTUnwrap(RightSidebarModeBarDragLayout(frames: modeBarFrames(), source: 1)) + XCTAssertEqual(layout.slot(translation: 23), 1, "covers 19pt of the 40pt neighbour") + XCTAssertEqual(layout.slot(translation: 25), 2, "covers 21pt") + let offsets = layout.offsets(slot: 2) + XCTAssertEqual(offsets[2], -64, "the neighbour shifts left by the dragged tab plus a gap") + XCTAssertEqual(offsets[1], 44, "the dragged tab would land after the neighbour") + XCTAssertEqual(offsets[0], 0) + XCTAssertEqual(offsets[3], 0) + XCTAssertEqual(layout.draggedOffset(translation: 25), 25, "the tab stays under the pointer") XCTAssertEqual( - RightSidebarModeBarReorderPolicy.displayedOrder(moving: .files, over: .sessions, in: displayed), - [.find, .sessions, .files, .machines] - ) - XCTAssertNil( - RightSidebarModeBarReorderPolicy.displayedOrder(moving: .files, over: .files, in: displayed) - ) - XCTAssertNil( - RightSidebarModeBarReorderPolicy.displayedOrder(moving: .feed, over: .files, in: displayed), - "a mode absent from the bar cannot reorder it" + layout.reordered([RightSidebarMode.files, .find, .sessions, .machines], slot: 2), + [.files, .sessions, .find, .machines] ) } + func testModeBarDragLayoutDraggingLeftShiftsTheTabsBefore() throws { + let layout = try XCTUnwrap(RightSidebarModeBarDragLayout(frames: modeBarFrames(), source: 3)) + XCTAssertEqual(layout.slot(translation: -200), 0) + let offsets = layout.offsets(slot: 0) + XCTAssertEqual(offsets[3], -152, "the last tab lands at the bar's start") + XCTAssertEqual(offsets[0], 54) + XCTAssertEqual(offsets[1], 54) + XCTAssertEqual(offsets[2], 54) + } + + func testModeBarDragLayoutResistsPastTheBarEnds() throws { + let layout = try XCTUnwrap(RightSidebarModeBarDragLayout(frames: modeBarFrames(), source: 0)) + let left = layout.draggedOffset(translation: -100) + XCTAssertLessThan(left, 0) + XCTAssertGreaterThan(left, -RightSidebarModeBarDragLayout.overscrollLimit) + let right = layout.draggedOffset(translation: 1000) + XCTAssertGreaterThan(right, 162, "held at the bar's end (162pt) plus some give") + XCTAssertLessThan(right, 162 + RightSidebarModeBarDragLayout.overscrollLimit) + XCTAssertEqual(layout.slot(translation: 1000), 3) + } + + func testModeBarDragLayoutLeavesTheBarOnlyWellOutsideIt() { + let tab = CGRect(x: 0, y: 7, width: 40, height: 24) + XCTAssertFalse(RightSidebarModeBarDragLayout.leavesBar(pointerY: 40, tabFrame: tab, barHeight: 38)) + XCTAssertTrue(RightSidebarModeBarDragLayout.leavesBar(pointerY: 51, tabFrame: tab, barHeight: 38)) + XCTAssertTrue(RightSidebarModeBarDragLayout.leavesBar(pointerY: -13, tabFrame: tab, barHeight: 38)) + XCTAssertNil(RightSidebarModeBarDragLayout(frames: [], source: 0)) + } + func testResetRestoresCanonicalOrderAndVisibility() { RightSidebarTabPreferences.move(.machines, offset: -5, defaults: defaults) RightSidebarTabPreferences.setHidden(true, mode: .find, defaults: defaults) From 974de84a1bb9e4a7c2e87d46674ea36d63c70374 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 14:12:03 +1300 Subject: [PATCH 06/56] move the header refresh icons and tab switch highlight to the follow-up pr keeps this pr to machine and tab reorder. the refresh icons in the cloud machines and my devices headers, the sliding tab highlight and the centered tab icons come back in the stacked cloud sidebar polish pr. --- .../Tree/CloudTreeDevicesSection.swift | 6 +- .../Tree/CloudTreeSectionRefresh.swift | 11 ---- .../Cloud/CloudRefreshMachinesButton.swift | 56 +++++++++++++++++++ Sources/Cloud/CloudTreeBuildInputs.swift | 2 - .../Cloud/CloudTreeCreateActionBuilder.swift | 2 +- Sources/Cloud/CloudTreeNode.swift | 4 +- Sources/Cloud/CloudTreeNodeActions.swift | 10 ++-- .../Cloud/CloudTreeNodeBuilder+Machines.swift | 5 +- Sources/Cloud/CloudTreeOutlineView.swift | 3 +- Sources/Cloud/CloudTreeRowContentView.swift | 2 +- Sources/Cloud/CloudTreeRowHoverButtons.swift | 48 ++++------------ Sources/Cloud/CloudTreeRowToolTip.swift | 2 +- Sources/Cloud/MachinesPanelView.swift | 12 ++-- Sources/RightSidebarChromeStyle.swift | 51 ++++------------- Sources/RightSidebarPanelView.swift | 8 --- cmux.xcodeproj/project.pbxproj | 4 ++ cmuxTests/CloudTreeHeaderActionsTests.swift | 13 ----- 17 files changed, 101 insertions(+), 138 deletions(-) delete mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift create mode 100644 Sources/Cloud/CloudRefreshMachinesButton.swift diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift index c832d1956a4e..503f35eeef9b 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift @@ -8,8 +8,7 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { incomingAccessEnabled: Bool = false, discoveryManaged: Bool = false, incomingAccessManaged: Bool = false, - available: Bool = true, - isRefreshing: Bool = false + available: Bool = true ) { self.count = count self.discoveryEnabled = discoveryEnabled @@ -17,7 +16,6 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { self.discoveryManaged = discoveryManaged self.incomingAccessManaged = incomingAccessManaged self.available = available - self.isRefreshing = isRefreshing } public var count: Int = 0 @@ -27,8 +25,6 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { public var incomingAccessManaged: Bool = false /// Whether Cloud/Beta availability permits either device preference. public var available: Bool = true - /// A refresh of the devices list is running; the header's refresh icon spins. - public var isRefreshing: Bool = false /// Both independent actions stay visible below the devices, preceded by /// "No other Macs yet" when the list is empty. diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift deleted file mode 100644 index 9d729ab2f9ce..000000000000 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -/// A section header's refresh icon: present while the section has something -/// to refresh, with a spinner in its place while a refresh runs. -public struct CloudTreeSectionRefresh: Equatable, Sendable { - public var isRefreshing: Bool - - public init(isRefreshing: Bool = false) { - self.isRefreshing = isRefreshing - } -} diff --git a/Sources/Cloud/CloudRefreshMachinesButton.swift b/Sources/Cloud/CloudRefreshMachinesButton.swift new file mode 100644 index 000000000000..51c93eade589 --- /dev/null +++ b/Sources/Cloud/CloudRefreshMachinesButton.swift @@ -0,0 +1,56 @@ +import CmuxFoundation +import SwiftUI + +/// Refresh Cloud Machines, at the bottom right of the Cloud panel. As wide as +/// its label, with the tree's hover fill, so it reads like a quiet row action. +/// While a refresh runs it shows a spinner in place of its icon and ignores +/// clicks. +struct CloudRefreshMachinesButton: View { + let isRefreshing: Bool + let action: () -> Void + @State private var isHovered = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var title: String { + String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines") + } + + var body: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + Button(action: action) { + HStack(spacing: 5) { + Group { + if isRefreshing { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .medium)) + } + } + .frame(width: 12, height: 12) + Text(title) + .cmuxFont(size: 11.5) + .lineLimit(1) + } + .foregroundStyle(isHovered && !isRefreshing ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) + .padding(.horizontal, 8) + .frame(height: 22) + .background( + RoundedRectangle(cornerRadius: CloudTreeHoverStyle.cornerRadius, style: .continuous) + .fill(Color.primary.opacity(isHovered && !isRefreshing ? CloudTreeHoverStyle.hoverOpacity : 0)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isRefreshing) + .onHover { isHovered = $0 } + .animation(reduceMotion ? nil : .easeOut(duration: isHovered ? CloudTreeHoverStyle.fadeIn : CloudTreeHoverStyle.fadeOut), value: isHovered) + .help(title) + .accessibilityLabel(title) + .accessibilityIdentifier("CloudRefreshMachinesButton") + } + .padding(.horizontal, RightSidebarChromeMetrics.barHorizontalPadding - 2) + .padding(.vertical, 6) + } +} diff --git a/Sources/Cloud/CloudTreeBuildInputs.swift b/Sources/Cloud/CloudTreeBuildInputs.swift index 6bd40f3928f6..cecddba2b545 100644 --- a/Sources/Cloud/CloudTreeBuildInputs.swift +++ b/Sources/Cloud/CloudTreeBuildInputs.swift @@ -17,7 +17,6 @@ struct CloudTreeBuildInputs: Equatable { var showsCloudVPNWarning = false var canCreateCloudMachine = false var cloudMachinesUsage: CloudMachinesUsage? = nil - var cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var localeIdentifier: String = Locale.current.identifier func nodes(now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init()) -> [CloudTreeNode] { @@ -32,7 +31,6 @@ struct CloudTreeBuildInputs: Equatable { showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: cloudMachinesUsage, - cloudMachinesRefresh: cloudMachinesRefresh, now: now, resourceNodeBuilder: resourceNodeBuilder ) } diff --git a/Sources/Cloud/CloudTreeCreateActionBuilder.swift b/Sources/Cloud/CloudTreeCreateActionBuilder.swift index 224bbcbbacaf..cec2f8618114 100644 --- a/Sources/Cloud/CloudTreeCreateActionBuilder.swift +++ b/Sources/Cloud/CloudTreeCreateActionBuilder.swift @@ -8,7 +8,7 @@ enum CloudTreeCreateActionBuilder { for node in nodes { node.children = add(to: node.children) switch node.kind { - case .cloudMachinesSection(let canCreateMachine, _, _): + case .cloudMachinesSection(let canCreateMachine, _): // New Cloud Machine is the button above the section // (`CloudNewMachineButton`), so the empty fleet's // double-click-only "New Machine" placeholder goes. diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 09230efbe0c0..61334bd9419a 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -68,8 +68,8 @@ final class CloudTreeNode: NSObject { case devicesSection(CloudTreeDevicesSection) /// The collapsible Cloud Machines section header. `canCreateMachine` shows its /// hover "+" (New Machine, Cmd-Y), false while Cloud Machines is off and the - /// header stands alone; `usage` is the plan's count (nil until it loads), `refresh` its refresh icon. - case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil, refresh: CloudTreeSectionRefresh? = nil) + /// header stands alone; `usage` is the plan's machine count, nil until it loads. + case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil) case createAction(CloudTreeCreateAction) /// My Devices guidance and independent discovery actions, also shown with peers. case devicesEmpty(CloudTreeDevicesSection) diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index a05c46454a45..08fd03aaa808 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -70,12 +70,12 @@ struct CloudTreeNodeActions { var newDisplay: @MainActor (_ machine: SurfaceMachineID) -> Void = { _ in } /// Opens the New Machine flow through the same action as Cmd-Y. var newMachine: @MainActor () -> Void = {} - /// The section headers' refresh icons: the fleet, and the account's other Macs. - var refreshCloudMachines: @MainActor () -> Void = {}, refreshDevices: @MainActor () -> Void = {} - /// Pops up a row's context menu from its trailing "⋯" button. Bound per - /// cell, so the button and a right-click show the same menu. + /// Pops up a row's context menu from its trailing "⋯" button. The outline + /// coordinator binds it per cell, so the button and a right-click show the + /// same menu. var showRowMenu: @MainActor (_ nodeID: String) -> Void = { _ in } - /// Opens a machine's detail tab, or closes it when open. Bound per cell. + /// Opens a machine's detail tab, or closes it when it is open. Bound per + /// cell by the outline coordinator. var selectMachineDetailTab: @MainActor (_ machine: SurfaceMachineID, _ tab: CloudTreeMachineDetailTab) -> Void = { _, _ in } var organize: @MainActor (CloudSidebarOrganizationAction, String, [CloudTreeNode]) -> Bool = { _, _, _ in false } /// Navigates a nested terminal through its owning Cloud workspace. diff --git a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift index 551b9c495138..1c4ac61ee6a3 100644 --- a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift +++ b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift @@ -19,7 +19,6 @@ extension CloudTreeNodeBuilder { showsCloudVPNWarning: Bool = false, canCreateCloudMachine: Bool = false, cloudMachinesUsage: CloudMachinesUsage? = nil, - cloudMachinesRefresh: CloudTreeSectionRefresh? = nil, now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init() ) -> [CloudTreeNode] { @@ -121,9 +120,7 @@ extension CloudTreeNodeBuilder { : nodes nodes = [CloudTreeNode( id: "cloud-machines-section", - kind: .cloudMachinesSection( - canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage, refresh: cloudMachinesRefresh - ), + kind: .cloudMachinesSection(canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage), children: cloudChildren )] } diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index dfce24783dd1..55db639ef65a 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -37,7 +37,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { /// The Cloud Machines header's New Machine "+" and its plan count (nil until the plan loads). var canCreateCloudMachine: Bool = false var cloudMachinesUsage: CloudMachinesUsage? = nil - var cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var reveal: CloudTreeRevealRequest? = nil var creationReveal: CloudWorkspaceCreationReveal? = nil var nodeBuilder: ((CloudTreeBuildInputs) -> [CloudTreeNode])? = nil @@ -77,7 +76,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { devicesSection: devicesSection, showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, - cloudMachinesUsage: cloudMachinesUsage, cloudMachinesRefresh: cloudMachinesRefresh + cloudMachinesUsage: cloudMachinesUsage )) context.coordinator.reveal(reveal) context.coordinator.reveal(creation: creationReveal) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 057e0b94c4f9..8af48284ff90 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -158,7 +158,7 @@ struct CloudTreeRowContentView: View { static func groupCount(for kind: CloudTreeNode.Kind) -> CloudTreeGroupCount? { switch kind { case .devicesSection(let section): CloudTreeGroupCount(section.count) - case .cloudMachinesSection(_, let usage?, _): CloudTreeGroupCount(usage: usage) + case .cloudMachinesSection(_, let usage?): CloudTreeGroupCount(usage: usage) case .terminalsPool(_, let count), .displaysPool(_, let count, _): CloudTreeGroupCount(count) default: nil } diff --git a/Sources/Cloud/CloudTreeRowHoverButtons.swift b/Sources/Cloud/CloudTreeRowHoverButtons.swift index e8aefff156da..488787cb1b06 100644 --- a/Sources/Cloud/CloudTreeRowHoverButtons.swift +++ b/Sources/Cloud/CloudTreeRowHoverButtons.swift @@ -13,30 +13,13 @@ struct CloudTreeRowHoverButtons: View { var body: some View { switch kind { case .devicesSection(let section): - HStack(spacing: 2) { - refresh(String(localized: "cloudTree.menu.refresh", defaultValue: "Refresh"), isRefreshing: section.isRefreshing) { - nodeActions.refreshDevices() - } - .accessibilityIdentifier("DevicesRefreshButton") - CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) - } - case .cloudMachinesSection(let canCreateMachine, _, let sectionRefresh): - HStack(spacing: 2) { - if let sectionRefresh { - refresh( - String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines"), - isRefreshing: sectionRefresh.isRefreshing - ) { - nodeActions.refreshCloudMachines() - } - .accessibilityIdentifier("CloudRefreshMachinesButton") - } - if canCreateMachine { - plus(String(localized: "machines.new", defaultValue: "New Machine")) { - nodeActions.newMachine() - } - .accessibilityIdentifier("CloudMachinesNewMachineButton") + CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) + case .cloudMachinesSection(let canCreateMachine, _): + if canCreateMachine { + plus(String(localized: "machines.new", defaultValue: "New Machine")) { + nodeActions.newMachine() } + .accessibilityIdentifier("CloudMachinesNewMachineButton") } case .machine(let machine, _): // Always visible: New Workspace, and the machine's full context @@ -139,8 +122,8 @@ struct CloudTreeRowHoverButtons: View { switch kind { case .machine, .localMachine, .terminalsPool, .displaysPool, .workspacesGroup, .workspace, .devicesSection: return true - case .cloudMachinesSection(let canCreateMachine, _, let refresh): - return canCreateMachine || refresh != nil + case .cloudMachinesSection(let canCreateMachine, _): + return canCreateMachine case .pendingMachine: return true case .device(let row): @@ -155,13 +138,8 @@ struct CloudTreeRowHoverButtons: View { /// True when the row's buttons stay visible without hover. Machine rows /// keep + and ⋯ on screen so their actions are discoverable at rest. static func showsAtRest(for kind: CloudTreeNode.Kind) -> Bool { - switch kind { - case .machine: return true - // A running refresh keeps its spinner on screen without hover. - case .cloudMachinesSection(_, _, let refresh): return refresh?.isRefreshing == true - case .devicesSection(let section): return section.isRefreshing - default: return false - } + if case .machine = kind { return true } + return false } /// The Displays affordance remains visible while guest discovery is pending @@ -177,12 +155,6 @@ struct CloudTreeRowHoverButtons: View { MachinesChromeIconButton(symbolName: "plus", accessibilityLabel: label, isBusy: false, action: action) } - /// Refresh, with a spinner in its place (and clicks ignored) while it runs. - private func refresh(_ label: String, isRefreshing: Bool, action: @escaping () -> Void) -> some View { - MachinesChromeIconButton(symbolName: "arrow.clockwise", accessibilityLabel: label, isBusy: isRefreshing, action: action) - .disabled(isRefreshing) - } - private func xmark(_ label: String, action: @escaping () -> Void) -> some View { MachinesChromeIconButton(symbolName: "xmark", accessibilityLabel: label, isBusy: false, action: action) } diff --git a/Sources/Cloud/CloudTreeRowToolTip.swift b/Sources/Cloud/CloudTreeRowToolTip.swift index 52a4b52ce817..704c4744d2a1 100644 --- a/Sources/Cloud/CloudTreeRowToolTip.swift +++ b/Sources/Cloud/CloudTreeRowToolTip.swift @@ -83,7 +83,7 @@ enum CloudTreeRowToolTip { toolTip: joined([placeholder.text], beyond: node.searchableTitle), accessibilityLabel: node.searchableTitle ) - case .cloudMachinesSection(_, let usage?, _): + case .cloudMachinesSection(_, let usage?): // The count's display host never hit-tests, so the plan's help rides // on the row, and the row's label keeps VoiceOver from reading the // visible "1/50" as "1 slash 50". diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index f917f1d34d6c..5a0efd215e71 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -246,6 +246,12 @@ struct MachinesPanelView: View { teamScopeLoading } else { content + if includesCloud { + CloudRefreshMachinesButton( + isRefreshing: viewModel.isLoading || devicesModel.isRefreshing, + action: refreshMachines + ) + } } } @@ -480,8 +486,6 @@ struct MachinesPanelView: View { nodeActions.setDeviceIncomingAccess = { [weak devicesModel] enabled in Task { await devicesModel?.preferences?.setIncomingAccessEnabled(enabled) } } - nodeActions.refreshCloudMachines = { [weak viewModel] in viewModel?.refresh(tree: true) } - nodeActions.refreshDevices = { [weak devicesModel] in devicesModel?.refresh() } // The header "+" is Cmd-Y from this window: same gates, sheet, optimistic create, and no workspace until the sheet completes. nodeActions.newMachine = { [weak tabManager] in _ = AppDelegate.shared?.performNewCloudMachineAction( @@ -507,13 +511,11 @@ struct MachinesPanelView: View { discoveryEnabled: includesDevices, incomingAccessEnabled: devicesModel.preferences?.incomingAccessEnabled ?? false, discoveryManaged: discoveryManaged, - incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable(), - isRefreshing: devicesModel.isRefreshing + incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable() ), showsCloudVPNWarning: tunnelStatus.status?.state == .off, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, - cloudMachinesRefresh: includesCloud ? CloudTreeSectionRefresh(isRefreshing: viewModel.isLoading) : nil, reveal: devicesModel.revealRequest ?? selectionReveal, creationReveal: SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.reveals.reveal(for: tabManager) ) diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 27e196ed7f33..0b26dd993d75 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -135,9 +135,6 @@ struct RightSidebarChromePillModifier: ViewModifier { var isHovered: Bool var horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding var geometryKeyPrefix: String? - /// When set, the selected fill is one shared shape that moves between - /// the pills of this namespace instead of each pill painting its own. - var selectionNamespace: Namespace.ID? @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent func body(content: Content) -> some View { @@ -151,20 +148,10 @@ struct RightSidebarChromePillModifier: ViewModifier { keyPrefix: geometryKeyPrefix, isVisible: true ) - .background { - let shape = RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) - if let selectionNamespace { - ZStack { - shape.fill(isSelected ? Color.clear : backgroundColor) - if isSelected { - shape.fill(backgroundColor) - .matchedGeometryEffect(id: "rightSidebarChromePillSelection", in: selectionNamespace) - } - } - } else { - shape.fill(backgroundColor) - } - } + .background( + RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) + .fill(backgroundColor) + ) .contentShape( RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) ) @@ -292,16 +279,14 @@ extension View { isSelected: Bool, isHovered: Bool, horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding, - geometryKeyPrefix: String? = nil, - selectionNamespace: Namespace.ID? = nil + geometryKeyPrefix: String? = nil ) -> some View { modifier( RightSidebarChromePillModifier( isSelected: isSelected, isHovered: isHovered, horizontalPadding: horizontalPadding, - geometryKeyPrefix: geometryKeyPrefix, - selectionNamespace: selectionNamespace + geometryKeyPrefix: geometryKeyPrefix ) ) } @@ -377,7 +362,6 @@ struct ModeBarButton: View { let item: RightSidebarModeBarItem let isSelected: Bool var badgeCount: Int = 0 - var selectionNamespace: Namespace.ID? = nil let shortcutHint: StoredShortcut let showsShortcutHint: Bool let action: () -> Void @@ -387,18 +371,10 @@ struct ModeBarButton: View { /// ellipsis; the tab then shows only its icon. The label keeps its slot, /// so hiding it never changes the tab's width. @State private var labelFits = true - @State private var labelWidth: CGFloat = 0 - - /// With its label hidden, the icon (and badge) moves into the middle of - /// the label's empty slot, so it sits centered in the tab's highlight. - private var hiddenLabelShift: CGFloat { - labelFits ? 0 : (labelWidth + Self.contentSpacing) / 2 - } - private static let contentSpacing: CGFloat = 4 var body: some View { Button(action: action) { - HStack(spacing: Self.contentSpacing) { + HStack(spacing: 4) { CmuxSystemSymbolImage( systemName: item.symbolName, pointSize: RightSidebarChromeControlStyle.modeIconSize, @@ -410,7 +386,6 @@ struct ModeBarButton: View { keyPrefix: "rightSidebarModeIcon_\(item.id)", isVisible: true ) - .offset(x: badgeCount > 0 ? 0 : hiddenLabelShift) Text(item.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, @@ -419,12 +394,9 @@ struct ModeBarButton: View { .lineLimit(1) .truncationMode(.tail) .opacity(labelFits ? 1 : 0) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.width - } action: { width in - labelWidth = width - labelFits = width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) - } + .onGeometryChange(for: Bool.self) { proxy in + proxy.size.width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) + } action: { labelFits = $0 } if badgeCount > 0 { pendingChip } @@ -432,8 +404,7 @@ struct ModeBarButton: View { .rightSidebarChromePill( isSelected: isSelected, isHovered: isHovered, - geometryKeyPrefix: "rightSidebarModeControl_\(item.id)", - selectionNamespace: selectionNamespace + geometryKeyPrefix: "rightSidebarModeControl_\(item.id)" ) .overlay(alignment: .trailing) { if showsShortcutHint { diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index a312320ddb1b..ed08c79277d9 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -86,9 +86,6 @@ struct RightSidebarPanelView: View { @State private var closeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var hasMountedRightSidebarContent = false @State private var modeBarDrag = RightSidebarModeBarDragController() - /// One selection highlight that slides between the mode tabs. - @Namespace private var modeSelectionNamespace - @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let alwaysShowShortcutHints = ShortcutHintDebugSettings().alwaysShowHints private let closeShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarCloseHintX @@ -238,7 +235,6 @@ struct RightSidebarPanelView: View { mode: fileExplorerState.mode ), badgeCount: item.mode == .feed ? feedPendingCount : 0, - selectionNamespace: modeSelectionNamespace, shortcutHint: shortcut, showsShortcutHint: ShortcutHintTitlebarPolicy.shouldShow( shortcut: shortcut, @@ -263,10 +259,6 @@ struct RightSidebarPanelView: View { .layoutValue(key: RightSidebarModeBarTabSelectedKey.self, value: item.isSelected(mode: fileExplorerState.mode)) } } - // Switching tabs slides the highlight and re-shares the widths - // on one spring: the new tab opens to its label as the old - // one narrows. - .animation(reduceMotion ? nil : RightSidebarModeBarDragController.spring, value: fileExplorerState.mode) .background(RightSidebarModeBarDragAnchorView(anchor: modeBarDrag.anchor)) .coordinateSpace(.named(RightSidebarModeBarDragController.coordinateSpace)) .layoutPriority(1) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3860372ca53c..26f6ed3bccfb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -862,6 +862,7 @@ 9F046FC7A14BBDB098F471B3 /* CloudReadResponseGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */; }; C13151C00000000000000023 /* CloudRefreshAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000022 /* CloudRefreshAuthClient.swift */; }; C13151C00000000000000021 /* CloudRefreshFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000020 /* CloudRefreshFixture.swift */; }; + 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */; }; C12625000000000000000004 /* CloudRefreshURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625000000000000000003 /* CloudRefreshURLProtocol.swift */; }; C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000051 /* CloudRenameOptimismTests.swift */; }; EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */; }; @@ -5360,6 +5361,7 @@ E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudReadResponseGate.swift"; sourceTree = ""; }; C13151C00000000000000022 /* CloudRefreshAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshAuthClient.swift; sourceTree = ""; }; C13151C00000000000000020 /* CloudRefreshFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshFixture.swift; sourceTree = ""; }; + 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshMachinesButton.swift; sourceTree = ""; }; C12625000000000000000003 /* CloudRefreshURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshURLProtocol.swift; sourceTree = ""; }; C12575000000000000000051 /* CloudRenameOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRenameOptimismTests.swift; sourceTree = ""; }; 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayFixture.swift"; sourceTree = ""; }; @@ -9585,6 +9587,7 @@ 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */, 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */, 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */, + 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */, ); name = Cloud; path = Cloud; @@ -14533,6 +14536,7 @@ 7A0CE100000000000000071C /* CloudPortRoutePlan.swift in Sources */, EA1141167D5A710EAD47E9F5 /* CloudPortsDiscoveryDemand.swift in Sources */, C13234000000000000000001 /* CloudPortsStatusContent.swift in Sources */, + 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */, B5F7FD4791E006CF41AFDE98 /* CloudSecurityExplainer.swift in Sources */, B12357000000000000000004 /* CloudSidebarDebugLabWindow.swift in Sources */, C70E8DD0BC4B4A2C9C58E093 /* CloudSidebarOrganizationDrop.swift in Sources */, diff --git a/cmuxTests/CloudTreeHeaderActionsTests.swift b/cmuxTests/CloudTreeHeaderActionsTests.swift index 329e43207923..174c1dc177d7 100644 --- a/cmuxTests/CloudTreeHeaderActionsTests.swift +++ b/cmuxTests/CloudTreeHeaderActionsTests.swift @@ -103,19 +103,6 @@ struct CloudTreeHeaderActionsTests { #expect(title.frame == restingTitleFrame) } - @Test("Section refresh icons show with the fleet and stay on screen while refreshing") - func sectionRefresh() { - let idle = CloudTreeNode.Kind.cloudMachinesSection(canCreateMachine: false, refresh: CloudTreeSectionRefresh()) - #expect(CloudTreeRowHoverButtons.hasButtons(for: idle), "a plan at its limit still refreshes") - #expect(!CloudTreeRowHoverButtons.showsAtRest(for: idle)) - let running = CloudTreeNode.Kind.cloudMachinesSection( - canCreateMachine: true, refresh: CloudTreeSectionRefresh(isRefreshing: true) - ) - #expect(CloudTreeRowHoverButtons.showsAtRest(for: running)) - #expect(!CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection()))) - #expect(CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection(isRefreshing: true)))) - } - /// The header renders while Cloud Machines is off too; there it has nothing /// to create, so it carries no "+". @Test("Cloud Machines' + is present only when a machine can be created") From 712b24ed50016b164cce6425eecb600872d0f9e1 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 14:12:23 +1300 Subject: [PATCH 07/56] cloud sidebar: refresh icons in the section headers, sliding tab highlight, centered tab icons refresh moves from the bottom of the cloud panel to an icon left of the cloud machines + and the my devices menu, with the same hover and a spinner that stays on screen while it runs. switching tabs slides one selection highlight while the widths re-share, and an icon-only tab centers its icon. --- .../Tree/CloudTreeDevicesSection.swift | 6 +- .../Tree/CloudTreeSectionRefresh.swift | 11 ++++ .../Cloud/CloudRefreshMachinesButton.swift | 56 ------------------- Sources/Cloud/CloudTreeBuildInputs.swift | 2 + .../Cloud/CloudTreeCreateActionBuilder.swift | 2 +- Sources/Cloud/CloudTreeNode.swift | 4 +- Sources/Cloud/CloudTreeNodeActions.swift | 10 ++-- .../Cloud/CloudTreeNodeBuilder+Machines.swift | 5 +- Sources/Cloud/CloudTreeOutlineView.swift | 3 +- Sources/Cloud/CloudTreeRowContentView.swift | 2 +- Sources/Cloud/CloudTreeRowHoverButtons.swift | 48 ++++++++++++---- Sources/Cloud/CloudTreeRowToolTip.swift | 2 +- Sources/Cloud/MachinesPanelView.swift | 12 ++-- Sources/RightSidebarChromeStyle.swift | 51 +++++++++++++---- Sources/RightSidebarPanelView.swift | 8 +++ cmux.xcodeproj/project.pbxproj | 4 -- cmuxTests/CloudTreeHeaderActionsTests.swift | 13 +++++ 17 files changed, 138 insertions(+), 101 deletions(-) create mode 100644 Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift delete mode 100644 Sources/Cloud/CloudRefreshMachinesButton.swift diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift index 503f35eeef9b..c832d1956a4e 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift @@ -8,7 +8,8 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { incomingAccessEnabled: Bool = false, discoveryManaged: Bool = false, incomingAccessManaged: Bool = false, - available: Bool = true + available: Bool = true, + isRefreshing: Bool = false ) { self.count = count self.discoveryEnabled = discoveryEnabled @@ -16,6 +17,7 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { self.discoveryManaged = discoveryManaged self.incomingAccessManaged = incomingAccessManaged self.available = available + self.isRefreshing = isRefreshing } public var count: Int = 0 @@ -25,6 +27,8 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { public var incomingAccessManaged: Bool = false /// Whether Cloud/Beta availability permits either device preference. public var available: Bool = true + /// A refresh of the devices list is running; the header's refresh icon spins. + public var isRefreshing: Bool = false /// Both independent actions stay visible below the devices, preceded by /// "No other Macs yet" when the list is empty. diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift new file mode 100644 index 000000000000..9d729ab2f9ce --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A section header's refresh icon: present while the section has something +/// to refresh, with a spinner in its place while a refresh runs. +public struct CloudTreeSectionRefresh: Equatable, Sendable { + public var isRefreshing: Bool + + public init(isRefreshing: Bool = false) { + self.isRefreshing = isRefreshing + } +} diff --git a/Sources/Cloud/CloudRefreshMachinesButton.swift b/Sources/Cloud/CloudRefreshMachinesButton.swift deleted file mode 100644 index 51c93eade589..000000000000 --- a/Sources/Cloud/CloudRefreshMachinesButton.swift +++ /dev/null @@ -1,56 +0,0 @@ -import CmuxFoundation -import SwiftUI - -/// Refresh Cloud Machines, at the bottom right of the Cloud panel. As wide as -/// its label, with the tree's hover fill, so it reads like a quiet row action. -/// While a refresh runs it shows a spinner in place of its icon and ignores -/// clicks. -struct CloudRefreshMachinesButton: View { - let isRefreshing: Bool - let action: () -> Void - @State private var isHovered = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private var title: String { - String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines") - } - - var body: some View { - HStack(spacing: 0) { - Spacer(minLength: 0) - Button(action: action) { - HStack(spacing: 5) { - Group { - if isRefreshing { - ProgressView().controlSize(.mini) - } else { - Image(systemName: "arrow.clockwise") - .font(.system(size: 10, weight: .medium)) - } - } - .frame(width: 12, height: 12) - Text(title) - .cmuxFont(size: 11.5) - .lineLimit(1) - } - .foregroundStyle(isHovered && !isRefreshing ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) - .padding(.horizontal, 8) - .frame(height: 22) - .background( - RoundedRectangle(cornerRadius: CloudTreeHoverStyle.cornerRadius, style: .continuous) - .fill(Color.primary.opacity(isHovered && !isRefreshing ? CloudTreeHoverStyle.hoverOpacity : 0)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isRefreshing) - .onHover { isHovered = $0 } - .animation(reduceMotion ? nil : .easeOut(duration: isHovered ? CloudTreeHoverStyle.fadeIn : CloudTreeHoverStyle.fadeOut), value: isHovered) - .help(title) - .accessibilityLabel(title) - .accessibilityIdentifier("CloudRefreshMachinesButton") - } - .padding(.horizontal, RightSidebarChromeMetrics.barHorizontalPadding - 2) - .padding(.vertical, 6) - } -} diff --git a/Sources/Cloud/CloudTreeBuildInputs.swift b/Sources/Cloud/CloudTreeBuildInputs.swift index cecddba2b545..6bd40f3928f6 100644 --- a/Sources/Cloud/CloudTreeBuildInputs.swift +++ b/Sources/Cloud/CloudTreeBuildInputs.swift @@ -17,6 +17,7 @@ struct CloudTreeBuildInputs: Equatable { var showsCloudVPNWarning = false var canCreateCloudMachine = false var cloudMachinesUsage: CloudMachinesUsage? = nil + var cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var localeIdentifier: String = Locale.current.identifier func nodes(now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init()) -> [CloudTreeNode] { @@ -31,6 +32,7 @@ struct CloudTreeBuildInputs: Equatable { showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: cloudMachinesUsage, + cloudMachinesRefresh: cloudMachinesRefresh, now: now, resourceNodeBuilder: resourceNodeBuilder ) } diff --git a/Sources/Cloud/CloudTreeCreateActionBuilder.swift b/Sources/Cloud/CloudTreeCreateActionBuilder.swift index cec2f8618114..224bbcbbacaf 100644 --- a/Sources/Cloud/CloudTreeCreateActionBuilder.swift +++ b/Sources/Cloud/CloudTreeCreateActionBuilder.swift @@ -8,7 +8,7 @@ enum CloudTreeCreateActionBuilder { for node in nodes { node.children = add(to: node.children) switch node.kind { - case .cloudMachinesSection(let canCreateMachine, _): + case .cloudMachinesSection(let canCreateMachine, _, _): // New Cloud Machine is the button above the section // (`CloudNewMachineButton`), so the empty fleet's // double-click-only "New Machine" placeholder goes. diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 61334bd9419a..09230efbe0c0 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -68,8 +68,8 @@ final class CloudTreeNode: NSObject { case devicesSection(CloudTreeDevicesSection) /// The collapsible Cloud Machines section header. `canCreateMachine` shows its /// hover "+" (New Machine, Cmd-Y), false while Cloud Machines is off and the - /// header stands alone; `usage` is the plan's machine count, nil until it loads. - case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil) + /// header stands alone; `usage` is the plan's count (nil until it loads), `refresh` its refresh icon. + case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil, refresh: CloudTreeSectionRefresh? = nil) case createAction(CloudTreeCreateAction) /// My Devices guidance and independent discovery actions, also shown with peers. case devicesEmpty(CloudTreeDevicesSection) diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index 08fd03aaa808..a05c46454a45 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -70,12 +70,12 @@ struct CloudTreeNodeActions { var newDisplay: @MainActor (_ machine: SurfaceMachineID) -> Void = { _ in } /// Opens the New Machine flow through the same action as Cmd-Y. var newMachine: @MainActor () -> Void = {} - /// Pops up a row's context menu from its trailing "⋯" button. The outline - /// coordinator binds it per cell, so the button and a right-click show the - /// same menu. + /// The section headers' refresh icons: the fleet, and the account's other Macs. + var refreshCloudMachines: @MainActor () -> Void = {}, refreshDevices: @MainActor () -> Void = {} + /// Pops up a row's context menu from its trailing "⋯" button. Bound per + /// cell, so the button and a right-click show the same menu. var showRowMenu: @MainActor (_ nodeID: String) -> Void = { _ in } - /// Opens a machine's detail tab, or closes it when it is open. Bound per - /// cell by the outline coordinator. + /// Opens a machine's detail tab, or closes it when open. Bound per cell. var selectMachineDetailTab: @MainActor (_ machine: SurfaceMachineID, _ tab: CloudTreeMachineDetailTab) -> Void = { _, _ in } var organize: @MainActor (CloudSidebarOrganizationAction, String, [CloudTreeNode]) -> Bool = { _, _, _ in false } /// Navigates a nested terminal through its owning Cloud workspace. diff --git a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift index 1c4ac61ee6a3..551b9c495138 100644 --- a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift +++ b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift @@ -19,6 +19,7 @@ extension CloudTreeNodeBuilder { showsCloudVPNWarning: Bool = false, canCreateCloudMachine: Bool = false, cloudMachinesUsage: CloudMachinesUsage? = nil, + cloudMachinesRefresh: CloudTreeSectionRefresh? = nil, now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init() ) -> [CloudTreeNode] { @@ -120,7 +121,9 @@ extension CloudTreeNodeBuilder { : nodes nodes = [CloudTreeNode( id: "cloud-machines-section", - kind: .cloudMachinesSection(canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage), + kind: .cloudMachinesSection( + canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage, refresh: cloudMachinesRefresh + ), children: cloudChildren )] } diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 55db639ef65a..dfce24783dd1 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -37,6 +37,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { /// The Cloud Machines header's New Machine "+" and its plan count (nil until the plan loads). var canCreateCloudMachine: Bool = false var cloudMachinesUsage: CloudMachinesUsage? = nil + var cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var reveal: CloudTreeRevealRequest? = nil var creationReveal: CloudWorkspaceCreationReveal? = nil var nodeBuilder: ((CloudTreeBuildInputs) -> [CloudTreeNode])? = nil @@ -76,7 +77,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { devicesSection: devicesSection, showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, - cloudMachinesUsage: cloudMachinesUsage + cloudMachinesUsage: cloudMachinesUsage, cloudMachinesRefresh: cloudMachinesRefresh )) context.coordinator.reveal(reveal) context.coordinator.reveal(creation: creationReveal) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 8af48284ff90..057e0b94c4f9 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -158,7 +158,7 @@ struct CloudTreeRowContentView: View { static func groupCount(for kind: CloudTreeNode.Kind) -> CloudTreeGroupCount? { switch kind { case .devicesSection(let section): CloudTreeGroupCount(section.count) - case .cloudMachinesSection(_, let usage?): CloudTreeGroupCount(usage: usage) + case .cloudMachinesSection(_, let usage?, _): CloudTreeGroupCount(usage: usage) case .terminalsPool(_, let count), .displaysPool(_, let count, _): CloudTreeGroupCount(count) default: nil } diff --git a/Sources/Cloud/CloudTreeRowHoverButtons.swift b/Sources/Cloud/CloudTreeRowHoverButtons.swift index 488787cb1b06..e8aefff156da 100644 --- a/Sources/Cloud/CloudTreeRowHoverButtons.swift +++ b/Sources/Cloud/CloudTreeRowHoverButtons.swift @@ -13,13 +13,30 @@ struct CloudTreeRowHoverButtons: View { var body: some View { switch kind { case .devicesSection(let section): - CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) - case .cloudMachinesSection(let canCreateMachine, _): - if canCreateMachine { - plus(String(localized: "machines.new", defaultValue: "New Machine")) { - nodeActions.newMachine() + HStack(spacing: 2) { + refresh(String(localized: "cloudTree.menu.refresh", defaultValue: "Refresh"), isRefreshing: section.isRefreshing) { + nodeActions.refreshDevices() + } + .accessibilityIdentifier("DevicesRefreshButton") + CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) + } + case .cloudMachinesSection(let canCreateMachine, _, let sectionRefresh): + HStack(spacing: 2) { + if let sectionRefresh { + refresh( + String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines"), + isRefreshing: sectionRefresh.isRefreshing + ) { + nodeActions.refreshCloudMachines() + } + .accessibilityIdentifier("CloudRefreshMachinesButton") + } + if canCreateMachine { + plus(String(localized: "machines.new", defaultValue: "New Machine")) { + nodeActions.newMachine() + } + .accessibilityIdentifier("CloudMachinesNewMachineButton") } - .accessibilityIdentifier("CloudMachinesNewMachineButton") } case .machine(let machine, _): // Always visible: New Workspace, and the machine's full context @@ -122,8 +139,8 @@ struct CloudTreeRowHoverButtons: View { switch kind { case .machine, .localMachine, .terminalsPool, .displaysPool, .workspacesGroup, .workspace, .devicesSection: return true - case .cloudMachinesSection(let canCreateMachine, _): - return canCreateMachine + case .cloudMachinesSection(let canCreateMachine, _, let refresh): + return canCreateMachine || refresh != nil case .pendingMachine: return true case .device(let row): @@ -138,8 +155,13 @@ struct CloudTreeRowHoverButtons: View { /// True when the row's buttons stay visible without hover. Machine rows /// keep + and ⋯ on screen so their actions are discoverable at rest. static func showsAtRest(for kind: CloudTreeNode.Kind) -> Bool { - if case .machine = kind { return true } - return false + switch kind { + case .machine: return true + // A running refresh keeps its spinner on screen without hover. + case .cloudMachinesSection(_, _, let refresh): return refresh?.isRefreshing == true + case .devicesSection(let section): return section.isRefreshing + default: return false + } } /// The Displays affordance remains visible while guest discovery is pending @@ -155,6 +177,12 @@ struct CloudTreeRowHoverButtons: View { MachinesChromeIconButton(symbolName: "plus", accessibilityLabel: label, isBusy: false, action: action) } + /// Refresh, with a spinner in its place (and clicks ignored) while it runs. + private func refresh(_ label: String, isRefreshing: Bool, action: @escaping () -> Void) -> some View { + MachinesChromeIconButton(symbolName: "arrow.clockwise", accessibilityLabel: label, isBusy: isRefreshing, action: action) + .disabled(isRefreshing) + } + private func xmark(_ label: String, action: @escaping () -> Void) -> some View { MachinesChromeIconButton(symbolName: "xmark", accessibilityLabel: label, isBusy: false, action: action) } diff --git a/Sources/Cloud/CloudTreeRowToolTip.swift b/Sources/Cloud/CloudTreeRowToolTip.swift index 704c4744d2a1..52a4b52ce817 100644 --- a/Sources/Cloud/CloudTreeRowToolTip.swift +++ b/Sources/Cloud/CloudTreeRowToolTip.swift @@ -83,7 +83,7 @@ enum CloudTreeRowToolTip { toolTip: joined([placeholder.text], beyond: node.searchableTitle), accessibilityLabel: node.searchableTitle ) - case .cloudMachinesSection(_, let usage?): + case .cloudMachinesSection(_, let usage?, _): // The count's display host never hit-tests, so the plan's help rides // on the row, and the row's label keeps VoiceOver from reading the // visible "1/50" as "1 slash 50". diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 5a0efd215e71..f917f1d34d6c 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -246,12 +246,6 @@ struct MachinesPanelView: View { teamScopeLoading } else { content - if includesCloud { - CloudRefreshMachinesButton( - isRefreshing: viewModel.isLoading || devicesModel.isRefreshing, - action: refreshMachines - ) - } } } @@ -486,6 +480,8 @@ struct MachinesPanelView: View { nodeActions.setDeviceIncomingAccess = { [weak devicesModel] enabled in Task { await devicesModel?.preferences?.setIncomingAccessEnabled(enabled) } } + nodeActions.refreshCloudMachines = { [weak viewModel] in viewModel?.refresh(tree: true) } + nodeActions.refreshDevices = { [weak devicesModel] in devicesModel?.refresh() } // The header "+" is Cmd-Y from this window: same gates, sheet, optimistic create, and no workspace until the sheet completes. nodeActions.newMachine = { [weak tabManager] in _ = AppDelegate.shared?.performNewCloudMachineAction( @@ -511,11 +507,13 @@ struct MachinesPanelView: View { discoveryEnabled: includesDevices, incomingAccessEnabled: devicesModel.preferences?.incomingAccessEnabled ?? false, discoveryManaged: discoveryManaged, - incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable() + incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable(), + isRefreshing: devicesModel.isRefreshing ), showsCloudVPNWarning: tunnelStatus.status?.state == .off, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, + cloudMachinesRefresh: includesCloud ? CloudTreeSectionRefresh(isRefreshing: viewModel.isLoading) : nil, reveal: devicesModel.revealRequest ?? selectionReveal, creationReveal: SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.reveals.reveal(for: tabManager) ) diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 0b26dd993d75..27e196ed7f33 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -135,6 +135,9 @@ struct RightSidebarChromePillModifier: ViewModifier { var isHovered: Bool var horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding var geometryKeyPrefix: String? + /// When set, the selected fill is one shared shape that moves between + /// the pills of this namespace instead of each pill painting its own. + var selectionNamespace: Namespace.ID? @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent func body(content: Content) -> some View { @@ -148,10 +151,20 @@ struct RightSidebarChromePillModifier: ViewModifier { keyPrefix: geometryKeyPrefix, isVisible: true ) - .background( - RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) - .fill(backgroundColor) - ) + .background { + let shape = RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) + if let selectionNamespace { + ZStack { + shape.fill(isSelected ? Color.clear : backgroundColor) + if isSelected { + shape.fill(backgroundColor) + .matchedGeometryEffect(id: "rightSidebarChromePillSelection", in: selectionNamespace) + } + } + } else { + shape.fill(backgroundColor) + } + } .contentShape( RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) ) @@ -279,14 +292,16 @@ extension View { isSelected: Bool, isHovered: Bool, horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding, - geometryKeyPrefix: String? = nil + geometryKeyPrefix: String? = nil, + selectionNamespace: Namespace.ID? = nil ) -> some View { modifier( RightSidebarChromePillModifier( isSelected: isSelected, isHovered: isHovered, horizontalPadding: horizontalPadding, - geometryKeyPrefix: geometryKeyPrefix + geometryKeyPrefix: geometryKeyPrefix, + selectionNamespace: selectionNamespace ) ) } @@ -362,6 +377,7 @@ struct ModeBarButton: View { let item: RightSidebarModeBarItem let isSelected: Bool var badgeCount: Int = 0 + var selectionNamespace: Namespace.ID? = nil let shortcutHint: StoredShortcut let showsShortcutHint: Bool let action: () -> Void @@ -371,10 +387,18 @@ struct ModeBarButton: View { /// ellipsis; the tab then shows only its icon. The label keeps its slot, /// so hiding it never changes the tab's width. @State private var labelFits = true + @State private var labelWidth: CGFloat = 0 + + /// With its label hidden, the icon (and badge) moves into the middle of + /// the label's empty slot, so it sits centered in the tab's highlight. + private var hiddenLabelShift: CGFloat { + labelFits ? 0 : (labelWidth + Self.contentSpacing) / 2 + } + private static let contentSpacing: CGFloat = 4 var body: some View { Button(action: action) { - HStack(spacing: 4) { + HStack(spacing: Self.contentSpacing) { CmuxSystemSymbolImage( systemName: item.symbolName, pointSize: RightSidebarChromeControlStyle.modeIconSize, @@ -386,6 +410,7 @@ struct ModeBarButton: View { keyPrefix: "rightSidebarModeIcon_\(item.id)", isVisible: true ) + .offset(x: badgeCount > 0 ? 0 : hiddenLabelShift) Text(item.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, @@ -394,9 +419,12 @@ struct ModeBarButton: View { .lineLimit(1) .truncationMode(.tail) .opacity(labelFits ? 1 : 0) - .onGeometryChange(for: Bool.self) { proxy in - proxy.size.width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) - } action: { labelFits = $0 } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { width in + labelWidth = width + labelFits = width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) + } if badgeCount > 0 { pendingChip } @@ -404,7 +432,8 @@ struct ModeBarButton: View { .rightSidebarChromePill( isSelected: isSelected, isHovered: isHovered, - geometryKeyPrefix: "rightSidebarModeControl_\(item.id)" + geometryKeyPrefix: "rightSidebarModeControl_\(item.id)", + selectionNamespace: selectionNamespace ) .overlay(alignment: .trailing) { if showsShortcutHint { diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index ed08c79277d9..a312320ddb1b 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -86,6 +86,9 @@ struct RightSidebarPanelView: View { @State private var closeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var hasMountedRightSidebarContent = false @State private var modeBarDrag = RightSidebarModeBarDragController() + /// One selection highlight that slides between the mode tabs. + @Namespace private var modeSelectionNamespace + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let alwaysShowShortcutHints = ShortcutHintDebugSettings().alwaysShowHints private let closeShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarCloseHintX @@ -235,6 +238,7 @@ struct RightSidebarPanelView: View { mode: fileExplorerState.mode ), badgeCount: item.mode == .feed ? feedPendingCount : 0, + selectionNamespace: modeSelectionNamespace, shortcutHint: shortcut, showsShortcutHint: ShortcutHintTitlebarPolicy.shouldShow( shortcut: shortcut, @@ -259,6 +263,10 @@ struct RightSidebarPanelView: View { .layoutValue(key: RightSidebarModeBarTabSelectedKey.self, value: item.isSelected(mode: fileExplorerState.mode)) } } + // Switching tabs slides the highlight and re-shares the widths + // on one spring: the new tab opens to its label as the old + // one narrows. + .animation(reduceMotion ? nil : RightSidebarModeBarDragController.spring, value: fileExplorerState.mode) .background(RightSidebarModeBarDragAnchorView(anchor: modeBarDrag.anchor)) .coordinateSpace(.named(RightSidebarModeBarDragController.coordinateSpace)) .layoutPriority(1) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 26f6ed3bccfb..3860372ca53c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -862,7 +862,6 @@ 9F046FC7A14BBDB098F471B3 /* CloudReadResponseGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */; }; C13151C00000000000000023 /* CloudRefreshAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000022 /* CloudRefreshAuthClient.swift */; }; C13151C00000000000000021 /* CloudRefreshFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000020 /* CloudRefreshFixture.swift */; }; - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */; }; C12625000000000000000004 /* CloudRefreshURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625000000000000000003 /* CloudRefreshURLProtocol.swift */; }; C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000051 /* CloudRenameOptimismTests.swift */; }; EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */; }; @@ -5361,7 +5360,6 @@ E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudReadResponseGate.swift"; sourceTree = ""; }; C13151C00000000000000022 /* CloudRefreshAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshAuthClient.swift; sourceTree = ""; }; C13151C00000000000000020 /* CloudRefreshFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshFixture.swift; sourceTree = ""; }; - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshMachinesButton.swift; sourceTree = ""; }; C12625000000000000000003 /* CloudRefreshURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshURLProtocol.swift; sourceTree = ""; }; C12575000000000000000051 /* CloudRenameOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRenameOptimismTests.swift; sourceTree = ""; }; 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayFixture.swift"; sourceTree = ""; }; @@ -9587,7 +9585,6 @@ 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */, 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */, 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */, - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */, ); name = Cloud; path = Cloud; @@ -14536,7 +14533,6 @@ 7A0CE100000000000000071C /* CloudPortRoutePlan.swift in Sources */, EA1141167D5A710EAD47E9F5 /* CloudPortsDiscoveryDemand.swift in Sources */, C13234000000000000000001 /* CloudPortsStatusContent.swift in Sources */, - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */, B5F7FD4791E006CF41AFDE98 /* CloudSecurityExplainer.swift in Sources */, B12357000000000000000004 /* CloudSidebarDebugLabWindow.swift in Sources */, C70E8DD0BC4B4A2C9C58E093 /* CloudSidebarOrganizationDrop.swift in Sources */, diff --git a/cmuxTests/CloudTreeHeaderActionsTests.swift b/cmuxTests/CloudTreeHeaderActionsTests.swift index 174c1dc177d7..329e43207923 100644 --- a/cmuxTests/CloudTreeHeaderActionsTests.swift +++ b/cmuxTests/CloudTreeHeaderActionsTests.swift @@ -103,6 +103,19 @@ struct CloudTreeHeaderActionsTests { #expect(title.frame == restingTitleFrame) } + @Test("Section refresh icons show with the fleet and stay on screen while refreshing") + func sectionRefresh() { + let idle = CloudTreeNode.Kind.cloudMachinesSection(canCreateMachine: false, refresh: CloudTreeSectionRefresh()) + #expect(CloudTreeRowHoverButtons.hasButtons(for: idle), "a plan at its limit still refreshes") + #expect(!CloudTreeRowHoverButtons.showsAtRest(for: idle)) + let running = CloudTreeNode.Kind.cloudMachinesSection( + canCreateMachine: true, refresh: CloudTreeSectionRefresh(isRefreshing: true) + ) + #expect(CloudTreeRowHoverButtons.showsAtRest(for: running)) + #expect(!CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection()))) + #expect(CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection(isRefreshing: true)))) + } + /// The header renders while Cloud Machines is off too; there it has nothing /// to create, so it carries no "+". @Test("Cloud Machines' + is present only when a machine can be created") From fba5a8dc6e96656ea5e84090e35dda1d15b41263 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 14:19:58 +1300 Subject: [PATCH 08/56] machine lift: closing machines above the held one is not a move review findings on the lift: - closing open machines above the source moved its row up, and that shift counted as pointer travel, so a small nudge could move the machine several slots. only the pointer's travel picks the slot now; the held row glides from under the hand into its closed slot - a drag whose end is never reported cancels once the button has been up for a moment, instead of following the pointer until the next click - the release lays out before landing, so the rows glide instead of snapping - mode bar: a late gesture change after the release no longer starts a phantom drag, and tab frames are measured without the drag offset - tests: the lifted drop gets a proposal that disagrees with the lift, and a new test opens machines above the held one --- .../Sidebar/CloudTreeMachineReorderLift.swift | 45 ++++++++++++++++--- .../CloudTreeOutlineView+MachineLift.swift | 1 + Sources/RightSidebarModeBarDrag.swift | 15 +++++-- cmuxTests/CloudMachineOrderingTests.swift | 33 ++++++++++++-- 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift index 639f8dd6a41b..cf5a484d80b9 100644 --- a/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift @@ -19,8 +19,13 @@ final class CloudTreeMachineReorderLift: NSObject { let sourceID: String let layout: CloudTreeReorderLiftLayout let sourceRows: Range - /// The pointer's y, in the collapsed outline, that leaves the row at rest. + /// Where the press landed. Only the pointer's travel from here picks + /// the slot; closing the machines above must not count as a move. let grabY: CGFloat + /// How far closing the machines above moved the held row up. The row + /// starts there, under the hand, and glides into its closed slot. + let closeShift: CGFloat + let startTime: CFTimeInterval /// Machines closed for the drag, opened again when it ends. let collapsedIDs: [String] var placement: CloudTreeReorderLiftLayout.Placement @@ -34,6 +39,11 @@ final class CloudTreeMachineReorderLift: NSObject { /// Row views this drag has moved or styled, reset when it ends. private let touched = NSHashTable.weakObjects() private var isFinishing = false + /// When the mouse button was first seen up during the drag; a drag whose + /// end AppKit never reported is cancelled after a short grace. + private var buttonUpSince: CFTimeInterval? + /// Ends a drag whose end was never reported (set by the coordinator). + var onDragLost: (() -> Void)? private static let shiftKey = "cmux.machineLift.shift" private static let liftZ: CGFloat = 10 @@ -92,16 +102,17 @@ final class CloudTreeMachineReorderLift: NSObject { ghosts.forEach { $0.layer.removeFromSuperlayer() } return } - // Closing machines above the source moves its row up; the grab point - // moves with it, so the row stays under the pointer. let newTop = frames[sourceRows.lowerBound].minY let oldTop = before[source.id] ?? newTop - let grabY = (pressY ?? outline.lastMouseDownPoint?.y ?? pointerY() ?? newTop) - (oldTop - newTop) + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion session = Session( sequence: sequence, sourceID: source.id, layout: layout, sourceRows: sourceRows, - grabY: grabY, collapsedIDs: closing.map(\.id), + grabY: pressY ?? outline.lastMouseDownPoint?.y ?? pointerY() ?? newTop, + closeShift: reduceMotion ? 0 : oldTop - newTop, startTime: CACurrentMediaTime(), + collapsedIDs: closing.map(\.id), placement: layout.placement(dragOffset: 0) ) + buttonUpSince = nil animateGhosts(ghosts, before: before) land(from: before, excluding: source.id, fadingIn: false) if let pointer = pointerY() { update(pointerY: pointer) } @@ -116,9 +127,29 @@ final class CloudTreeMachineReorderLift: NSObject { @objc private func tick() { guard session != nil, let pointer = pointerY() else { return } + if NSEvent.pressedMouseButtons & 1 == 0 { + let now = CACurrentMediaTime() + if let since = buttonUpSince, now - since > 0.3 { + onDragLost?() + return + } + buttonUpSince = buttonUpSince ?? now + } else { + buttonUpSince = nil + } update(pointerY: pointer) } + /// The part of the close shift the held row still carries: a critically + /// damped glide from under the hand into its closed slot. + private static func remainingCloseShift(_ session: Session) -> CGFloat { + guard session.closeShift != 0 else { return 0 } + let omega = 17.0 + let t = max(0, CACurrentMediaTime() - session.startTime) + let remaining = exp(-omega * t) * (1 + omega * t) + return remaining < 0.002 ? 0 : session.closeShift * CGFloat(remaining) + } + /// Moves the lifted row to the pointer and returns the slot it shows. @discardableResult func update(pointerY: CGFloat) -> Int? { @@ -132,7 +163,7 @@ final class CloudTreeMachineReorderLift: NSObject { if session.sourceRows.contains(row) { // Direct manipulation: never smoothed, or the row lags the hand. layer.removeAnimation(forKey: Self.shiftKey) - Self.setShift(placement.sourceOffset, on: layer) + Self.setShift(placement.sourceOffset + Self.remainingCloseShift(session), on: layer) layer.zPosition = Self.liftZ CloudTreeMachineLiftStyle.apply(to: rowView, animated: true) return @@ -177,6 +208,8 @@ final class CloudTreeMachineReorderLift: NSObject { isFinishing = true let result = mutate?() ?? false reopen(session.collapsedIDs) + // Row views exist only after layout; landing before it would snap. + outline?.layoutSubtreeIfNeeded() isFinishing = false land(from: before, excluding: nil, fadingIn: true, liftedID: session.sourceID) return result diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift index 8d7d2b429957..abd305e242ab 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift @@ -18,6 +18,7 @@ extension CloudTreeOutlineView.Coordinator { ) { guard node.canReorderMachine, let scope = CloudMachineReorderScope(machineNodeID: node.id, roots: nodes) else { return } + outline.machineLift.onDragLost = { [weak self] in self?.finishMachineLift() } outline.machineLift.begin( sequence: session.draggingSequenceNumber, source: node, siblings: scope.siblings, pressY: pressY ) { machines in diff --git a/Sources/RightSidebarModeBarDrag.swift b/Sources/RightSidebarModeBarDrag.swift index 30ae1fc229bf..61324b2b5743 100644 --- a/Sources/RightSidebarModeBarDrag.swift +++ b/Sources/RightSidebarModeBarDrag.swift @@ -37,6 +37,9 @@ final class RightSidebarModeBarDragController { let anchor = RightSidebarModeBarDragAnchor() @ObservationIgnored private var paneDrag: RightSidebarModePaneDragSource? @ObservationIgnored private var mouseUpMonitor: Any? + /// The gesture that last ended. SwiftUI can deliver one more change for + /// it after the release; that must not start a new drag. + @ObservationIgnored private var endedStartLocation: CGPoint? func isLifted(_ mode: RightSidebarMode) -> Bool { guard let session else { return false } @@ -60,7 +63,7 @@ final class RightSidebarModeBarDragController { dragImage: @MainActor (CGSize) -> NSImage? ) { if session?.mode != mode || session?.startLocation != startLocation { - guard begin(mode: mode, displayed: displayed, startLocation: startLocation) else { return } + guard startLocation != endedStartLocation, begin(mode: mode, displayed: displayed, startLocation: startLocation) else { return } } guard var current = session, !current.isCarriedOut else { return } let tabFrame = current.layout.frames[current.layout.source] @@ -119,6 +122,7 @@ final class RightSidebarModeBarDragController { private func commit(_ current: Session, animation: Animation?) { removeMouseUpMonitor() + endedStartLocation = current.startLocation guard !current.isCarriedOut else { return } let order = current.layout.reordered(current.modes, slot: current.slot) withAnimation(animation) { @@ -162,7 +166,10 @@ final class RightSidebarModeBarDragController { private func paneDragEnded() { paneDrag = nil - if session?.isCarriedOut == true { session = nil } + if session?.isCarriedOut == true { + endedStartLocation = session?.startLocation + session = nil + } } private func removeMouseUpMonitor() { @@ -218,7 +225,9 @@ struct RightSidebarModeBarTabDrag: ViewModifier { .onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .named(RightSidebarModeBarDragController.coordinateSpace)) } action: { frame in - controller.frames[mode] = frame + // Measured inside the offset; keep the tab's resting frame so + // a drag that starts mid-glide freezes the real slots. + controller.frames[mode] = frame.offsetBy(dx: -controller.offset(for: mode), dy: 0) } .offset(x: controller.offset(for: mode)) // The lifted tab is under the hand: it never animates while held. diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index e86bfaf3a004..0ebc6541d983 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -96,18 +96,45 @@ struct CloudMachineOrderingTests { let start = outline.rect(ofRow: outline.row(forItem: source)) let c = outline.rect(ofRow: outline.row(forItem: try fixture.root("c"))) drag.info.draggingLocation = outline.convert(NSPoint(x: start.midX, y: c.maxY - 2), to: nil) - // AppKit's own proposal is ignored while the lift owns the drag. + // AppKit's own proposal (on b, which alone would drop a after b) is + // ignored while the lift owns the drag. #expect(coordinator.outlineView(outline, validateDrop: drag.info, - proposedItem: try fixture.root("d"), proposedChildIndex: NSOutlineViewDropOnItemIndex) == .move) + proposedItem: try fixture.root("b"), proposedChildIndex: NSOutlineViewDropOnItemIndex) == .move) expectNoReorderIndicator(outline) #expect(outline.machineLift.slot == 2) - #expect(coordinator.outlineView(outline, acceptDrop: drag.info, item: fixture.section, childIndex: 0)) + #expect(coordinator.outlineView(outline, acceptDrop: drag.info, item: try fixture.root("b"), + childIndex: NSOutlineViewDropOnItemIndex)) #expect(fixture.order == ["b", "c", "a", "d"]) #expect(!outline.machineLift.isActive(sequence: drag.session.draggingSequenceNumber)) #expect(outline.isItemExpanded(try fixture.root("b")), "open machines come back open") try fixture.end(drag) } + @Test("Closing open machines above the held one is not a move") + func liftedDragBelowOpenMachines() throws { + let fixture = CloudMachineOrderingFixture(sectioned: true) + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + outline.expandItem(try #require(fixture.section)) + outline.expandItem(try fixture.root("a"), expandChildren: true) + outline.expandItem(try fixture.root("b"), expandChildren: true) + let source = try fixture.root("c") + let drag = try fixture.begin("c") + let press = outline.rect(ofRow: outline.row(forItem: source)).midY + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) + #expect(!outline.isItemExpanded(try fixture.root("a")) && !outline.isItemExpanded(try fixture.root("b"))) + // A small nudge from the press point: c's row moved up as a and b + // closed, but only the pointer's travel counts. + drag.info.draggingLocation = outline.convert(NSPoint(x: 10, y: press + 3), to: nil) + #expect(coordinator.outlineView(outline, validateDrop: drag.info, + proposedItem: nil, proposedChildIndex: 0).isEmpty) + #expect(outline.machineLift.slot == 2, "c keeps its place among a, b and d") + try fixture.end(drag) + #expect(fixture.order == ["a", "b", "c", "d"]) + #expect(outline.isItemExpanded(try fixture.root("a")) && outline.isItemExpanded(try fixture.root("b"))) + } + @Test("A lifted drag released on its own slot moves nothing and reopens machines") func liftedCancel() throws { let fixture = CloudMachineOrderingFixture(sectioned: true) From bf080de63a74788f2d577e1aa9ce7b1a098e1bae Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 14:48:22 +1300 Subject: [PATCH 09/56] cloud sidebar polish: ports alignment, empty states, pane button, full tab names, smoother switching and resize, upgrade at the limit - ports tab: the status text and its button start on the ports tab's edge - an empty fleet keeps a "No cloud machines yet" line, so cloud machines keeps its chevron; my devices says "No other devices yet" - the open-as-pane button stays in the bar in every tab; a tab that can't open as a pane says so in its tooltip - the right sidebar's minimum (and opening) width follows the mode tabs shown, so every tab name fits with the trailing buttons - switching tabs moves one highlight with no crossfade, and row selection is a layer so it no longer flickers while the sidebar resizes - a failed create keeps its warning icon on the name's line - a create refused at the plan's machine limit offers Upgrade Plan when a bigger plan exists - the cloud machines refresh icon spins only for a refresh someone asked for --- .../CmuxCloud/Billing/ProUpgradeSource.swift | 2 + .../Machines/MachinePlanSnapshot.swift | 10 + .../Tree/CloudTreeDevicesSection.swift | 2 +- .../CmuxCloud/Tree/CloudTreePlaceholder.swift | 5 +- .../Sections/ComputersSection.swift | 2 +- Resources/Localizable.xcstrings | 195 +++++++++++++++++- Sources/Cloud/CloudPortsStatusContent.swift | 4 +- Sources/Cloud/CloudTreeCellView.swift | 12 +- .../Cloud/CloudTreeCreateActionBuilder.swift | 9 +- Sources/Cloud/CloudTreeDevicesEmptyView.swift | 2 +- Sources/Cloud/CloudTreeNode.swift | 2 +- .../Cloud/CloudTreeNodeBuilder+Machines.swift | 4 +- .../CloudTreePendingMachineRowContent.swift | 6 +- .../Cloud/CloudTreePlaceholderContent.swift | 6 +- Sources/Cloud/CloudTreeRowView.swift | 51 +++-- Sources/Cloud/MachineCreateOperation.swift | 6 + Sources/Cloud/MachinesPanelView.swift | 5 +- Sources/Cloud/MachinesPanelViewModel.swift | 18 +- .../Sidebar/CloudTreeMachineLiftStyle.swift | 6 +- Sources/ContentView.swift | 16 +- Sources/FileExplorerState.swift | 3 + .../Panels/MachineCreateLoadingContent.swift | 23 ++- Sources/RightSidebarChromeStyle.swift | 4 + Sources/RightSidebarModeBarTabsLayout.swift | 6 + Sources/RightSidebarModeBarWidthReport.swift | 30 +++ Sources/RightSidebarPanelView.swift | 22 +- cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/CloudSidebarPolishTests.swift | 41 ++++ .../CloudTreeCategoryCreateActionTests.swift | 26 ++- cmuxTests/CloudTreeHeaderActionsTests.swift | 2 +- 30 files changed, 451 insertions(+), 77 deletions(-) create mode 100644 Sources/RightSidebarModeBarWidthReport.swift create mode 100644 cmuxTests/CloudSidebarPolishTests.swift diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift index 87fda61fd044..c5a2375fdbf5 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift @@ -30,6 +30,8 @@ public enum ProUpgradeSource: String, CaseIterable, Sendable { case machinesPanelMachineAction = "mac_machines_panel_machine_action" /// New machine sheet refused because the free plan is at its limit. case newMachineAtLimit = "mac_new_machine_at_limit" + /// "Upgrade Plan" on a create that failed at the plan's machine limit. + case createFailedAtLimit = "mac_create_failed_at_limit" /// New machine sheet "Upgrade to Max" under the locked 32 GB / 64 GB sizes. case newMachineSheetMaxUpgrade = "mac_new_machine_sheet_max_upgrade" /// Link inside the `vm_memory_requires_plan` error text (`VMClient`). diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift index 6d2587f415d4..8d55ff8cedd0 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift @@ -39,6 +39,16 @@ public struct MachinePlanSnapshot: Equatable, Sendable { /// upgrade affordance after the server returns `vm_requires_pro`. public var isPaidPlan: Bool { Self.isPaidPlanID(planId) } + /// Whether a plan with more machines exists to upgrade to. Max, Team and + /// Founders are the top of the ladder; everything else (free, Go, Pro, an + /// unknown id) can move up. + public var hasHigherPlan: Bool { + switch planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "max", "team", "founders": return false + default: return true + } + } + public static func isPaidPlanID(_ planId: String) -> Bool { switch planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "go", "pro", "max", "team", "founders": diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift index c832d1956a4e..aa3098409b3b 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift @@ -31,7 +31,7 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { public var isRefreshing: Bool = false /// Both independent actions stay visible below the devices, preceded by - /// "No other Macs yet" when the list is empty. + /// "No other devices yet" when the list is empty. public var inlineRowCount: Int { (count == 0 ? 1 : 0) + 2 } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift index 1a3cc73af4da..554fddd02d31 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift @@ -4,9 +4,10 @@ public struct CloudTreePlaceholder: Equatable, Sendable { case dimmed case connecting case error - /// An action row that opens the New Machine sheet: the empty state of - /// the Cloud Machines section. + /// An action row that opens the New Machine sheet. case createMachine + /// Plain guidance with no icon or action, like "No cloud machines yet". + case empty } public let text: String diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 94580edf76a1..afac2bfbca54 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -67,7 +67,7 @@ public struct ComputersSection: View { SettingsCardNote(String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other Macs to see your devices.")) } else if snapshot.computers.isEmpty { VStack(alignment: .leading, spacing: 6) { - Label(String(localized: "devices.empty.title", defaultValue: "No other Macs yet"), systemImage: "desktopcomputer") + Label(String(localized: "devices.empty.title", defaultValue: "No other devices yet"), systemImage: "desktopcomputer") .font(.callout.weight(.medium)) Text(String(localized: "devices.empty.help", defaultValue: "Sign in to cmux on another Mac and make it discoverable in Settings › Devices.")) .font(.callout) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 76146b9d1c19..e5f6d86a2f71 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -527852,55 +527852,55 @@ "ar": { "stringUnit": { "state": "translated", - "value": "لا توجد أجهزة Mac أخرى بعد" + "value": "لا توجد أجهزة أخرى بعد" } }, "de": { "stringUnit": { "state": "translated", - "value": "Noch keine anderen Macs" + "value": "Noch keine anderen Geräte" } }, "en": { "stringUnit": { "state": "translated", - "value": "No other Macs yet" + "value": "No other devices yet" } }, "es": { "stringUnit": { "state": "translated", - "value": "Aún no hay otros Mac" + "value": "Aún no hay otros dispositivos" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Aucun autre Mac pour le moment" + "value": "Aucun autre appareil pour le moment" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ほかのMacはまだありません" + "value": "ほかのデバイスはまだありません" } }, "ko": { "stringUnit": { "state": "translated", - "value": "아직 다른 Mac이 없습니다" + "value": "아직 다른 기기가 없습니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "尚无其他 Mac" + "value": "尚无其他设备" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "尚無其他 Mac" + "value": "尚無其他裝置" } } } @@ -618218,6 +618218,183 @@ } } } + }, + "machines.empty.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No cloud machines yet" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد أجهزة سحابية بعد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Cloud-Maschinen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay máquinas en la nube" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune machine cloud pour le moment" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドマシンはまだありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 클라우드 머신이 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚无云端机器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚無雲端機器" + } + } + } + }, + "machines.pending.upgrade": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade Plan" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترقية الخطة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tarif upgraden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar plan" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passer au forfait supérieur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プランをアップグレード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "플랜 업그레이드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "升级套餐" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "升級方案" + } + } + } + }, + "rightSidebar.openAsPane.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ can't open as a pane yet" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكن فتح %@ كجزء بعد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ kann noch nicht als Teilfenster geöffnet werden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ aún no se puede abrir como panel" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ ne peut pas encore s'ouvrir en tant que volet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@はまだペインで開けません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@은(는) 아직 분할 창으로 열 수 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 暂时无法以窗格打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 尚無法開啟為窗格" + } + } + } } }, "version": "1.0" diff --git a/Sources/Cloud/CloudPortsStatusContent.swift b/Sources/Cloud/CloudPortsStatusContent.swift index 89e0437d564f..692b30e12807 100644 --- a/Sources/Cloud/CloudPortsStatusContent.swift +++ b/Sources/Cloud/CloudPortsStatusContent.swift @@ -69,7 +69,7 @@ final class CloudPortsStatusContent: NSView { override func layout() { super.layout() let width = max(1, bounds.width) - let inset: CGFloat = 2 + let inset = Self.contentInset let titleHeight = Self.textHeight(titleLabel.stringValue, font: titleLabel.font ?? .systemFont(ofSize: 11), width: width - inset * 2) titleLabel.frame = NSRect(x: inset, y: 2, width: width - inset * 2, height: titleHeight) let messageY = titleLabel.frame.maxY + 2 @@ -85,6 +85,8 @@ final class CloudPortsStatusContent: NSView { } private static let actionHorizontalPadding: CGFloat = 8 + /// The text's and the button's inset from this view's leading edge. + static let contentInset: CGFloat = 2 override var intrinsicContentSize: NSSize { guard let presentation else { return NSSize(width: NSView.noIntrinsicMetric, height: 0) } diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 7d880c81e4e1..947fb0170f65 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -36,6 +36,7 @@ final class CloudTreeCellView: NSTableCellView { private let displayHost = CloudTreePassthroughHostingView(rootView: AnyView(EmptyView())) private var portsStatus: CloudPortsStatusContent? private var portsStatusTrailingConstraint: NSLayoutConstraint? + private var portsStatusLeadingConstraint: NSLayoutConstraint? private var portAction: @MainActor (CloudPortsStatusAction, SurfaceMachineID) -> Void = { _, _ in } private var buttonsHost: CloudTreeRowControlsHostingView? private var buttonsTrailingConstraint: NSLayoutConstraint? @@ -197,6 +198,13 @@ final class CloudTreeCellView: NSTableCellView { portAction(status.action, node.machine) } portsStatusTrailingConstraint?.constant = -style.rowGrid.trailingPadding + // The cell starts on the tab panel's icon column; the guidance + // and its button start on the Ports tab's own edge instead. + portsStatusLeadingConstraint?.constant = configuredPanelLevel.map { + CloudTreeMachineDetailTabsView.panelHighlightLeading(tabRowLevel: $0, style: style) + - CloudTreeMachineDetailTabsView.panelContentLeading(tabRowLevel: $0, style: style) + - CloudPortsStatusContent.contentInset + } ?? 0 } else { portsStatus?.isHidden = true } @@ -354,13 +362,15 @@ final class CloudTreeCellView: NSTableCellView { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) let trailing = view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -style.rowGrid.trailingPadding) + let leading = view.leadingAnchor.constraint(equalTo: leadingAnchor) NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: leadingAnchor), + leading, trailing, view.topAnchor.constraint(equalTo: topAnchor), view.bottomAnchor.constraint(equalTo: bottomAnchor) ]) portsStatusTrailingConstraint = trailing + portsStatusLeadingConstraint = leading portsStatus = view return view } diff --git a/Sources/Cloud/CloudTreeCreateActionBuilder.swift b/Sources/Cloud/CloudTreeCreateActionBuilder.swift index 224bbcbbacaf..c88b40ea6783 100644 --- a/Sources/Cloud/CloudTreeCreateActionBuilder.swift +++ b/Sources/Cloud/CloudTreeCreateActionBuilder.swift @@ -8,12 +8,11 @@ enum CloudTreeCreateActionBuilder { for node in nodes { node.children = add(to: node.children) switch node.kind { - case .cloudMachinesSection(let canCreateMachine, _, _): + case .cloudMachinesSection: // New Cloud Machine is the button above the section - // (`CloudNewMachineButton`), so the empty fleet's - // double-click-only "New Machine" placeholder goes. - node.children.removeAll { $0.id == "cloud-machines-section/empty" } - guard canCreateMachine else { break } + // (`CloudNewMachineButton`); an empty fleet keeps its + // "No cloud machines yet" line so the section still opens. + break case .workspacesGroup(let machine) where (machine.cloudMachineID != nil || machine.isDevice) && !node.children.contains(where: { $0.structureTag == "createAction" }): node.children.insert(CloudTreeNode( diff --git a/Sources/Cloud/CloudTreeDevicesEmptyView.swift b/Sources/Cloud/CloudTreeDevicesEmptyView.swift index fad6d2aa6a82..48d9759d0257 100644 --- a/Sources/Cloud/CloudTreeDevicesEmptyView.swift +++ b/Sources/Cloud/CloudTreeDevicesEmptyView.swift @@ -20,7 +20,7 @@ struct CloudTreeDevicesEmptyView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if section.count == 0 { - Text(String(localized: "devices.empty.title", defaultValue: "No other Macs yet")) + Text(String(localized: "devices.empty.title", defaultValue: "No other devices yet")) .cmuxFont(size: style.detailSize, design: style.fontDesign) .foregroundStyle(.secondary) .padding(.leading, scaled(textInset)) diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 09230efbe0c0..1977fe5faea4 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -238,7 +238,7 @@ final class CloudTreeNode: NSObject { case .machineDetailTabs, .machineEndSpacer: return "" case .devicesEmpty(let section): return section.count == 0 - ? String(localized: "devices.empty.title", defaultValue: "No other Macs yet") + ? String(localized: "devices.empty.title", defaultValue: "No other devices yet") : String(localized: "devices.manage", defaultValue: "Manage My Devices") } } diff --git a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift index 551b9c495138..a808b7811b1e 100644 --- a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift +++ b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift @@ -113,8 +113,8 @@ extension CloudTreeNodeBuilder { kind: .placeholder( machine: .cloud("cloud-machines-section"), CloudTreePlaceholder( - text: String(localized: "machines.empty.create", defaultValue: "New Machine"), - style: .createMachine + text: String(localized: "machines.empty.none", defaultValue: "No cloud machines yet"), + style: .empty ) ) )] diff --git a/Sources/Cloud/CloudTreePendingMachineRowContent.swift b/Sources/Cloud/CloudTreePendingMachineRowContent.swift index a2ce0c5d7bae..ec2fa5c96524 100644 --- a/Sources/Cloud/CloudTreePendingMachineRowContent.swift +++ b/Sources/Cloud/CloudTreePendingMachineRowContent.swift @@ -16,7 +16,9 @@ struct CloudTreePendingMachineRowContent: View { switch style.machineRowLayout { case .singleLine: CloudTreeMachineBand(style: style) { - HStack(alignment: .firstTextBaseline, spacing: style.rowGrid.dotGap) { + // Centered, not baseline-aligned: the glyph has no text + // baseline, so on a baseline it drops below the name. + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { name statusGlyph status @@ -28,7 +30,7 @@ struct CloudTreePendingMachineRowContent: View { case .twoLine: HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: scaled(style.rowGrid.machineLineSpacing)) { - HStack(alignment: .firstTextBaseline, spacing: style.rowGrid.dotGap) { + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { name statusGlyph } diff --git a/Sources/Cloud/CloudTreePlaceholderContent.swift b/Sources/Cloud/CloudTreePlaceholderContent.swift index c28cf4de8af3..f3524d6e8adf 100644 --- a/Sources/Cloud/CloudTreePlaceholderContent.swift +++ b/Sources/Cloud/CloudTreePlaceholderContent.swift @@ -28,6 +28,10 @@ struct CloudTreePlaceholderContent: View { weight: .medium, tint: Color(nsColor: .secondaryLabelColor) ) + case .empty: + // Keeps the icon column so the text lines up with the + // rows it stands in for, like "No other devices yet". + Color.clear case .dimmed: CmuxSystemSymbolImage( systemName: "moon.zzz", @@ -39,7 +43,7 @@ struct CloudTreePlaceholderContent: View { } .frame(width: max(style.iconSlot, 12)) Text(placeholder.text) - .cmuxFont(size: style.detailSize + 1, design: style.fontDesign) + .cmuxFont(size: placeholder.style == .empty ? style.detailSize : style.detailSize + 1, design: style.fontDesign) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) diff --git a/Sources/Cloud/CloudTreeRowView.swift b/Sources/Cloud/CloudTreeRowView.swift index 0712bb2e0faf..c5df594e0979 100644 --- a/Sources/Cloud/CloudTreeRowView.swift +++ b/Sources/Cloud/CloudTreeRowView.swift @@ -16,6 +16,9 @@ final class CloudTreeRowView: NSTableRowView { } private let hoverLayer = CALayer() + /// The selection fill is a layer too, not drawn: a drawn fill is + /// stretched and redrawn as the sidebar resizes, which flickers. + private let selectionLayer = CALayer() /// Where the highlight starts, set by the outline from the row's level. var highlightLeading: CGFloat = CloudTreeHoverStyle.horizontalInset { @@ -34,7 +37,12 @@ final class CloudTreeRowView: NSTableRowView { } override var isSelected: Bool { - didSet { updateHoverLayer(animated: false) } + didSet { updateHoverLayer(animated: false); updateSelectionLayer() } + } + + /// The outline marks rows emphasized while it has keyboard focus. + override var isEmphasized: Bool { + didSet { updateSelectionLayer() } } override init(frame frameRect: NSRect) { @@ -43,6 +51,9 @@ final class CloudTreeRowView: NSTableRowView { hoverLayer.opacity = 0 hoverLayer.cornerRadius = CloudTreeHoverStyle.cornerRadius hoverLayer.cornerCurve = .continuous + selectionLayer.opacity = 0 + selectionLayer.cornerRadius = CloudTreeHoverStyle.cornerRadius + selectionLayer.cornerCurve = .continuous } @available(*, unavailable) @@ -54,17 +65,28 @@ final class CloudTreeRowView: NSTableRowView { super.layout() if hoverLayer.superlayer == nil, let layer { layer.insertSublayer(hoverLayer, at: 0) + layer.insertSublayer(selectionLayer, at: 0) updateHoverColor() } CATransaction.begin() CATransaction.setDisableActions(true) hoverLayer.frame = highlightRect + selectionLayer.frame = highlightRect CATransaction.commit() + updateSelectionLayer() } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() updateHoverColor() + updateSelectionLayer() + } + + /// The outline asks every visible row to redraw when its focus changes; + /// the selection shade follows it here. + override func viewWillDraw() { + super.viewWillDraw() + updateSelectionLayer() } override func prepareForReuse() { @@ -72,17 +94,22 @@ final class CloudTreeRowView: NSTableRowView { setHoverHighlighted(false, animated: false) } - override func drawSelection(in dirtyRect: NSRect) { - guard isSelected else { return } - let rect = highlightRect - let radius = CloudTreeHoverStyle.cornerRadius - let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) - // Gray in both focus states (no accent blue); keyboard focus reads as a - // slightly stronger shade. - NSColor.labelColor.withAlphaComponent( - isKeyboardFocusActive ? CloudTreeHoverStyle.focusedSelectedOpacity : CloudTreeHoverStyle.selectedOpacity - ).setFill() - path.fill() + /// The selection layer draws the highlight; AppKit's own fill stays off. + override func drawSelection(in dirtyRect: NSRect) {} + + private func updateSelectionLayer() { + CATransaction.begin() + CATransaction.setDisableActions(true) + selectionLayer.opacity = isSelected ? 1 : 0 + if isSelected { + // Gray in both focus states (no accent blue); keyboard focus reads + // as a slightly stronger shade. + let opacity = isKeyboardFocusActive ? CloudTreeHoverStyle.focusedSelectedOpacity : CloudTreeHoverStyle.selectedOpacity + effectiveAppearance.performAsCurrentDrawingAppearance { + selectionLayer.backgroundColor = NSColor.labelColor.withAlphaComponent(opacity).cgColor + } + } + CATransaction.commit() } private var highlightRect: NSRect { diff --git a/Sources/Cloud/MachineCreateOperation.swift b/Sources/Cloud/MachineCreateOperation.swift index 261b27d550b7..57aab62f380c 100644 --- a/Sources/Cloud/MachineCreateOperation.swift +++ b/Sources/Cloud/MachineCreateOperation.swift @@ -47,6 +47,12 @@ struct MachineCreateOperation: Identifiable, Equatable { return nil } + /// The create failed because the plan's active-machine limit is reached + /// (the service's `vm_active_limit_exceeded`), so upgrading would let it through. + var hitMachineLimit: Bool { + failureOutput?.contains("vm_active_limit_exceeded") == true + } + /// The one-line status beside the name: the sheet's progress wording /// while running, the failure headline once it failed. var statusLabel: String { diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index f917f1d34d6c..63873cca6486 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -513,7 +513,8 @@ struct MachinesPanelView: View { showsCloudVPNWarning: tunnelStatus.status?.state == .off, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, - cloudMachinesRefresh: includesCloud ? CloudTreeSectionRefresh(isRefreshing: viewModel.isLoading) : nil, + // Only a refresh asked for spins the header; the 45 s poll stays quiet. + cloudMachinesRefresh: includesCloud ? CloudTreeSectionRefresh(isRefreshing: viewModel.isRefreshingOnRequest) : nil, reveal: devicesModel.revealRequest ?? selectionReveal, creationReveal: SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.reveals.reveal(for: tabManager) ) @@ -528,7 +529,7 @@ struct MachinesPanelView: View { Image(systemName: "desktopcomputer") .font(.system(size: 30, weight: .light)) .foregroundStyle(.secondary) - Text(String(localized: "devices.empty.title", defaultValue: "No other Macs yet")) + Text(String(localized: "devices.empty.title", defaultValue: "No other devices yet")) .font(.callout.weight(.medium)) Text(String(localized: "devices.empty.help", defaultValue: "Sign in to cmux on another Mac and make it discoverable in Settings › Devices.")) .font(.callout) diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index e07ee09a6b08..62d709ccabb5 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -10,7 +10,9 @@ import SwiftUI final class MachinesPanelViewModel: ObservableObject { @Published private(set) var machines: [MachineSnapshot] = [] @Published private(set) var plan: MachinePlanSnapshot? - @Published private(set) var isLoading = false + @Published private(set) var isLoading = false { didSet { if !isLoading { isRefreshingOnRequest = false } } } + /// A refresh someone asked for (`refresh(tree:)`) is loading, as opposed to the poll. + @Published private(set) var isRefreshingOnRequest = false @Published private(set) var hasLoadedOnce = false @Published private(set) var lastErrorDescription: String? /// Classified list failure for the matching sign-in, plan, or retry presentation. @@ -343,6 +345,7 @@ final class MachinesPanelViewModel: ObservableObject { func refresh(tree forceTree: Bool) { recoverList() refreshTree(force: forceTree) + isRefreshingOnRequest = isLoading } func refreshMachine(_ machine: SurfaceMachineID) { machineRefreshes.refresh(machine) } nonisolated static func usageBackoffDelay(failureCount: Int) -> TimeInterval { @@ -355,16 +358,13 @@ final class MachinesPanelViewModel: ObservableObject { } static let pollInterval: Duration = .seconds(45) static let initialTransientFailureLimit = 3 - /// A refresh asked for while one is in flight runs again afterwards: a - /// create that lands mid-poll must still replace its pending row with the - /// real machine now, not on the next 45 s sweep. + /// A refresh asked for while one is in flight runs again afterwards: a create that lands + /// mid-poll must still replace its pending row with the real machine now, not on the next 45 s sweep. var refreshRequestedWhileLoading = false - /// A queued automatic refresh promotes the current request to recovery - /// presentation and keeps that intent for the follow-up read. + /// A queued automatic refresh promotes the current request to recovery presentation and keeps that intent for the follow-up read. var refreshRequestedWhileLoadingIsRecovery = false - /// Invalidates refresh completions when the Cloud gate closes. A cancelled - /// URLSession task may still resume on the main actor, so cancellation - /// alone is not enough to prevent stale rows or follow-up work. + /// Invalidates refresh completions when the Cloud gate closes. A cancelled URLSession task may + /// still resume on the main actor, so cancellation alone is not enough to prevent stale rows or follow-up work. var refreshGeneration: UInt64 = 0 /// Sleeps until the earliest upcoming transition across the fleet, then /// recomputes the free-access facet locally and re-arms for the next one. diff --git a/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift b/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift index c2de61aa992f..dd8d2d1e81a4 100644 --- a/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift +++ b/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift @@ -80,12 +80,12 @@ enum CloudTreeMachineLiftStyle { ) } - /// The window ground with the row's own highlight mixed in, so a selected - /// machine keeps reading as selected while it is lifted. + /// The window ground with the hover shade mixed in. A selected machine's + /// selection layer sits above the card, so it keeps reading as selected. private static func fill(for rowView: NSTableRowView) -> CGColor { var color = NSColor.windowBackgroundColor.cgColor rowView.effectiveAppearance.performAsCurrentDrawingAppearance { - let tint = rowView.isSelected ? CloudTreeHoverStyle.selectedOpacity : CloudTreeHoverStyle.hoverOpacity + let tint = CloudTreeHoverStyle.hoverOpacity let base = NSColor.windowBackgroundColor.usingColorSpace(.deviceRGB) ?? .windowBackgroundColor color = base.blended(withFraction: tint, of: .labelColor)?.cgColor ?? base.cgColor } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6ff1a2a8d4ce..8669e52ba435 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1177,11 +1177,7 @@ struct ContentView: View { captureStart: { fileExplorerDragStartWidth = fileExplorerWidth }, updateWidth: { translation in let startWidth = fileExplorerDragStartWidth ?? fileExplorerWidth - let nextWidth = Self.clampedRightSidebarWidth( - startWidth - translation, - availableWidth: resolvedRightSidebarAvailableWidth(availableWidth), - configuredMaximumWidth: rightSidebarConfiguredMaximumWidth - ) + let nextWidth = normalizedRightSidebarWidth(startWidth - translation, availableWidth: availableWidth) withTransaction(Transaction(animation: nil)) { fileExplorerWidth = nextWidth } @@ -1231,12 +1227,14 @@ struct ContentView: View { return max(minimumWidth, min(sanitizedMaximumWidth, candidate)) } + /// `contentMinimumWidth`: what the content needs (every mode tab's full name), never below the built-in minimum. static func clampedRightSidebarWidth( _ candidate: CGFloat, availableWidth: CGFloat, - configuredMaximumWidth: CGFloat? = nil + configuredMaximumWidth: CGFloat? = nil, + contentMinimumWidth: CGFloat = 0 ) -> CGFloat { - let minimumWidth = Self.minimumRightSidebarWidth + let minimumWidth = max(Self.minimumRightSidebarWidth, contentMinimumWidth) let sanitizedCandidate = candidate.isFinite ? candidate : 220 let sanitizedAvailableWidth = availableWidth.isFinite && availableWidth > 0 ? availableWidth : 1920 let availableWidthCap = max( @@ -1317,7 +1315,8 @@ struct ContentView: View { Self.clampedRightSidebarWidth( candidate, availableWidth: resolvedRightSidebarAvailableWidth(availableWidth), - configuredMaximumWidth: rightSidebarConfiguredMaximumWidth + configuredMaximumWidth: rightSidebarConfiguredMaximumWidth, + contentMinimumWidth: fileExplorerState.modeBarMinimumWidth ) } @@ -1979,6 +1978,7 @@ struct ContentView: View { } } } + .onChange(of: fileExplorerState.modeBarMinimumWidth) { _ in clampRightSidebarWidthIfNeeded() } .onChange(of: fileExplorerState.width) { newValue in if fileExplorerDragStartWidth == nil { let sanitized = normalizedRightSidebarWidth(newValue) diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift index f84f31dc2eb8..68590fcfa0a9 100644 --- a/Sources/FileExplorerState.swift +++ b/Sources/FileExplorerState.swift @@ -24,6 +24,9 @@ final class FileExplorerState: ObservableObject { @Published var width: CGFloat { didSet { defaults.set(Double(width), forKey: "fileExplorer.width") } } + /// The width the mode bar needs to show every tab's full name with its + /// trailing controls; the right sidebar never gets narrower. Not persisted. + @Published var modeBarMinimumWidth: CGFloat = 0 /// Proportion of sidebar height allocated to the tab list (0.0-1.0). /// The file explorer gets the remaining space below. diff --git a/Sources/Panels/MachineCreateLoadingContent.swift b/Sources/Panels/MachineCreateLoadingContent.swift index b3302f4ded5a..a424aef54a78 100644 --- a/Sources/Panels/MachineCreateLoadingContent.swift +++ b/Sources/Panels/MachineCreateLoadingContent.swift @@ -1,3 +1,4 @@ +import CmuxCloud import SwiftUI /// A truthful reservation surface using the same operation and actions as the machine row. @@ -33,8 +34,22 @@ struct MachineCreateLoadingContent: View { .foregroundStyle(.secondary) .textSelection(.enabled) HStack(spacing: 8) { - Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } + if offersUpgrade { + Button(String(localized: "machines.pending.upgrade", defaultValue: "Upgrade Plan")) { + ProUpgradePresenter.present(source: .createFailedAtLimit) + } .buttonStyle(.borderedProminent) + .accessibilityIdentifier("MachineCreateUpgradeButton") + } + // Upgrading is the way forward at the limit, so it takes + // the prominent style and Retry steps back. + if offersUpgrade { + Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } + .buttonStyle(.bordered) + } else { + Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } + .buttonStyle(.borderedProminent) + } Button(String(localized: "machines.pending.dismiss", defaultValue: "Dismiss")) { actions.dismiss(operation.id) } .buttonStyle(.bordered) } @@ -50,4 +65,10 @@ struct MachineCreateLoadingContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: GhosttyApp.shared.defaultBackgroundColor)) } + + /// The create hit the plan's machine limit and a plan with more machines + /// exists. An unloaded plan still offers it; the plans page shows the rest. + private var offersUpgrade: Bool { + operation.hitMachineLimit && (NewMachineSheetDataCache.shared?.currentData?.plan?.hasHigherPlan ?? true) + } } diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 27e196ed7f33..b7f82cb85c0d 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -157,8 +157,12 @@ struct RightSidebarChromePillModifier: ViewModifier { ZStack { shape.fill(isSelected ? Color.clear : backgroundColor) if isSelected { + // No fade: the old and new highlights crossfading + // while they slide read as a flash of colour. One + // fill moves, at one opacity, the whole way. shape.fill(backgroundColor) .matchedGeometryEffect(id: "rightSidebarChromePillSelection", in: selectionNamespace) + .transition(.identity) } } } else { diff --git a/Sources/RightSidebarModeBarTabsLayout.swift b/Sources/RightSidebarModeBarTabsLayout.swift index b3f9e18a946d..27c28aeee2cd 100644 --- a/Sources/RightSidebarModeBarTabsLayout.swift +++ b/Sources/RightSidebarModeBarTabsLayout.swift @@ -10,8 +10,14 @@ struct RightSidebarModeBarTabSelectedKey: LayoutValueKey { /// narrows and never grow past their full label. struct RightSidebarModeBarTabsLayout: Layout { var spacing: CGFloat + /// Receives the width every tab needs to show its full label. + var widthReport: RightSidebarModeBarWidthReport? func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + if let widthReport { + let natural = subviews.map { $0.sizeThatFits(.unspecified).width } + MainActor.assumeIsolated { widthReport.note(tabsWidth: natural.reduce(0, +) + gaps(subviews)) } + } let widths = tabWidths(available: proposal.width, subviews: subviews) let height = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0 return CGSize(width: widths.reduce(0, +) + gaps(subviews), height: height) diff --git a/Sources/RightSidebarModeBarWidthReport.swift b/Sources/RightSidebarModeBarWidthReport.swift new file mode 100644 index 000000000000..2933d7336c0c --- /dev/null +++ b/Sources/RightSidebarModeBarWidthReport.swift @@ -0,0 +1,30 @@ +import CoreGraphics +import Foundation + +/// Carries the mode bar's full-label width out of its layout, so the right +/// sidebar's minimum width fits every tab's name plus the trailing controls. +/// The layout writes it while measuring; the change is delivered on the next +/// main-queue turn, never during a view update. +@MainActor +final class RightSidebarModeBarWidthReport { + var onChange: ((CGFloat) -> Void)? + private var reported: CGFloat = 0 + + /// Space the bar needs beside its tabs: the gaps before the open-as-pane + /// and close buttons, both buttons, and the bar's own leading and + /// trailing padding. + static var trailingReserve: CGFloat { + RightSidebarChromeMetrics.headerLeadingPadding + + RightSidebarChromeMetrics.headerTrailingPadding + + 3 * RightSidebarChromeMetrics.headerControlSpacing + + 2 * RightSidebarChromeMetrics.headerControlSize + } + + /// Notes the tabs' width with every label in full, gaps included. + func note(tabsWidth: CGFloat) { + let width = (tabsWidth + Self.trailingReserve).rounded(.up) + guard abs(width - reported) > 0.5 else { return } + reported = width + DispatchQueue.main.async { [weak self] in self?.onChange?(width) } + } +} diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index a312320ddb1b..59db5990027f 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -86,6 +86,7 @@ struct RightSidebarPanelView: View { @State private var closeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var hasMountedRightSidebarContent = false @State private var modeBarDrag = RightSidebarModeBarDragController() + @State private var modeBarWidthReport = RightSidebarModeBarWidthReport() /// One selection highlight that slides between the mode tabs. @Namespace private var modeSelectionNamespace @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -194,6 +195,8 @@ struct RightSidebarPanelView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("RightSidebar") .onAppear { + // The sidebar never gets narrower than every tab's full name. + modeBarWidthReport.onChange = { [fileExplorerState] in fileExplorerState.modeBarMinimumWidth = $0 } startShortcutHintMonitorsIfNeeded() if fileExplorerState.isVisible { hasMountedRightSidebarContent = true } fileExplorerState.refreshModeAvailability() @@ -229,7 +232,7 @@ struct RightSidebarPanelView: View { let displayedModes = availableModes // The selected tab keeps its full label; the others share the // rest and truncate, then drop to their icon. - RightSidebarModeBarTabsLayout(spacing: RightSidebarChromeMetrics.headerControlSpacing) { + RightSidebarModeBarTabsLayout(spacing: RightSidebarChromeMetrics.headerControlSpacing, widthReport: modeBarWidthReport) { ForEach(modeBarItems) { item in let shortcut = item.shortcutAction.map { KeyboardShortcutSettings.shortcut(for: $0) } ?? .unbound ModeBarButton( @@ -271,9 +274,8 @@ struct RightSidebarPanelView: View { .coordinateSpace(.named(RightSidebarModeBarDragController.coordinateSpace)) .layoutPriority(1) Spacer(minLength: 0) - if fileExplorerState.mode.canOpenAsPane, fileExplorerState.mode.isAvailable() { - openAsPaneButton(mode: fileExplorerState.mode) - } + // Always present, so the bar keeps its shape in every tab. + openAsPaneButton(mode: fileExplorerState.mode) closeButton } } @@ -317,8 +319,9 @@ struct RightSidebarPanelView: View { } private func openAsPaneButton(mode: RightSidebarMode) -> some View { - Button { - onOpenAsPane(mode) + let canOpen = mode.canOpenAsPane && mode.isAvailable() + return Button { + if canOpen { onOpenAsPane(mode) } else { NSSound.beep() } } label: { HeaderChromeIconStyle.symbol("rectangle.split.2x1") } @@ -332,7 +335,12 @@ struct RightSidebarPanelView: View { isVisible: true ) .rightSidebarHeaderControlAlignment() - .safeHelp(String(localized: "rightSidebar.openAsPane.tooltip", defaultValue: "Open as pane")) + .safeHelp(canOpen + ? String(localized: "rightSidebar.openAsPane.tooltip", defaultValue: "Open as pane") + : String.localizedStringWithFormat( + String(localized: "rightSidebar.openAsPane.unavailable", defaultValue: "%@ can't open as a pane yet"), + mode.label + )) .accessibilityLabel( String.localizedStringWithFormat( String(localized: "rightSidebar.openAsPane.accessibilityLabel", defaultValue: "Open %@ as Pane"), diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3860372ca53c..4705c3e5402c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -882,6 +882,7 @@ 3E825519877E4FD58D1E91AC /* CloudSidebarOrganizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF7BCD47CC347BC8A5660AD /* CloudSidebarOrganizationStore.swift */; }; DF3CB4E1EB244622B4300041 /* CloudSidebarOrganizationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA34C8F7DABB44E193EF9294 /* CloudSidebarOrganizationTree.swift */; }; C5CD2E187E90476987D291C8 /* CloudSidebarPinGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A17868063D4FF7939B0525 /* CloudSidebarPinGeometryTests.swift */; }; + C2403304B0E829DD4A0D33C4 /* CloudSidebarPolishTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE43F14FD0AFB491247CA131 /* CloudSidebarPolishTests.swift */; }; EA9F42A5F5EF461CBDB53012 /* CloudSidebarRenameFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0804BE3246D64D49AD7C91C4 /* CloudSidebarRenameFixture.swift */; }; 4610710A93DA483CA04C8C25 /* CloudSidebarRenameReconciliationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBF296FE6654DB6BE033698 /* CloudSidebarRenameReconciliationTests.swift */; }; B656E460C0904C4D96D87C9B /* CloudSidebarRowDecoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DCB7412E584AB1AAA90BA5 /* CloudSidebarRowDecoration.swift */; }; @@ -2997,6 +2998,7 @@ D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */; }; D2A7955872F8600B29A7DF58 /* RightSidebarModeBarTabWidths.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */; }; 302334DCE6B277B59D309A6E /* RightSidebarModeBarTabWidthsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */; }; + D738AF025681A2045F6FAFE0 /* RightSidebarModeBarWidthReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */; }; AD1258D161734BE094CD8BEF /* RightSidebarModeDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEB5E400E684891ABF90AE6 /* RightSidebarModeDragPayload.swift */; }; 46814000000000000000000D /* RightSidebarModeShortcutEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46814000000000000000000E /* RightSidebarModeShortcutEntry.swift */; }; 468110000000000000000005 /* RightSidebarModeShortcutMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468110000000000000000006 /* RightSidebarModeShortcutMatcher.swift */; }; @@ -5380,6 +5382,7 @@ 4EF7BCD47CC347BC8A5660AD /* CloudSidebarOrganizationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudSidebarOrganizationStore.swift"; sourceTree = ""; }; EA34C8F7DABB44E193EF9294 /* CloudSidebarOrganizationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudSidebarOrganizationTree.swift"; sourceTree = ""; }; F0A17868063D4FF7939B0525 /* CloudSidebarPinGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarPinGeometryTests.swift"; sourceTree = ""; }; + EE43F14FD0AFB491247CA131 /* CloudSidebarPolishTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarPolishTests.swift"; sourceTree = ""; }; 0804BE3246D64D49AD7C91C4 /* CloudSidebarRenameFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarRenameFixture.swift"; sourceTree = ""; }; 4FBF296FE6654DB6BE033698 /* CloudSidebarRenameReconciliationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarRenameReconciliationTests.swift"; sourceTree = ""; }; F5DCB7412E584AB1AAA90BA5 /* CloudSidebarRowDecoration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudSidebarRowDecoration.swift"; sourceTree = ""; }; @@ -7349,6 +7352,7 @@ 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarTabsLayout.swift; sourceTree = ""; }; DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarTabWidths.swift; sourceTree = ""; }; CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarModeBarTabWidthsTests.swift"; sourceTree = ""; }; + 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarWidthReport.swift; sourceTree = ""; }; AEEB5E400E684891ABF90AE6 /* RightSidebarModeDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeDragPayload.swift; sourceTree = ""; }; 46814000000000000000000E /* RightSidebarModeShortcutEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeShortcutEntry.swift; sourceTree = ""; }; 468110000000000000000006 /* RightSidebarModeShortcutMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeShortcutMatcher.swift; sourceTree = ""; }; @@ -11790,6 +11794,7 @@ 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */, DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */, C8D88A287C6181685BC9F4C4 /* RightSidebarModeBarDragLayout.swift */, + 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */, ); path = Sources; sourceTree = ""; @@ -13291,6 +13296,7 @@ 5FA4140BCE8D49BBAE4E8234 /* CodexForkMonitorArgumentTests.swift */, BAB9A7B1FEACDDDF753351A6 /* CloudTreeMachineDetailLayoutTests.swift */, CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */, + EE43F14FD0AFB491247CA131 /* CloudSidebarPolishTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -15671,6 +15677,7 @@ 42232416D5AAA41CECB0129F /* RightSidebarModeBarDragLayout.swift in Sources */, D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */, D2A7955872F8600B29A7DF58 /* RightSidebarModeBarTabWidths.swift in Sources */, + D738AF025681A2045F6FAFE0 /* RightSidebarModeBarWidthReport.swift in Sources */, AD1258D161734BE094CD8BEF /* RightSidebarModeDragPayload.swift in Sources */, 46814000000000000000000D /* RightSidebarModeShortcutEntry.swift in Sources */, 468110000000000000000005 /* RightSidebarModeShortcutMatcher.swift in Sources */, @@ -17400,6 +17407,7 @@ 084B5CE8F32F41B398E4B326 /* CloudSidebarNotificationTests.swift in Sources */, BEDA293E177041CCBF7677A0 /* CloudSidebarOrderingTests.swift in Sources */, C5CD2E187E90476987D291C8 /* CloudSidebarPinGeometryTests.swift in Sources */, + C2403304B0E829DD4A0D33C4 /* CloudSidebarPolishTests.swift in Sources */, EA9F42A5F5EF461CBDB53012 /* CloudSidebarRenameFixture.swift in Sources */, 4610710A93DA483CA04C8C25 /* CloudSidebarRenameReconciliationTests.swift in Sources */, A12481000000000000000001 /* CloudSidebarScaleTests.swift in Sources */, diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift new file mode 100644 index 000000000000..ed5955f792db --- /dev/null +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -0,0 +1,41 @@ +import CmuxCloud +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Cloud sidebar polish") +struct CloudSidebarPolishTests { + @Test("A create refused at the plan's machine limit offers an upgrade when a bigger plan exists") + func machineLimitFailureOffersUpgrade() { + var operation = MachineCreateOperation( + id: UUID(), request: MachineCreateCoordinatorTests.newMachineRequest(), startedAt: Date() + ) + #expect(!operation.hitMachineLimit) + operation.phase = .failed("Error: Cloud VM limit reached (HTTP 402: vm_active_limit_exceeded)") + #expect(operation.hitMachineLimit) + operation.phase = .failed("Error: The Cloud VM service is temporarily unavailable.") + #expect(!operation.hitMachineLimit) + for plan in ["free", "go", "pro", "unknown"] { + #expect(MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") + } + for plan in ["max", "Team", " founders "] { + #expect(!MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") + } + } + + @Test("The right sidebar never gets narrower than its mode tabs' full names") + func rightSidebarClampHonorsContentMinimum() { + let builtIn = CGFloat(RightSidebarWidthSettings.minimumWidth) + #expect(ContentView.clampedRightSidebarWidth(200, availableWidth: 1600, contentMinimumWidth: 381) == 381) + #expect(ContentView.clampedRightSidebarWidth(500, availableWidth: 1600, contentMinimumWidth: 381) == 500) + // A content minimum below the built-in one never lowers it. + #expect(ContentView.clampedRightSidebarWidth(100, availableWidth: 1600, contentMinimumWidth: 120) == builtIn) + } +} diff --git a/cmuxTests/CloudTreeCategoryCreateActionTests.swift b/cmuxTests/CloudTreeCategoryCreateActionTests.swift index dddfa6cd2327..cce5157e9543 100644 --- a/cmuxTests/CloudTreeCategoryCreateActionTests.swift +++ b/cmuxTests/CloudTreeCategoryCreateActionTests.swift @@ -13,20 +13,32 @@ import Testing @MainActor @Suite("Cloud sidebar category create rows") struct CloudTreeCategoryCreateActionTests { - @Test("Cloud Machines has no New Cloud Machine row; the panel's button owns creation", arguments: [0, 1, 3]) + @Test("Cloud Machines has no New Cloud Machine row and an empty fleet keeps its line", arguments: [0, 1, 3]) func cloudMachinesCategoryHasNoMachineRow(machineCount: Int) throws { let fixture = Fixture() defer { fixture.close() } fixture.apply(machines: fixture.machines(machineCount)) let section = try #require(fixture.cloudSection) - // New Cloud Machine is `CloudNewMachineButton` above the tree, and the - // empty fleet's double-click-only placeholder goes with the row. - #expect(section.children.allSatisfy { $0.id != "cloud-machines-section/empty" }) - // The section has no create row of its own: each machine's - // workspaces start with their New Workspace. + // New Cloud Machine is `CloudNewMachineButton` above the tree. The + // section has no create row of its own: each machine's workspaces + // start with their New Workspace. #expect(section.children.allSatisfy { $0.structureTag != "createAction" }) - if machineCount == 0 { #expect(section.children.isEmpty) } + let empty = section.children.filter { $0.id == "cloud-machines-section/empty" } + if machineCount == 0 { + // An empty fleet keeps a plain "No cloud machines yet" line, so the + // section still has its chevron. + #expect(section.children.count == 1) + guard case .placeholder(_, let placeholder) = try #require(empty.first).kind else { + Issue.record("the empty fleet's row is a placeholder") + return + } + #expect(placeholder.style == .empty) + #expect(placeholder.text == String(localized: "machines.empty.none", defaultValue: "No cloud machines yet")) + #expect(section.isExpandable) + } else { + #expect(empty.isEmpty) + } } @Test("Each Cloud machine's workspaces start with New Workspace") diff --git a/cmuxTests/CloudTreeHeaderActionsTests.swift b/cmuxTests/CloudTreeHeaderActionsTests.swift index 329e43207923..180636641844 100644 --- a/cmuxTests/CloudTreeHeaderActionsTests.swift +++ b/cmuxTests/CloudTreeHeaderActionsTests.swift @@ -213,7 +213,7 @@ struct CloudTreeHeaderActionsTests { let controls = try #require(tree.devicesSection.children.first { if case .devicesEmpty = $0.kind { true } else { false } }) - // "No other Macs yet", then both independent actions in every state. + // "No other devices yet", then both independent actions in every state. let inlineRows = (listedMacs == 0 ? 1 : 0) + 2 let style = tree.outline.treeStyle // Each inline row plus the 2 pt top and bottom inset, nothing more. From 856abd30dd7de27b4a96d333349d04ef419c1028 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 14:51:29 +1300 Subject: [PATCH 10/56] right sidebar width fits the selected tab's name with the others as icons; ports layout back as it was the minimum and opening width now leave room for one full tab name (the widest, so it doesn't change as the selection moves) with every other tab at its icon, instead of every name in full. the ports tab's status alignment goes back to main's. --- Sources/Cloud/CloudPortsStatusContent.swift | 4 +--- Sources/Cloud/CloudTreeCellView.swift | 12 +----------- Sources/ContentView.swift | 2 +- Sources/FileExplorerState.swift | 5 +++-- Sources/RightSidebarModeBarTabWidths.swift | 9 +++++++++ Sources/RightSidebarModeBarTabsLayout.swift | 10 +++++++--- Sources/RightSidebarModeBarWidthReport.swift | 7 ++++--- Sources/RightSidebarPanelView.swift | 2 +- cmuxTests/CloudSidebarPolishTests.swift | 11 ++++++++++- 9 files changed, 37 insertions(+), 25 deletions(-) diff --git a/Sources/Cloud/CloudPortsStatusContent.swift b/Sources/Cloud/CloudPortsStatusContent.swift index 692b30e12807..89e0437d564f 100644 --- a/Sources/Cloud/CloudPortsStatusContent.swift +++ b/Sources/Cloud/CloudPortsStatusContent.swift @@ -69,7 +69,7 @@ final class CloudPortsStatusContent: NSView { override func layout() { super.layout() let width = max(1, bounds.width) - let inset = Self.contentInset + let inset: CGFloat = 2 let titleHeight = Self.textHeight(titleLabel.stringValue, font: titleLabel.font ?? .systemFont(ofSize: 11), width: width - inset * 2) titleLabel.frame = NSRect(x: inset, y: 2, width: width - inset * 2, height: titleHeight) let messageY = titleLabel.frame.maxY + 2 @@ -85,8 +85,6 @@ final class CloudPortsStatusContent: NSView { } private static let actionHorizontalPadding: CGFloat = 8 - /// The text's and the button's inset from this view's leading edge. - static let contentInset: CGFloat = 2 override var intrinsicContentSize: NSSize { guard let presentation else { return NSSize(width: NSView.noIntrinsicMetric, height: 0) } diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 947fb0170f65..7d880c81e4e1 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -36,7 +36,6 @@ final class CloudTreeCellView: NSTableCellView { private let displayHost = CloudTreePassthroughHostingView(rootView: AnyView(EmptyView())) private var portsStatus: CloudPortsStatusContent? private var portsStatusTrailingConstraint: NSLayoutConstraint? - private var portsStatusLeadingConstraint: NSLayoutConstraint? private var portAction: @MainActor (CloudPortsStatusAction, SurfaceMachineID) -> Void = { _, _ in } private var buttonsHost: CloudTreeRowControlsHostingView? private var buttonsTrailingConstraint: NSLayoutConstraint? @@ -198,13 +197,6 @@ final class CloudTreeCellView: NSTableCellView { portAction(status.action, node.machine) } portsStatusTrailingConstraint?.constant = -style.rowGrid.trailingPadding - // The cell starts on the tab panel's icon column; the guidance - // and its button start on the Ports tab's own edge instead. - portsStatusLeadingConstraint?.constant = configuredPanelLevel.map { - CloudTreeMachineDetailTabsView.panelHighlightLeading(tabRowLevel: $0, style: style) - - CloudTreeMachineDetailTabsView.panelContentLeading(tabRowLevel: $0, style: style) - - CloudPortsStatusContent.contentInset - } ?? 0 } else { portsStatus?.isHidden = true } @@ -362,15 +354,13 @@ final class CloudTreeCellView: NSTableCellView { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) let trailing = view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -style.rowGrid.trailingPadding) - let leading = view.leadingAnchor.constraint(equalTo: leadingAnchor) NSLayoutConstraint.activate([ - leading, + view.leadingAnchor.constraint(equalTo: leadingAnchor), trailing, view.topAnchor.constraint(equalTo: topAnchor), view.bottomAnchor.constraint(equalTo: bottomAnchor) ]) portsStatusTrailingConstraint = trailing - portsStatusLeadingConstraint = leading portsStatus = view return view } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8669e52ba435..3e870f31e03a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1227,7 +1227,7 @@ struct ContentView: View { return max(minimumWidth, min(sanitizedMaximumWidth, candidate)) } - /// `contentMinimumWidth`: what the content needs (every mode tab's full name), never below the built-in minimum. + /// `contentMinimumWidth`: what the content needs (the selected mode tab's name), never below the built-in minimum. static func clampedRightSidebarWidth( _ candidate: CGFloat, availableWidth: CGFloat, diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift index 68590fcfa0a9..aaf0ebd34ac4 100644 --- a/Sources/FileExplorerState.swift +++ b/Sources/FileExplorerState.swift @@ -24,8 +24,9 @@ final class FileExplorerState: ObservableObject { @Published var width: CGFloat { didSet { defaults.set(Double(width), forKey: "fileExplorer.width") } } - /// The width the mode bar needs to show every tab's full name with its - /// trailing controls; the right sidebar never gets narrower. Not persisted. + /// The width the mode bar needs for the selected tab's full name, the other + /// tabs as icons, and its trailing controls; the right sidebar never gets + /// narrower. Not persisted. @Published var modeBarMinimumWidth: CGFloat = 0 /// Proportion of sidebar height allocated to the tab list (0.0-1.0). diff --git a/Sources/RightSidebarModeBarTabWidths.swift b/Sources/RightSidebarModeBarTabWidths.swift index 733c2c055525..25715856597f 100644 --- a/Sources/RightSidebarModeBarTabWidths.swift +++ b/Sources/RightSidebarModeBarTabWidths.swift @@ -7,6 +7,15 @@ import CoreGraphics struct RightSidebarModeBarTabWidths { let widths: [CGFloat] + /// The tabs' width with one tab showing its full label and the rest at + /// their floor (their icon): the widest such row, so the result does not + /// change as the selection moves. + static func oneLabelWidth(natural: [CGFloat], floors: [CGFloat]) -> CGFloat { + precondition(natural.count == floors.count) + let extra = zip(natural, floors).map { max(0, $0 - $1) }.max() ?? 0 + return floors.reduce(0, +) + extra + } + /// - Parameters: /// - natural: Each tab's width with its full label. /// - floors: Each tab's smallest width. diff --git a/Sources/RightSidebarModeBarTabsLayout.swift b/Sources/RightSidebarModeBarTabsLayout.swift index 27c28aeee2cd..cd470073d80c 100644 --- a/Sources/RightSidebarModeBarTabsLayout.swift +++ b/Sources/RightSidebarModeBarTabsLayout.swift @@ -10,13 +10,17 @@ struct RightSidebarModeBarTabSelectedKey: LayoutValueKey { /// narrows and never grow past their full label. struct RightSidebarModeBarTabsLayout: Layout { var spacing: CGFloat - /// Receives the width every tab needs to show its full label. + /// Receives the width the selected tab needs for its full label with + /// every other tab at its icon. var widthReport: RightSidebarModeBarWidthReport? func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { if let widthReport { - let natural = subviews.map { $0.sizeThatFits(.unspecified).width } - MainActor.assumeIsolated { widthReport.note(tabsWidth: natural.reduce(0, +) + gaps(subviews)) } + let width = RightSidebarModeBarTabWidths.oneLabelWidth( + natural: subviews.map { $0.sizeThatFits(.unspecified).width }, + floors: subviews.map { $0.sizeThatFits(ProposedViewSize(width: 0, height: nil)).width } + ) + gaps(subviews) + MainActor.assumeIsolated { widthReport.note(tabsWidth: width) } } let widths = tabWidths(available: proposal.width, subviews: subviews) let height = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0 diff --git a/Sources/RightSidebarModeBarWidthReport.swift b/Sources/RightSidebarModeBarWidthReport.swift index 2933d7336c0c..e0eb5d464366 100644 --- a/Sources/RightSidebarModeBarWidthReport.swift +++ b/Sources/RightSidebarModeBarWidthReport.swift @@ -1,8 +1,9 @@ import CoreGraphics import Foundation -/// Carries the mode bar's full-label width out of its layout, so the right -/// sidebar's minimum width fits every tab's name plus the trailing controls. +/// Carries the mode bar's needed width out of its layout, so the right +/// sidebar's minimum width fits the selected tab's name, the other tabs as +/// icons, and the trailing controls. /// The layout writes it while measuring; the change is delivered on the next /// main-queue turn, never during a view update. @MainActor @@ -20,7 +21,7 @@ final class RightSidebarModeBarWidthReport { + 2 * RightSidebarChromeMetrics.headerControlSize } - /// Notes the tabs' width with every label in full, gaps included. + /// Notes the tabs' width (one full label, the rest icons), gaps included. func note(tabsWidth: CGFloat) { let width = (tabsWidth + Self.trailingReserve).rounded(.up) guard abs(width - reported) > 0.5 else { return } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 59db5990027f..b744ae559134 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -195,7 +195,7 @@ struct RightSidebarPanelView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("RightSidebar") .onAppear { - // The sidebar never gets narrower than every tab's full name. + // The sidebar always fits the selected tab's name with the others as icons. modeBarWidthReport.onChange = { [fileExplorerState] in fileExplorerState.modeBarMinimumWidth = $0 } startShortcutHintMonitorsIfNeeded() if fileExplorerState.isVisible { hasMountedRightSidebarContent = true } diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift index ed5955f792db..6395b731d3d5 100644 --- a/cmuxTests/CloudSidebarPolishTests.swift +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -30,7 +30,16 @@ struct CloudSidebarPolishTests { } } - @Test("The right sidebar never gets narrower than its mode tabs' full names") + @Test("The mode bar's minimum fits the widest tab's name with every other tab as its icon") + func oneLabelWidth() { + // Full and icon-only widths for three tabs; Cloud's label is the widest. + let natural: [CGFloat] = [59, 66, 58] + let floors: [CGFloat] = [30, 30, 30] + #expect(RightSidebarModeBarTabWidths.oneLabelWidth(natural: natural, floors: floors) == 30 * 3 + 36) + #expect(RightSidebarModeBarTabWidths.oneLabelWidth(natural: [], floors: []) == 0) + } + + @Test("The right sidebar never gets narrower than its mode bar needs") func rightSidebarClampHonorsContentMinimum() { let builtIn = CGFloat(RightSidebarWidthSettings.minimumWidth) #expect(ContentView.clampedRightSidebarWidth(200, availableWidth: 1600, contentMinimumWidth: 381) == 381) From e0ba72b67e2680680b1719b720a086bccaba1590 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 15:16:55 +1300 Subject: [PATCH 11/56] cloud sidebar: refresh icons sit after the section counts; a failed create keeps its status readable the cloud machines and my devices refresh icons move from the header's trailing buttons to right after the title and count, small and in the count's grey. the header passes clicks through to the row everywhere but the icon, so clicking the header still opens and closes the section. in a narrow sidebar a failed create's name gives way before its status. --- Sources/Cloud/CloudTreeCellView.swift | 33 +++++++++- Sources/Cloud/CloudTreeNSOutlineView.swift | 2 +- .../CloudTreePendingMachineRowContent.swift | 3 + Sources/Cloud/CloudTreeRowContentView.swift | 36 +++++++++- Sources/Cloud/CloudTreeRowHoverButtons.swift | 50 ++++---------- .../Cloud/CloudTreeSectionRefreshHeader.swift | 65 +++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 ++ cmuxTests/CloudTreeHeaderActionsTests.swift | 17 +++-- 8 files changed, 158 insertions(+), 52 deletions(-) create mode 100644 Sources/Cloud/CloudTreeSectionRefreshHeader.swift diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 7d880c81e4e1..38cecbf22653 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -288,13 +288,29 @@ final class CloudTreeCellView: NSTableCellView { let description = CloudTreeRowToolTip.describe(node: node, style: style, presenceHeads: presenceHeads) toolTip = description.toolTip setAccessibilityLabel(description.accessibilityLabel) + displayHost.interactiveRects = [] displayHost.rootView = AnyView( - CloudTreeRowContentView(kind: node.kind, presenceHeads: presenceHeads, style: style, resources: node.resourceSection) + CloudTreeRowContentView( + kind: node.kind, presenceHeads: presenceHeads, style: style, resources: node.resourceSection, + onRefresh: Self.sectionRefreshAction(node.kind, configuredNodeActions), + onInteractiveFrame: { [weak displayHost] frame in displayHost?.interactiveRects = frame.map { [$0] } ?? [] } + ) .modifier(CloudSidebarRowDecoration(isPinned: node.isPinned, showsAttentionSlot: node.showsAttentionSlot, hasUnreadNotification: node.hasUnreadAttention, attentionSlot: style.rowGrid.attentionSlot)) .frame(maxWidth: .infinity, alignment: .leading) + .coordinateSpace(.named(CloudTreePassthroughHostingView.coordinateSpace)) ) } + /// The refresh a section header's icon runs; nil for every other row. + private static func sectionRefreshAction(_ kind: CloudTreeNode.Kind, _ actions: CloudTreeNodeActions?) -> (() -> Void)? { + guard let actions else { return nil } + switch kind { + case .cloudMachinesSection: return { actions.refreshCloudMachines() } + case .devicesSection: return { actions.refreshDevices() } + default: return nil + } + } + /// Rows the pointer can act on take the shared hover fill. Create rows and /// tabs draw their own; readings, notes and the closing gap take none. static func showsRowHover(_ kind: CloudTreeNode.Kind) -> Bool { @@ -388,14 +404,29 @@ final class CloudTreeCellView: NSTableCellView { /// A hosting view that is invisible to hit testing, so the outline row beneath /// it owns selection, drag, double-click, and the context menu. final class CloudTreePassthroughHostingView: NSHostingView { + /// The coordinate space a row's content reports its clickable spots in. + static let coordinateSpace = "CloudTreeRowContent" var passesThrough = true + /// Spots that take clicks even while the rest passes through, like a + /// section header's refresh icon, in this view's (flipped) coordinates. + var interactiveRects: [CGRect] = [] override func hitTest(_ point: NSPoint) -> NSView? { guard passesThrough else { return super.hitTest(point) } + let local = convert(point, from: superview) + if interactiveRects.contains(where: { $0.contains(local) }) { return super.hitTest(point) } // The outline owns all ordinary row interaction. Returning nil here is // what keeps a header click from being swallowed by the SwiftUI host. return nil } + + /// Whether a click (or this view's whole content) goes to SwiftUI. + func acceptsClick(_ event: NSEvent?) -> Bool { + guard passesThrough else { return true } + guard let event else { return false } + let local = convert(event.locationInWindow, from: nil) + return interactiveRects.contains { $0.contains(local) } + } } /// The hit-testable host for a row's hover buttons. `CloudTreeNSOutlineView` diff --git a/Sources/Cloud/CloudTreeNSOutlineView.swift b/Sources/Cloud/CloudTreeNSOutlineView.swift index 7a88d2ddbe66..838f67c5c20f 100644 --- a/Sources/Cloud/CloudTreeNSOutlineView.swift +++ b/Sources/Cloud/CloudTreeNSOutlineView.swift @@ -259,7 +259,7 @@ final class CloudTreeNSOutlineView: NSOutlineView { return !controls.isHiddenOrHasHiddenAncestor } if let actionHost = candidate as? CloudTreePassthroughHostingView, - !actionHost.passesThrough { + actionHost.acceptsClick(event) { return !actionHost.isHiddenOrHasHiddenAncestor } view = candidate.superview diff --git a/Sources/Cloud/CloudTreePendingMachineRowContent.swift b/Sources/Cloud/CloudTreePendingMachineRowContent.swift index ec2fa5c96524..28fd571d2264 100644 --- a/Sources/Cloud/CloudTreePendingMachineRowContent.swift +++ b/Sources/Cloud/CloudTreePendingMachineRowContent.swift @@ -21,7 +21,10 @@ struct CloudTreePendingMachineRowContent: View { HStack(alignment: .center, spacing: style.rowGrid.dotGap) { name statusGlyph + // In a narrow sidebar the name gives way first: the status is + // what the row is saying, and the tooltip carries the rest. status + .layoutPriority(1) Spacer(minLength: style.rowGrid.trailingGap) } } diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 057e0b94c4f9..a9a07724dcf6 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -15,6 +15,9 @@ struct CloudTreeRowContentView: View { var presenceHeads: [WorkspacePresenceParticipant] = [] var style: CloudTreeStyle = CloudTreeStyleStore.current var resources: CloudTreeMachineResourceSection? = nil + /// A section header's refresh, and where its icon sits (the header's one clickable spot). + var onRefresh: (() -> Void)? = nil + var onInteractiveFrame: (CGRect?) -> Void = { _ in } var body: some View { row @@ -146,14 +149,41 @@ struct CloudTreeRowContentView: View { /// leads with its identity glyph in the shared icon slot. @ViewBuilder private func groupRow(title: String) -> some View { - let label = CloudTreeGroupRowContent(title: title, count: Self.groupCount(for: kind), style: style) if let symbol = kind.sectionHeaderSymbol { - CloudTreeSectionHeaderRow(style: style, symbol: symbol) { label } + CloudTreeSectionHeaderRow(style: style, symbol: symbol) { groupLabel(title: title) } } else { - label + groupLabel(title: title) } } + @ViewBuilder + private func groupLabel(title: String) -> some View { + if let refresh = Self.sectionRefresh(for: kind), let onRefresh { + CloudTreeSectionRefreshHeader( + title: title, count: Self.groupCount(for: kind), style: style, refresh: refresh, + label: Self.refreshLabel(for: kind), action: onRefresh, onInteractiveFrame: onInteractiveFrame + ) + } else { + CloudTreeGroupRowContent(title: title, count: Self.groupCount(for: kind), style: style) + } + } + + /// The refresh icon a section header carries after its count; nil shows none. + static func sectionRefresh(for kind: CloudTreeNode.Kind) -> CloudTreeSectionRefresh? { + switch kind { + case .cloudMachinesSection(_, _, let refresh): refresh + case .devicesSection(let section): CloudTreeSectionRefresh(isRefreshing: section.isRefreshing) + default: nil + } + } + + private static func refreshLabel(for kind: CloudTreeNode.Kind) -> String { + if case .cloudMachinesSection = kind { + return String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines") + } + return String(localized: "cloudTree.menu.refresh", defaultValue: "Refresh") + } + /// The count a group header shows after its title ("My Devices 2"); nil shows none. static func groupCount(for kind: CloudTreeNode.Kind) -> CloudTreeGroupCount? { switch kind { diff --git a/Sources/Cloud/CloudTreeRowHoverButtons.swift b/Sources/Cloud/CloudTreeRowHoverButtons.swift index e8aefff156da..ae99b2bff6f3 100644 --- a/Sources/Cloud/CloudTreeRowHoverButtons.swift +++ b/Sources/Cloud/CloudTreeRowHoverButtons.swift @@ -12,31 +12,16 @@ struct CloudTreeRowHoverButtons: View { var body: some View { switch kind { + // The section headers' refresh icons sit after their counts + // (`CloudTreeSectionRefreshHeader`), not with these buttons. case .devicesSection(let section): - HStack(spacing: 2) { - refresh(String(localized: "cloudTree.menu.refresh", defaultValue: "Refresh"), isRefreshing: section.isRefreshing) { - nodeActions.refreshDevices() - } - .accessibilityIdentifier("DevicesRefreshButton") - CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) - } - case .cloudMachinesSection(let canCreateMachine, _, let sectionRefresh): - HStack(spacing: 2) { - if let sectionRefresh { - refresh( - String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines"), - isRefreshing: sectionRefresh.isRefreshing - ) { - nodeActions.refreshCloudMachines() - } - .accessibilityIdentifier("CloudRefreshMachinesButton") - } - if canCreateMachine { - plus(String(localized: "machines.new", defaultValue: "New Machine")) { - nodeActions.newMachine() - } - .accessibilityIdentifier("CloudMachinesNewMachineButton") + CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) + case .cloudMachinesSection(let canCreateMachine, _, _): + if canCreateMachine { + plus(String(localized: "machines.new", defaultValue: "New Machine")) { + nodeActions.newMachine() } + .accessibilityIdentifier("CloudMachinesNewMachineButton") } case .machine(let machine, _): // Always visible: New Workspace, and the machine's full context @@ -139,8 +124,8 @@ struct CloudTreeRowHoverButtons: View { switch kind { case .machine, .localMachine, .terminalsPool, .displaysPool, .workspacesGroup, .workspace, .devicesSection: return true - case .cloudMachinesSection(let canCreateMachine, _, let refresh): - return canCreateMachine || refresh != nil + case .cloudMachinesSection(let canCreateMachine, _, _): + return canCreateMachine case .pendingMachine: return true case .device(let row): @@ -155,13 +140,8 @@ struct CloudTreeRowHoverButtons: View { /// True when the row's buttons stay visible without hover. Machine rows /// keep + and ⋯ on screen so their actions are discoverable at rest. static func showsAtRest(for kind: CloudTreeNode.Kind) -> Bool { - switch kind { - case .machine: return true - // A running refresh keeps its spinner on screen without hover. - case .cloudMachinesSection(_, _, let refresh): return refresh?.isRefreshing == true - case .devicesSection(let section): return section.isRefreshing - default: return false - } + if case .machine = kind { return true } + return false } /// The Displays affordance remains visible while guest discovery is pending @@ -177,12 +157,6 @@ struct CloudTreeRowHoverButtons: View { MachinesChromeIconButton(symbolName: "plus", accessibilityLabel: label, isBusy: false, action: action) } - /// Refresh, with a spinner in its place (and clicks ignored) while it runs. - private func refresh(_ label: String, isRefreshing: Bool, action: @escaping () -> Void) -> some View { - MachinesChromeIconButton(symbolName: "arrow.clockwise", accessibilityLabel: label, isBusy: isRefreshing, action: action) - .disabled(isRefreshing) - } - private func xmark(_ label: String, action: @escaping () -> Void) -> some View { MachinesChromeIconButton(symbolName: "xmark", accessibilityLabel: label, isBusy: false, action: action) } diff --git a/Sources/Cloud/CloudTreeSectionRefreshHeader.swift b/Sources/Cloud/CloudTreeSectionRefreshHeader.swift new file mode 100644 index 000000000000..2f126ac7b3c8 --- /dev/null +++ b/Sources/Cloud/CloudTreeSectionRefreshHeader.swift @@ -0,0 +1,65 @@ +import CmuxCloud +import CmuxFoundation +import SwiftUI + +/// A section header ("Cloud Machines 3/5", "My Devices") with its refresh +/// icon right after the count, small and in the count's color. +struct CloudTreeSectionRefreshHeader: View { + let title: String + let count: CloudTreeGroupCount? + let style: CloudTreeStyle + let refresh: CloudTreeSectionRefresh + /// The icon's tooltip and accessibility label. + let label: String + let action: () -> Void + /// Reports the icon's frame in `CloudTreePassthroughHostingView.coordinateSpace`, + /// the only spot of the header that takes a click instead of the row. + let onInteractiveFrame: (CGRect?) -> Void + + @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification + @State private var isHovered = false + + var body: some View { + HStack(alignment: .center, spacing: scaled(4)) { + // The shared header, hugging its title and count (its own spacer + // collapses) so the icon sits right after the count. + CloudTreeGroupRowContent(title: title, count: count, style: style) + .fixedSize(horizontal: true, vertical: false) + .padding(.trailing, -style.rowGrid.trailingPadding) + refreshButton + Spacer(minLength: 0) + } + .padding(.trailing, style.rowGrid.trailingPadding) + .onDisappear { onInteractiveFrame(nil) } + } + + private var refreshButton: some View { + Button(action: action) { + Group { + if refresh.isRefreshing { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.7) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: scaled(max(8, style.detailSize - 2)), weight: .semibold)) + .foregroundStyle(isHovered ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary)) + } + } + .frame(width: scaled(14), height: scaled(14)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(refresh.isRefreshing) + .onHover { isHovered = $0 } + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named(CloudTreePassthroughHostingView.coordinateSpace)) + } action: { onInteractiveFrame($0) } + .help(label) + .accessibilityLabel(label) + } + + private func scaled(_ value: CGFloat) -> CGFloat { + GlobalFontMagnification.scaledSize(value, percent: magnification) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 4705c3e5402c..8ac5eb75d6e2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1030,6 +1030,7 @@ A12481200000000000000001 /* CloudTreeRowUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12481200000000000000002 /* CloudTreeRowUpdate.swift */; }; DEABB0C438031ADB363DE082 /* CloudTreeRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB22532809A3E370F5EE466 /* CloudTreeRowView.swift */; }; B29325C466BEAB8D272ED852 /* CloudTreeSectionHeaderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA9D99AC276D3C794C6702A /* CloudTreeSectionHeaderRow.swift */; }; + B7D2DF595F25E9DEBC907591 /* CloudTreeSectionRefreshHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3F6B820FF35BCF02EF6B2E /* CloudTreeSectionRefreshHeader.swift */; }; C16B6FB2E23E52FB6F4FA4A7 /* CloudTreeStyleGalleryWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E4DB2770E4B0CBF4C388E5 /* CloudTreeStyleGalleryWindow.swift */; }; C986A0010000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986A0020000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift */; }; A12357000000000000000002 /* CloudTreeTerminalNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12357000000000000000001 /* CloudTreeTerminalNavigationCoordinator.swift */; }; @@ -5530,6 +5531,7 @@ A12481200000000000000002 /* CloudTreeRowUpdate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeRowUpdate.swift; sourceTree = ""; }; BDB22532809A3E370F5EE466 /* CloudTreeRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeRowView.swift; sourceTree = ""; }; 7EA9D99AC276D3C794C6702A /* CloudTreeSectionHeaderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeSectionHeaderRow.swift; sourceTree = ""; }; + 4F3F6B820FF35BCF02EF6B2E /* CloudTreeSectionRefreshHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeSectionRefreshHeader.swift; sourceTree = ""; }; 46E4DB2770E4B0CBF4C388E5 /* CloudTreeStyleGalleryWindow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeStyleGalleryWindow.swift; sourceTree = ""; }; C986A0020000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeSurfaceDragPasteboardWriter.swift; sourceTree = ""; }; A12357000000000000000001 /* CloudTreeTerminalNavigationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeTerminalNavigationCoordinator.swift; sourceTree = ""; }; @@ -9589,6 +9591,7 @@ 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */, 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */, 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */, + 4F3F6B820FF35BCF02EF6B2E /* CloudTreeSectionRefreshHeader.swift */, ); name = Cloud; path = Cloud; @@ -14640,6 +14643,7 @@ A12481200000000000000001 /* CloudTreeRowUpdate.swift in Sources */, DEABB0C438031ADB363DE082 /* CloudTreeRowView.swift in Sources */, B29325C466BEAB8D272ED852 /* CloudTreeSectionHeaderRow.swift in Sources */, + B7D2DF595F25E9DEBC907591 /* CloudTreeSectionRefreshHeader.swift in Sources */, C16B6FB2E23E52FB6F4FA4A7 /* CloudTreeStyleGalleryWindow.swift in Sources */, C986A0010000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift in Sources */, A12357000000000000000002 /* CloudTreeTerminalNavigationCoordinator.swift in Sources */, diff --git a/cmuxTests/CloudTreeHeaderActionsTests.swift b/cmuxTests/CloudTreeHeaderActionsTests.swift index 180636641844..c2a5ccc3d82b 100644 --- a/cmuxTests/CloudTreeHeaderActionsTests.swift +++ b/cmuxTests/CloudTreeHeaderActionsTests.swift @@ -103,17 +103,16 @@ struct CloudTreeHeaderActionsTests { #expect(title.frame == restingTitleFrame) } - @Test("Section refresh icons show with the fleet and stay on screen while refreshing") + @Test("Section headers carry their refresh icon after the count, not with the hover buttons") func sectionRefresh() { let idle = CloudTreeNode.Kind.cloudMachinesSection(canCreateMachine: false, refresh: CloudTreeSectionRefresh()) - #expect(CloudTreeRowHoverButtons.hasButtons(for: idle), "a plan at its limit still refreshes") - #expect(!CloudTreeRowHoverButtons.showsAtRest(for: idle)) - let running = CloudTreeNode.Kind.cloudMachinesSection( - canCreateMachine: true, refresh: CloudTreeSectionRefresh(isRefreshing: true) - ) - #expect(CloudTreeRowHoverButtons.showsAtRest(for: running)) - #expect(!CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection()))) - #expect(CloudTreeRowHoverButtons.showsAtRest(for: .devicesSection(CloudTreeDevicesSection(isRefreshing: true)))) + #expect(CloudTreeRowContentView.sectionRefresh(for: idle) == CloudTreeSectionRefresh(), "a plan at its limit still refreshes") + #expect(!CloudTreeRowHoverButtons.hasButtons(for: idle)) + #expect(CloudTreeRowContentView.sectionRefresh(for: .cloudMachinesSection(canCreateMachine: true)) == nil, + "no refresh while Cloud is off") + let running = CloudTreeNode.Kind.devicesSection(CloudTreeDevicesSection(isRefreshing: true)) + #expect(CloudTreeRowContentView.sectionRefresh(for: running)?.isRefreshing == true) + #expect(CloudTreeRowContentView.sectionRefresh(for: .devicesSection(CloudTreeDevicesSection()))?.isRefreshing == false) } /// The header renders while Cloud Machines is off too; there it has nothing From b9c46ede66a211dd0c9c9dc6b178d46ec216d884 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:32:33 -0700 Subject: [PATCH 12/56] Unify machine drag release cleanup --- Sources/Cloud/CloudTreeOutlineView.swift | 24 +++++++++++++++++++ .../CloudTreeOutlineView+MachineLift.swift | 1 + 2 files changed, 25 insertions(+) diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 55db639ef65a..82e53eea795c 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -110,6 +110,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { var machineDetailLayout = CloudTreeMachineDetailLayout() private(set) var isUpdatingProgrammatically = false private var activeDrag: ActiveDrag? + private var machineLiftMouseUpMonitor: Any? // NSDraggingItem retains the writer for the live native session. A weak // coordinator edge prevents a retained writer/container cycle. private weak var activeDragWriter: CloudTreeSurfaceDragPasteboardWriter? @@ -160,6 +161,27 @@ struct CloudTreeOutlineView: NSViewRepresentable { } deinit { if let organizationObserver { NotificationCenter.default.removeObserver(organizationObserver) } + removeMachineLiftMouseUpMonitor() + } + + /// Removes the fallback monitor used when AppKit omits a drag-end callback. + private func removeMachineLiftMouseUpMonitor() { + if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } + machineLiftMouseUpMonitor = nil + } + + /// Finishes the native drag through the same coordinator path as `endedAt`. + func installMachineLiftMouseUpMonitor(for session: NSDraggingSession, in outline: CloudTreeNSOutlineView) { + removeMachineLiftMouseUpMonitor() + machineLiftMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in + MainActor.assumeIsolated { + guard let self, + self.activeDragSession === session, + self.activeDragSequenceNumber == session.draggingSequenceNumber else { return } + self.outlineView(outline, draggingSession: session, endedAt: event.locationInWindow, operation: []) + } + return event + } } private func discardPendingDrag(_ pending: PendingDrag) { pending.registration.end() @@ -225,6 +247,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { /// The boundary is safe because AppKit does not dispatch a new /// `mouseDown` while the older native drag loop is still running. func prepareForNativeDragBoundary(on sourceView: CloudTreeNSOutlineView) { + removeMachineLiftMouseUpMonitor() if let activeDragSourceView, activeDragSourceView !== sourceView, outlineView !== sourceView { // A stale callback from an older outline must not retire the @@ -1009,6 +1032,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { } func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + removeMachineLiftMouseUpMonitor() if let supersededDragSession, supersededDragSession === session { // This is the terminal callback for a source already retired diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift index ce030bebfc96..91929af5b509 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift @@ -25,6 +25,7 @@ extension CloudTreeOutlineView.Coordinator { for machine in machines { outline.collapseItem(machine) } } } + installMachineLiftMouseUpMonitor(for: session, in: outline) } /// The real row is the drag visual, so the native image is blank and From e03ae8cdcdc57267b030c8b6a351e9e7d2ec7e7c Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 15:40:48 +1300 Subject: [PATCH 13/56] cloud sidebar: refresh icon hover fill, a quieter tab switch, "Discover other devices" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the section headers' refresh icons take the same hover fill as + and ⋯; their resting color and size are unchanged - switching tabs no longer moves a highlight across the bar. the widths ease on one short curve with no overshoot, the old tab's fill fades as it narrows and the new one's as it opens, labels are uncovered by their slot (with a soft edge) instead of re-truncating every frame, and an icon-only tab's icon glides to its leading spot - my devices and settings say "Discover other devices" (and "Stop discovering other devices"), in all 9 locales --- .../Models/DeviceAccessControl.swift | 4 +- .../CuratedSettingEntry+Devices.swift | 2 +- .../Resources/Localizable.xcstrings | 18 +++--- .../Sections/ComputersSection.swift | 2 +- .../DeviceAccessControlTests.swift | 4 +- Resources/Localizable.xcstrings | 36 +++++------ .../Cloud/CloudTreeSectionRefreshHeader.swift | 8 ++- Sources/Hive/HiveComputersService.swift | 2 +- Sources/ModeBarLabelEdgeFade.swift | 23 +++++++ Sources/RightSidebarChromeStyle.swift | 62 +++++++++---------- Sources/RightSidebarPanelView.swift | 11 ++-- cmux.xcodeproj/project.pbxproj | 4 ++ .../DeviceDiscoverabilityGatingTests.swift | 2 +- .../DeviceDiscoverabilityUITests.swift | 14 ++--- .../SettingsComputersBehaviorUITests.swift | 14 ++--- 15 files changed, 117 insertions(+), 89 deletions(-) create mode 100644 Sources/ModeBarLabelEdgeFade.swift diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift index 30dcecb37c6b..758004fb61c8 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift @@ -33,8 +33,8 @@ public struct DeviceAccessControl { switch preference { case .discovery: return isOn - ? String(localized: "devices.discovery.stop", defaultValue: "Stop discovering other Macs", bundle: .module) - : String(localized: "devices.discovery.toggle", defaultValue: "Discover other Macs") + ? String(localized: "devices.discovery.stop", defaultValue: "Stop discovering other devices", bundle: .module) + : String(localized: "devices.discovery.toggle", defaultValue: "Discover other devices") case .incomingAccess: return isOn ? String(localized: "devices.incoming.hide", defaultValue: "Hide this Mac from My Devices", bundle: .module) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift index 7c2fd18ae850..2381d00e5725 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift @@ -27,7 +27,7 @@ extension Array where Element == CuratedSettingEntry { .init( section: .computers, id: "discovery", - title: String(localized: "devices.discovery.toggle", defaultValue: "Discover other Macs"), + title: String(localized: "devices.discovery.toggle", defaultValue: "Discover other devices"), detailText: String(localized: "devices.discovery.help", defaultValue: "Find and connect to other Macs signed in to your account. Turning this off disconnects their panes without closing their terminals."), synonyms: "Discover other Macs discovery find connect other computers my devices" ) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings index 18aeb8fc5e79..d533578262b9 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings @@ -7101,55 +7101,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Stop discovering other Macs" + "value": "Stop discovering other devices" } }, "ar": { "stringUnit": { "state": "translated", - "value": "إيقاف اكتشاف أجهزة Mac الأخرى" + "value": "إيقاف اكتشاف الأجهزة الأخرى" } }, "de": { "stringUnit": { "state": "translated", - "value": "Andere Macs nicht mehr finden" + "value": "Andere Geräte nicht mehr finden" } }, "es": { "stringUnit": { "state": "translated", - "value": "Dejar de descubrir otros Mac" + "value": "Dejar de descubrir otros dispositivos" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Arrêter de rechercher d’autres Mac" + "value": "Arrêter de rechercher d’autres appareils" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ほかのMacの検出を停止" + "value": "ほかのデバイスの検出を停止" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다른 Mac 검색 중지" + "value": "다른 기기 검색 중지" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "停止发现其他 Mac" + "value": "停止发现其他设备" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "停止尋找其他 Mac" + "value": "停止尋找其他裝置" } } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index afac2bfbca54..833cd2f9fa43 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -64,7 +64,7 @@ public struct ComputersSection: View { } else if discoveryManaged { SettingsCardNote(String(localized: "devices.managed", defaultValue: "Disabled by your administrator.")) } else if !discoveryEnabled { - SettingsCardNote(String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other Macs to see your devices.")) + SettingsCardNote(String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other devices to see your devices.")) } else if snapshot.computers.isEmpty { VStack(alignment: .leading, spacing: 6) { Label(String(localized: "devices.empty.title", defaultValue: "No other devices yet"), systemImage: "desktopcomputer") diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift index 6536692e7cf8..72d512231f5b 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift @@ -8,7 +8,7 @@ struct DeviceAccessControlTests { let incoming = DeviceAccessControl(.incomingAccess, enabled: enabled, managed: false) let discovery = DeviceAccessControl(.discovery, enabled: enabled, managed: false) #expect(incoming.title == (enabled ? "Hide this Mac from My Devices" : "Make this Mac discoverable")) - #expect(discovery.title == (enabled ? "Stop discovering other Macs" : "Discover other Macs")) + #expect(discovery.title == (enabled ? "Stop discovering other devices" : "Discover other devices")) #expect(incoming.isOn == enabled) #expect(discovery.isOn == enabled) #expect(incoming.isEnabled && discovery.isEnabled) @@ -20,7 +20,7 @@ struct DeviceAccessControlTests { let control = DeviceAccessControl(preference, enabled: true, managed: managed, unavailable: !managed) #expect(!control.isOn) #expect(!control.isEnabled) - #expect(control.title == (preference == .incomingAccess ? "Make this Mac discoverable" : "Discover other Macs")) + #expect(control.title == (preference == .incomingAccess ? "Make this Mac discoverable" : "Discover other devices")) if managed { #expect(control.help == "Disabled by your administrator.") } } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e5f6d86a2f71..ebdef38e85cc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -527793,55 +527793,55 @@ "ar": { "stringUnit": { "state": "translated", - "value": "اكتشاف أجهزة Mac الأخرى" + "value": "اكتشاف الأجهزة الأخرى" } }, "de": { "stringUnit": { "state": "translated", - "value": "Andere Macs finden" + "value": "Andere Geräte finden" } }, "en": { "stringUnit": { "state": "translated", - "value": "Discover other Macs" + "value": "Discover other devices" } }, "es": { "stringUnit": { "state": "translated", - "value": "Descubrir otros Mac" + "value": "Descubrir otros dispositivos" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Rechercher d’autres Mac" + "value": "Rechercher d’autres appareils" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ほかのMacを検出" + "value": "ほかのデバイスを検出" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다른 Mac 검색" + "value": "다른 기기 검색" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "发现其他 Mac" + "value": "发现其他设备" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "尋找其他 Mac" + "value": "尋找其他裝置" } } } @@ -569834,55 +569834,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Turn on Discover other Macs to see your devices." + "value": "Turn on Discover other devices to see your devices." } }, "ar": { "stringUnit": { "state": "translated", - "value": "فعّل «اكتشاف أجهزة Mac الأخرى» لعرض أجهزتك." + "value": "فعّل «اكتشاف الأجهزة الأخرى» لعرض أجهزتك." } }, "de": { "stringUnit": { "state": "translated", - "value": "Schalte „Andere Macs finden“ ein, um deine Geräte zu sehen." + "value": "Schalte „Andere Geräte finden“ ein, um deine Geräte zu sehen." } }, "es": { "stringUnit": { "state": "translated", - "value": "Activa «Descubrir otros Mac» para ver tus dispositivos." + "value": "Activa «Descubrir otros dispositivos» para ver tus dispositivos." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Activez « Rechercher d’autres Mac » pour voir vos appareils." + "value": "Activez « Rechercher d’autres appareils » pour voir vos appareils." } }, "ja": { "stringUnit": { "state": "translated", - "value": "デバイスを表示するには、「ほかのMacを検出」をオンにしてください。" + "value": "デバイスを表示するには、「ほかのデバイスを検出」をオンにしてください。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "기기를 보려면 '다른 Mac 검색'을 켜세요." + "value": "기기를 보려면 '다른 기기 검색'을 켜세요." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "打开“发现其他 Mac”以查看你的设备。" + "value": "打开“发现其他设备”以查看你的设备。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "開啟「尋找其他 Mac」以查看您的裝置。" + "value": "開啟「尋找其他裝置」以查看您的裝置。" } } } diff --git a/Sources/Cloud/CloudTreeSectionRefreshHeader.swift b/Sources/Cloud/CloudTreeSectionRefreshHeader.swift index 2f126ac7b3c8..a2e27e3032f8 100644 --- a/Sources/Cloud/CloudTreeSectionRefreshHeader.swift +++ b/Sources/Cloud/CloudTreeSectionRefreshHeader.swift @@ -46,10 +46,16 @@ struct CloudTreeSectionRefreshHeader: View { .foregroundStyle(isHovered ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary)) } } - .frame(width: scaled(14), height: scaled(14)) + // The same hit area and hover fill as the headers' + and ⋯ + // (`MachinesChromeIconButton`); the glyph keeps its own size. + .frame(width: 22, height: 20) .contentShape(Rectangle()) } .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) + .fill(isHovered && !refresh.isRefreshing ? Color.primary.opacity(0.06) : Color.clear) + ) .disabled(refresh.isRefreshing) .onHover { isHovered = $0 } .onGeometryChange(for: CGRect.self) { proxy in diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 8341b44d930b..1449a1fdb83f 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -307,7 +307,7 @@ final class HiveComputersService { if ManagedDevicePolicy().isDeviceDiscoveryDisabled { return String(localized: "devices.managed", defaultValue: "Disabled by your administrator.") } - return String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other Macs to see your devices.") + return String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other devices to see your devices.") } /// Why the account's Macs cannot be listed at all, or `nil` while diff --git a/Sources/ModeBarLabelEdgeFade.swift b/Sources/ModeBarLabelEdgeFade.swift new file mode 100644 index 000000000000..e93333083b7e --- /dev/null +++ b/Sources/ModeBarLabelEdgeFade.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// The mask over a mode tab's label: opaque, with a short fade at the +/// trailing edge while the label is wider than its slot. +struct ModeBarLabelEdgeFade: View { + let naturalWidth: CGFloat + + var body: some View { + GeometryReader { proxy in + let width = max(proxy.size.width, 1) + let fade = min(10, max(0, naturalWidth - proxy.size.width)) + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: max(0, (width - fade) / width)), + .init(color: .black.opacity(fade > 0 ? 0 : 1), location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + } +} diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index b7f82cb85c0d..2a29f64d9d86 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -135,9 +135,6 @@ struct RightSidebarChromePillModifier: ViewModifier { var isHovered: Bool var horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding var geometryKeyPrefix: String? - /// When set, the selected fill is one shared shape that moves between - /// the pills of this namespace instead of each pill painting its own. - var selectionNamespace: Namespace.ID? @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent func body(content: Content) -> some View { @@ -151,24 +148,10 @@ struct RightSidebarChromePillModifier: ViewModifier { keyPrefix: geometryKeyPrefix, isVisible: true ) - .background { - let shape = RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) - if let selectionNamespace { - ZStack { - shape.fill(isSelected ? Color.clear : backgroundColor) - if isSelected { - // No fade: the old and new highlights crossfading - // while they slide read as a flash of colour. One - // fill moves, at one opacity, the whole way. - shape.fill(backgroundColor) - .matchedGeometryEffect(id: "rightSidebarChromePillSelection", in: selectionNamespace) - .transition(.identity) - } - } - } else { - shape.fill(backgroundColor) - } - } + .background( + RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) + .fill(backgroundColor) + ) .contentShape( RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) ) @@ -296,16 +279,14 @@ extension View { isSelected: Bool, isHovered: Bool, horizontalPadding: CGFloat = RightSidebarChromeMetrics.controlHorizontalPadding, - geometryKeyPrefix: String? = nil, - selectionNamespace: Namespace.ID? = nil + geometryKeyPrefix: String? = nil ) -> some View { modifier( RightSidebarChromePillModifier( isSelected: isSelected, isHovered: isHovered, horizontalPadding: horizontalPadding, - geometryKeyPrefix: geometryKeyPrefix, - selectionNamespace: selectionNamespace + geometryKeyPrefix: geometryKeyPrefix ) ) } @@ -381,17 +362,23 @@ struct ModeBarButton: View { let item: RightSidebarModeBarItem let isSelected: Bool var badgeCount: Int = 0 - var selectionNamespace: Namespace.ID? = nil let shortcutHint: StoredShortcut let showsShortcutHint: Bool let action: () -> Void @State private var isHovered: Bool = false - /// False once the label is truncated to less than a letter and an - /// ellipsis; the tab then shows only its icon. The label keeps its slot, - /// so hiding it never changes the tab's width. + /// False once the label's slot is narrower than about a letter; the tab + /// then shows only its icon. The label keeps its slot, so hiding it + /// never changes the tab's width. @State private var labelFits = true @State private var labelWidth: CGFloat = 0 + /// The label's full width, which its slot may be narrower than. + @State private var naturalLabelWidth: CGFloat = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The tab switch's one curve: smooth, short and without overshoot, so + /// tabs settle into their new widths instead of bouncing like a reorder. + static let switchAnimation = Animation.smooth(duration: 0.26) /// With its label hidden, the icon (and badge) moves into the middle of /// the label's empty slot, so it sits centered in the tab's highlight. @@ -415,13 +402,21 @@ struct ModeBarButton: View { isVisible: true ) .offset(x: badgeCount > 0 ? 0 : hiddenLabelShift) + // The label keeps its natural width and its slot uncovers it: + // a slot that narrows clips with a soft edge rather than + // re-truncating ("Files", "Fil…", "F…") on every frame of a + // width change. Text(item.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight ) .lineLimit(1) - .truncationMode(.tail) + .fixedSize() + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { naturalLabelWidth = $0 } + .frame(minWidth: 0, alignment: .leading) + .clipped() + .mask { ModeBarLabelEdgeFade(naturalWidth: naturalLabelWidth) } .opacity(labelFits ? 1 : 0) .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.width @@ -436,8 +431,7 @@ struct ModeBarButton: View { .rightSidebarChromePill( isSelected: isSelected, isHovered: isHovered, - geometryKeyPrefix: "rightSidebarModeControl_\(item.id)", - selectionNamespace: selectionNamespace + geometryKeyPrefix: "rightSidebarModeControl_\(item.id)" ) .overlay(alignment: .trailing) { if showsShortcutHint { @@ -450,6 +444,10 @@ struct ModeBarButton: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + // The label's fade and the icon's glide to or from the middle follow + // the width on the same curve. They change a layout pass after the + // width, so they carry their own animation rather than the switch's. + .animation(reduceMotion ? nil : Self.switchAnimation, value: labelFits) .titlebarInteractiveControl() .onHover { isHovered = $0 } .help(helpText) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index b744ae559134..03d32769bb17 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -87,8 +87,6 @@ struct RightSidebarPanelView: View { @State private var hasMountedRightSidebarContent = false @State private var modeBarDrag = RightSidebarModeBarDragController() @State private var modeBarWidthReport = RightSidebarModeBarWidthReport() - /// One selection highlight that slides between the mode tabs. - @Namespace private var modeSelectionNamespace @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let alwaysShowShortcutHints = ShortcutHintDebugSettings().alwaysShowHints @@ -241,7 +239,6 @@ struct RightSidebarPanelView: View { mode: fileExplorerState.mode ), badgeCount: item.mode == .feed ? feedPendingCount : 0, - selectionNamespace: modeSelectionNamespace, shortcutHint: shortcut, showsShortcutHint: ShortcutHintTitlebarPolicy.shouldShow( shortcut: shortcut, @@ -266,10 +263,10 @@ struct RightSidebarPanelView: View { .layoutValue(key: RightSidebarModeBarTabSelectedKey.self, value: item.isSelected(mode: fileExplorerState.mode)) } } - // Switching tabs slides the highlight and re-shares the widths - // on one spring: the new tab opens to its label as the old - // one narrows. - .animation(reduceMotion ? nil : RightSidebarModeBarDragController.spring, value: fileExplorerState.mode) + // Switching tabs eases the widths: the old tab's fill fades as + // it narrows, the new one's fades in as it opens. No highlight + // travels across the bar. + .animation(reduceMotion ? nil : ModeBarButton.switchAnimation, value: fileExplorerState.mode) .background(RightSidebarModeBarDragAnchorView(anchor: modeBarDrag.anchor)) .coordinateSpace(.named(RightSidebarModeBarDragController.coordinateSpace)) .layoutPriority(1) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8ac5eb75d6e2..4f93ad6ad6cb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2569,6 +2569,7 @@ 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */; }; D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */; }; 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */; }; + 238F7F4A1651DD3FFA9B3C1E /* ModeBarLabelEdgeFade.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; D77690000000000000000005 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000006 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift */; }; E05E36509344691CA085F0CC /* NarrowWindowSidePanelFitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C6C5413708B2C95D6C1CC3 /* NarrowWindowSidePanelFitTests.swift */; }; @@ -6932,6 +6933,7 @@ C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceListObserver.swift; sourceTree = ""; }; D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceObserverSignposts.swift; sourceTree = ""; }; CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBridgeInputCapture.swift; sourceTree = ""; }; + A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeBarLabelEdgeFade.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; D77690000000000000000006 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsWorkspaceHeadlineUITests.swift; sourceTree = ""; }; D0C6C5413708B2C95D6C1CC3 /* NarrowWindowSidePanelFitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NarrowWindowSidePanelFitTests.swift"; sourceTree = ""; }; @@ -11798,6 +11800,7 @@ DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */, C8D88A287C6181685BC9F4C4 /* RightSidebarModeBarDragLayout.swift */, 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */, + A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */, ); path = Sources; sourceTree = ""; @@ -15418,6 +15421,7 @@ C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */, 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */, D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */, + 238F7F4A1651DD3FFA9B3C1E /* ModeBarLabelEdgeFade.swift in Sources */, 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */, A6AC72030000000000000001 /* NativeSpinner.swift in Sources */, A1002000000000000000000F /* NativeTextSurfaceSelectionReader.swift in Sources */, diff --git a/cmuxTests/DeviceDiscoverabilityGatingTests.swift b/cmuxTests/DeviceDiscoverabilityGatingTests.swift index ffd44a7e5d52..34721fb0b7b9 100644 --- a/cmuxTests/DeviceDiscoverabilityGatingTests.swift +++ b/cmuxTests/DeviceDiscoverabilityGatingTests.swift @@ -17,7 +17,7 @@ struct DeviceDiscoverabilityGatingTests { #expect(section.inlineRowCount == 3) #expect(!section.discoveryControl.isEnabled) #expect(!section.incomingControl.isEnabled) - #expect(section.discoveryControl.title == "Discover other Macs") + #expect(section.discoveryControl.title == "Discover other devices") #expect(section.incomingControl.title == "Make this Mac discoverable") let nodes = CloudTreeNodeBuilder.nodes( machines: [], snapshot: .empty, localWorkspaces: [], includeLocalMachine: false, diff --git a/cmuxUITests/DeviceDiscoverabilityUITests.swift b/cmuxUITests/DeviceDiscoverabilityUITests.swift index 4c0d4fab9e3e..87a970881615 100644 --- a/cmuxUITests/DeviceDiscoverabilityUITests.swift +++ b/cmuxUITests/DeviceDiscoverabilityUITests.swift @@ -33,7 +33,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { XCTAssertTrue(poll(timeout: 5) { !self.isOn(settingsDiscovery) }) } XCTAssertEqual(settingsIncoming.label, "Make this Mac discoverable") - XCTAssertEqual(settingsDiscovery.label, "Discover other Macs") + XCTAssertEqual(settingsDiscovery.label, "Discover other devices") settingsIncoming.click() assertConfirmation(app) @@ -55,7 +55,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { settingsDiscovery.click() XCTAssertTrue(poll(timeout: 5) { self.isOn(settingsDiscovery) }) - XCTAssertEqual(settingsDiscovery.label, "Stop discovering other Macs") + XCTAssertEqual(settingsDiscovery.label, "Stop discovering other devices") XCTAssertFalse(app.sheets.firstMatch.exists) closeSettings(app, window) let reopened = openSettings(app) @@ -78,12 +78,12 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { incoming.click() XCTAssertTrue(poll(timeout: 5) { incoming.label == "Make this Mac discoverable" }) } - if discovery.label == "Stop discovering other Macs" { + if discovery.label == "Stop discovering other devices" { discovery.click() - XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other Macs" }) + XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other devices" }) } XCTAssertEqual(incoming.label, "Make this Mac discoverable") - XCTAssertEqual(discovery.label, "Discover other Macs") + XCTAssertEqual(discovery.label, "Discover other devices") capture(app, "devices-controls-off-at-rest") incoming.click() @@ -96,7 +96,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { app.sheets.buttons["Make Discoverable"].firstMatch.click() XCTAssertTrue(poll(timeout: 5) { incoming.label == "Hide this Mac from My Devices" }) discovery.click() - XCTAssertTrue(poll(timeout: 5) { discovery.label == "Stop discovering other Macs" }) + XCTAssertTrue(poll(timeout: 5) { discovery.label == "Stop discovering other devices" }) XCTAssertFalse(app.sheets.firstMatch.exists) XCTAssertTrue(incoming.isHittable && discovery.isHittable) capture(app, "devices-controls-on-at-rest") @@ -110,7 +110,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { XCTAssertTrue(poll(timeout: 5) { incoming.label == "Make this Mac discoverable" }) XCTAssertFalse(app.sheets.firstMatch.exists) discovery.click() - XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other Macs" }) + XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other devices" }) XCTAssertFalse(app.sheets.firstMatch.exists) XCTAssertTrue(incoming.isHittable && discovery.isHittable) } diff --git a/cmuxUITests/SettingsComputersBehaviorUITests.swift b/cmuxUITests/SettingsComputersBehaviorUITests.swift index df75d4aaae72..7e02d49cbddb 100644 --- a/cmuxUITests/SettingsComputersBehaviorUITests.swift +++ b/cmuxUITests/SettingsComputersBehaviorUITests.swift @@ -62,7 +62,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { let incomingAccess = toggle(window, id: incomingAccessToggleID) let refresh = window.buttons[refreshButtonID] XCTAssertTrue(window.descendants(matching: .any)["SettingsComputersHeading"].exists) - XCTAssertTrue(poll(timeout: 4) { self.isVisible(discovery, in: window) }, "the Devices page should show Discover other Macs") + XCTAssertTrue(poll(timeout: 4) { self.isVisible(discovery, in: window) }, "the Devices page should show Discover other devices") XCTAssertTrue(poll(timeout: 4) { self.isVisible(incomingAccess, in: window) }, "the Devices page should show Make this Mac discoverable") XCTAssertTrue(poll(timeout: 4) { self.isVisible(refresh, in: window) }, "the Devices page should show Refresh") @@ -71,10 +71,10 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { after.lifetime = .keepAlways add(after) - XCTAssertTrue(poll(timeout: 4) { discovery.isEnabled }, "Discover other Macs should be switchable while Cloud Machines is on") - XCTAssertTrue(poll(timeout: 4) { !self.isToggleOn(discovery) }, "Discover other Macs should start off") + XCTAssertTrue(poll(timeout: 4) { discovery.isEnabled }, "Discover other devices should be switchable while Cloud Machines is on") + XCTAssertTrue(poll(timeout: 4) { !self.isToggleOn(discovery) }, "Discover other devices should start off") discovery.click() - XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(discovery) }, "Discover other Macs should read on after a click") + XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(discovery) }, "Discover other devices should read on after a click") // The switch writes the shared My Devices preference, so a fresh // Settings window reads the new value back. @@ -83,7 +83,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { defer { closeSettings(app, window) } navigate(window, to: "Devices") let reopened = toggle(window, id: discoveryToggleID) - XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(reopened) }, "Discover other Macs should stay on after reopening Settings") + XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(reopened) }, "Discover other devices should stay on after reopening Settings") } /// Settings shows one section at a time, so with Mobile selected the @@ -102,7 +102,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { "Mobile's content should be the shown pane" ) let controls = [ - ("Discover other Macs", window.descendants(matching: .any)[discoveryToggleID]), + ("Discover other devices", window.descendants(matching: .any)[discoveryToggleID]), ("Make this Mac discoverable", window.descendants(matching: .any)[incomingAccessToggleID]), ("Refresh", window.buttons[refreshButtonID]), ] @@ -217,7 +217,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { screenshot.lifetime = .keepAlways add(screenshot) - XCTAssertTrue(poll(timeout: 4) { !discovery.isEnabled }, "Discover other Macs should be disabled while Cloud Machines is off") + XCTAssertTrue(poll(timeout: 4) { !discovery.isEnabled }, "Discover other devices should be disabled while Cloud Machines is off") XCTAssertTrue(poll(timeout: 4) { !incomingAccess.isEnabled }, "Make this Mac discoverable should be disabled while Cloud Machines is off") } From 9cb17b6951c5b2530b19b129b685ca28c1671c45 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:43:09 -0700 Subject: [PATCH 14/56] Fix Swift 6 drag cleanup build errors --- Sources/Cloud/CloudTreeOutlineView.swift | 2 +- Sources/Cloud/Sidebar/CloudMachineReorderScope.swift | 5 +++++ Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift | 10 +++++----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 82e53eea795c..213a6f005091 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -161,7 +161,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { } deinit { if let organizationObserver { NotificationCenter.default.removeObserver(organizationObserver) } - removeMachineLiftMouseUpMonitor() + if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } } /// Removes the fallback monitor used when AppKit omits a drag-end callback. diff --git a/Sources/Cloud/Sidebar/CloudMachineReorderScope.swift b/Sources/Cloud/Sidebar/CloudMachineReorderScope.swift index d1300997df02..339640dde402 100644 --- a/Sources/Cloud/Sidebar/CloudMachineReorderScope.swift +++ b/Sources/Cloud/Sidebar/CloudMachineReorderScope.swift @@ -6,6 +6,11 @@ struct CloudMachineReorderScope { let parent: CloudTreeNode? let siblings: [CloudTreeNode] + private init(parent: CloudTreeNode?, siblings: [CloudTreeNode]) { + self.parent = parent + self.siblings = siblings + } + /// Resolves the single sibling list that owns machine reordering. static func resolve(machineNodeID: String? = nil, roots: [CloudTreeNode]) -> CloudMachineReorderScope? { let rootMatches = roots.filter { node in diff --git a/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift b/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift index 5fc8db286b2b..ca9cf9a66c0c 100644 --- a/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift +++ b/Sources/Cloud/Sidebar/CloudTreeMachineLiftStyle.swift @@ -25,7 +25,7 @@ struct CloudTreeMachineLiftStyle { CATransaction.commit() guard card.opacity != 1 else { return } if animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { - fade(card, to: 1, duration: fadeIn) + fade(card, to: 1, duration: Self.fadeIn) } else { card.removeAllAnimations() CATransaction.begin() @@ -38,7 +38,7 @@ struct CloudTreeMachineLiftStyle { func remove(from rowView: NSTableRowView, animated: Bool) { guard let host = rowView.layer, let card = existingCard(in: host), card.opacity != 0 else { return } if animated { - fade(card, to: 0, duration: fadeOut) + fade(card, to: 0, duration: Self.fadeOut) } else { card.removeAllAnimations() CATransaction.begin() @@ -49,17 +49,17 @@ struct CloudTreeMachineLiftStyle { } private func existingCard(in host: CALayer) -> CALayer? { - host.sublayers?.first { $0.name == layerName } + host.sublayers?.first { $0.name == Self.layerName } } private func makeCard(in host: CALayer) -> CALayer { let card = CALayer() - card.name = layerName + card.name = Self.layerName card.opacity = 0 card.cornerRadius = CloudTreeHoverStyle.cornerRadius card.cornerCurve = .continuous card.shadowColor = NSColor.black.cgColor - card.shadowOpacity = shadowOpacity + card.shadowOpacity = Self.shadowOpacity card.shadowRadius = 8 card.shadowOffset = .zero // Below the hover fill and the cell, above nothing: the card replaces From bf9dea6f173c0da22d8ff0201b21538c5aeb5e6c Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 15:48:05 +1300 Subject: [PATCH 15/56] cloud sidebar: a failed create's row is just its notice the row drops the "New Machine" name once the create has failed and shows only the warning and "Couldn't create machine"; the name and the reason stay in its tooltip and on the create's page. --- .../CloudTreePendingMachineRowContent.swift | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/Sources/Cloud/CloudTreePendingMachineRowContent.swift b/Sources/Cloud/CloudTreePendingMachineRowContent.swift index 28fd571d2264..4aa9222f669b 100644 --- a/Sources/Cloud/CloudTreePendingMachineRowContent.swift +++ b/Sources/Cloud/CloudTreePendingMachineRowContent.swift @@ -19,10 +19,10 @@ struct CloudTreePendingMachineRowContent: View { // Centered, not baseline-aligned: the glyph has no text // baseline, so on a baseline it drops below the name. HStack(alignment: .center, spacing: style.rowGrid.dotGap) { - name + // A failed create is only its notice; the name and the + // reason are in the tooltip and on the create's page. + if !isFailed { name } statusGlyph - // In a narrow sidebar the name gives way first: the status is - // what the row is saying, and the tooltip carries the rest. status .layoutPriority(1) Spacer(minLength: style.rowGrid.trailingGap) @@ -33,13 +33,21 @@ struct CloudTreePendingMachineRowContent: View { case .twoLine: HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: scaled(style.rowGrid.machineLineSpacing)) { - HStack(alignment: .center, spacing: style.rowGrid.dotGap) { - name - statusGlyph + if isFailed { + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { + statusGlyph + status + } + .frame(height: scaled(style.machineNameLineHeight)) + } else { + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { + name + statusGlyph + } + .frame(height: scaled(style.machineNameLineHeight)) + status + .frame(height: scaled(style.machineSubtitleLineHeight)) } - .frame(height: scaled(style.machineNameLineHeight)) - status - .frame(height: scaled(style.machineSubtitleLineHeight)) } Spacer(minLength: style.rowGrid.trailingGap) } @@ -68,6 +76,8 @@ struct CloudTreePendingMachineRowContent: View { } } + private var isFailed: Bool { operation.failureOutput != nil } + private func scaled(_ value: CGFloat) -> CGFloat { GlobalFontMagnification.scaledSize(value, percent: magnification) } From becd460921f0af3e4f1135ff0141cf4bf31818a1 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 16:02:02 +1300 Subject: [PATCH 16/56] cloud sidebar: clicking a header's refresh icon refreshes instead of toggling the section each reload of a section header cleared the icon's clickable spot, and SwiftUI reports the icon's frame only when it moves, so after the first reload the click fell through to the row and opened or closed the section. a header now keeps the spot across reloads; it clears only when the cell is reused for another kind of row. --- Sources/Cloud/CloudTreeCellView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 38cecbf22653..cc754f51209d 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -288,7 +288,11 @@ final class CloudTreeCellView: NSTableCellView { let description = CloudTreeRowToolTip.describe(node: node, style: style, presenceHeads: presenceHeads) toolTip = description.toolTip setAccessibilityLabel(description.accessibilityLabel) - displayHost.interactiveRects = [] + // A header keeps its icon's clickable spot across reloads: SwiftUI + // reports the icon's frame only when it moves, and a reload that + // leaves it in place would otherwise leave the header with no + // clickable spot, so a click on the icon would toggle the section. + if CloudTreeRowContentView.sectionRefresh(for: node.kind) == nil { displayHost.interactiveRects = [] } displayHost.rootView = AnyView( CloudTreeRowContentView( kind: node.kind, presenceHeads: presenceHeads, style: style, resources: node.resourceSection, From c0999688884117df34a1ea1f399ec6206f84232a Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sat, 3 Oct 2026 16:11:35 +1300 Subject: [PATCH 17/56] cloud sidebar polish: fit the merge under the line budgets both header icons use the panel's one refresh (fleet and devices) instead of two new single-section actions; each icon still spins only for its own section. shorter ports status comment and denser panel arguments. --- Sources/Cloud/CloudTreeCellView.swift | 4 ++-- Sources/Cloud/CloudTreeNodeActions.swift | 2 -- Sources/Cloud/CloudTreeOutlineView.swift | 4 +--- Sources/Cloud/MachinesPanelView.swift | 9 ++------- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index cc754f51209d..40a7d0238a76 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -309,8 +309,8 @@ final class CloudTreeCellView: NSTableCellView { private static func sectionRefreshAction(_ kind: CloudTreeNode.Kind, _ actions: CloudTreeNodeActions?) -> (() -> Void)? { guard let actions else { return nil } switch kind { - case .cloudMachinesSection: return { actions.refreshCloudMachines() } - case .devicesSection: return { actions.refreshDevices() } + // The panel's one refresh: the fleet and the account's other devices. + case .cloudMachinesSection, .devicesSection: return { actions.refresh() } default: return nil } } diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index 4c089b9564fc..43cd9ca0e720 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -72,8 +72,6 @@ struct CloudTreeNodeActions { var newMachine: @MainActor () -> Void = {} /// Creates a workspace on the remembered or selected Cloud machine. var newWorkspaceOnResolvedMachine: @MainActor () -> Void = {} - /// The section headers' refresh icons: the fleet, and the account's other Macs. - var refreshCloudMachines: @MainActor () -> Void = {}, refreshDevices: @MainActor () -> Void = {} /// Pops up a row's context menu from its trailing "⋯" button. Bound per /// cell, so the button and a right-click show the same menu. var showRowMenu: @MainActor (_ nodeID: String) -> Void = { _ in } diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index b4fe8d136839..a2a5e9e047c9 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -610,9 +610,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { nodeActions.newMachine() return } - // A Ports status row (No reachable ports, asleep, Discovering…) is - // text with its own button; only that button acts, so a click on the - // text never refreshes or wakes. + // A Ports status row's text never acts; only its own button refreshes or wakes. guard placeholder.portStatus == nil else { return } // "Asleep — open to wake": a fresh terminal on the machine is what wakes it. if placeholder.opensMachine, let machine = machine(id: machineID) { openMachine(machine) } diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index debea9f4314b..61daf7d074ed 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -451,8 +451,6 @@ struct MachinesPanelView: View { nodeActions.setDeviceIncomingAccess = { [weak devicesModel] enabled in Task { await devicesModel?.preferences?.setIncomingAccessEnabled(enabled) } } - nodeActions.refreshCloudMachines = { [weak viewModel] in viewModel?.refresh(tree: true) } - nodeActions.refreshDevices = { [weak devicesModel] in devicesModel?.refresh() } // The header "+" is Cmd-Y from this window: same gates, sheet, optimistic create, and no workspace until the sheet completes. nodeActions.newMachine = { [weak tabManager] in _ = AppDelegate.shared?.performNewCloudMachineAction( @@ -479,14 +477,11 @@ struct MachinesPanelView: View { discoveryEnabled: includesDevices, incomingAccessEnabled: devicesModel.preferences?.incomingAccessEnabled ?? false, discoveryManaged: discoveryManaged, - incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable(), - isRefreshing: devicesModel.isRefreshing + incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable(), isRefreshing: devicesModel.isRefreshing ), showsCloudVPNWarning: tunnelStatus.status?.state == .off, canCreateCloudMachine: includesCloud, - cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, - // Only a refresh asked for spins the header; the 45 s poll stays quiet. - cloudMachinesRefresh: includesCloud ? CloudTreeSectionRefresh(isRefreshing: viewModel.isRefreshingOnRequest) : nil, + cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, cloudMachinesRefresh: includesCloud ? .init(isRefreshing: viewModel.isRefreshingOnRequest) : nil, reveal: devicesModel.revealRequest, creationReveal: SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.reveals.reveal(for: tabManager) ) From ef643c7a7e7405ee6d56a85b03956597b80597fc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 2 Oct 2026 21:35:50 -0700 Subject: [PATCH 18/56] refactor cloud tree coordinator for line budget --- .../CloudTreeOutlineView+MachineLookup.swift | 11 +++++++ Sources/Cloud/CloudTreeOutlineView.swift | 33 ++----------------- .../CloudTreeOutlineView+MachineLift.swift | 20 +++++++++++ cmux.xcodeproj/project.pbxproj | 4 +++ 4 files changed, 38 insertions(+), 30 deletions(-) create mode 100644 Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift diff --git a/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift b/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift new file mode 100644 index 000000000000..1c637614edf3 --- /dev/null +++ b/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift @@ -0,0 +1,11 @@ +import CmuxCloud + +extension CloudTreeOutlineView.Coordinator { + /// Finds a Cloud machine anywhere in the displayed tree, including under its section row. + func machine(id: SurfaceMachineID) -> MachineSnapshot? { + for node in CloudTreeNodeBuilder.flattened(nodes) { + if case .machine(let machine, _) = node.kind, .cloud(machine.id) == id { return machine } + } + return nil + } +} diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index a2a5e9e047c9..714917919b72 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -110,12 +110,12 @@ struct CloudTreeOutlineView: NSViewRepresentable { var machineDetailLayout = CloudTreeMachineDetailLayout() private(set) var isUpdatingProgrammatically = false private var activeDrag: ActiveDrag? - private var machineLiftMouseUpMonitor: Any? + var machineLiftMouseUpMonitor: Any? // NSDraggingItem retains the writer for the live native session. A weak // coordinator edge prevents a retained writer/container cycle. private weak var activeDragWriter: CloudTreeSurfaceDragPasteboardWriter? - private var activeDragSequenceNumber: Int? - private var activeDragSession: NSDraggingSession? + var activeDragSequenceNumber: Int? + var activeDragSession: NSDraggingSession? private weak var activeDragSourceView: CloudTreeNSOutlineView? private var supersededDragSession: NSDraggingSession? private var supersededDragSequenceNumber: Int? @@ -163,25 +163,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } } - /// Removes the fallback monitor used when AppKit omits a drag-end callback. - private func removeMachineLiftMouseUpMonitor() { - if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } - machineLiftMouseUpMonitor = nil - } - - /// Finishes the native drag through the same coordinator path as `endedAt`. - func installMachineLiftMouseUpMonitor(for session: NSDraggingSession, in outline: CloudTreeNSOutlineView) { - removeMachineLiftMouseUpMonitor() - machineLiftMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in - MainActor.assumeIsolated { - guard let self, - self.activeDragSession === session, - self.activeDragSequenceNumber == session.draggingSequenceNumber else { return } - self.outlineView(outline, draggingSession: session, endedAt: event.locationInWindow, operation: []) - } - return event - } - } private func discardPendingDrag(_ pending: PendingDrag) { pending.registration.end() } @@ -628,14 +609,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { } } - /// Machines can sit under a section row (`.cloudMachinesSection`), so search the whole tree. - func machine(id: SurfaceMachineID) -> MachineSnapshot? { - for node in CloudTreeNodeBuilder.flattened(nodes) { - if case .machine(let machine, _) = node.kind, .cloud(machine.id) == id { return machine } - } - return nil - } - // MARK: Keyboard func moveSelection(by delta: Int) { diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift index 91929af5b509..3dc2cb0578db 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift @@ -2,6 +2,26 @@ import AppKit /// Coordinator side of the continuous machine drag (`CloudTreeMachineReorderLift`). extension CloudTreeOutlineView.Coordinator { + /// Removes the fallback monitor used when AppKit omits a drag-end callback. + func removeMachineLiftMouseUpMonitor() { + if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } + machineLiftMouseUpMonitor = nil + } + + /// Finishes the native drag through the same coordinator path as `endedAt`. + func installMachineLiftMouseUpMonitor(for session: NSDraggingSession, in outline: CloudTreeNSOutlineView) { + removeMachineLiftMouseUpMonitor() + machineLiftMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in + MainActor.assumeIsolated { + guard let self, + self.activeDragSession === session, + self.activeDragSequenceNumber == session.draggingSequenceNumber else { return } + self.outlineView(outline, draggingSession: session, endedAt: event.locationInWindow, operation: []) + } + return event + } + } + /// Starts the lift when the drag that just began carries a machine row. func liftMachineDrag(_ session: NSDraggingSession, draggedItems: [Any], in outlineView: NSOutlineView) { guard machineLiftEnabled, let node = draggedItems.first as? CloudTreeNode, node.canReorderMachine, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8cb2586c79de..e185f675d07f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1005,6 +1005,7 @@ 99A348B1D074417BB9DC59D1 /* CloudTreeOutlineView+Displays.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12747253A744B63A30EFA27 /* CloudTreeOutlineView+Displays.swift */; }; C7D2AAC178FE439AFB93CDA1 /* CloudTreeOutlineView+MachineDetailTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F701F7BA409DB9FFC4271474 /* CloudTreeOutlineView+MachineDetailTabs.swift */; }; 1DFA8FF6BFF48B174B6FCBEC /* CloudTreeOutlineView+MachineLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */; }; + F1534C522AA2977842FF139F /* CloudTreeOutlineView+MachineLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343AD0359AD2A02EE6861D0D /* CloudTreeOutlineView+MachineLookup.swift */; }; E639673A813D2A174CE3BAE1 /* CloudTreeOutlineView+MachineMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 412FF88FEF5819465A8157DE /* CloudTreeOutlineView+MachineMenu.swift */; }; A1308600000000000000000C /* CloudTreeOutlineView+MachineOrdering.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1308600000000000000000B /* CloudTreeOutlineView+MachineOrdering.swift */; }; FB6DB41BCE7B4EC987BF2F0B /* CloudTreeOutlineView+Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6150D65B1F43F683253998 /* CloudTreeOutlineView+Organization.swift */; }; @@ -5513,6 +5514,7 @@ E12747253A744B63A30EFA27 /* CloudTreeOutlineView+Displays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+Displays.swift"; sourceTree = ""; }; F701F7BA409DB9FFC4271474 /* CloudTreeOutlineView+MachineDetailTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineDetailTabs.swift"; sourceTree = ""; }; 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+MachineLift.swift"; sourceTree = ""; }; + 343AD0359AD2A02EE6861D0D /* CloudTreeOutlineView+MachineLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineLookup.swift"; sourceTree = ""; }; 412FF88FEF5819465A8157DE /* CloudTreeOutlineView+MachineMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineMenu.swift"; sourceTree = ""; }; A1308600000000000000000B /* CloudTreeOutlineView+MachineOrdering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+MachineOrdering.swift"; sourceTree = ""; }; 5A6150D65B1F43F683253998 /* CloudTreeOutlineView+Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+Organization.swift"; sourceTree = ""; }; @@ -9611,6 +9613,7 @@ 7AC64FE02675D0294E2CF115 /* MachinesPanelView+Activation.swift */, 2E642299D32B3539B81F7E90 /* MachinesPanelView+TeamScope.swift */, 4F3F6B820FF35BCF02EF6B2E /* CloudTreeSectionRefreshHeader.swift */, + 343AD0359AD2A02EE6861D0D /* CloudTreeOutlineView+MachineLookup.swift */, ); name = Cloud; path = Cloud; @@ -14639,6 +14642,7 @@ 99A348B1D074417BB9DC59D1 /* CloudTreeOutlineView+Displays.swift in Sources */, C7D2AAC178FE439AFB93CDA1 /* CloudTreeOutlineView+MachineDetailTabs.swift in Sources */, 1DFA8FF6BFF48B174B6FCBEC /* CloudTreeOutlineView+MachineLift.swift in Sources */, + F1534C522AA2977842FF139F /* CloudTreeOutlineView+MachineLookup.swift in Sources */, E639673A813D2A174CE3BAE1 /* CloudTreeOutlineView+MachineMenu.swift in Sources */, A1308600000000000000000C /* CloudTreeOutlineView+MachineOrdering.swift in Sources */, FB6DB41BCE7B4EC987BF2F0B /* CloudTreeOutlineView+Organization.swift in Sources */, From 1dd8bd32afb37b92764410185b418a0db9246912 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:03:29 -0700 Subject: [PATCH 19/56] Fix namespace type convention lint --- .../Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift | 3 ++- .../Sources/CmuxControlSocket/Wire/SocketCommandLine.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift index 1dcd2f2fe8f4..cded4fc0a34d 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift @@ -17,7 +17,8 @@ public import Foundation /// The templates are kept free of Swift interpolation so the /// `tests/react_grab_bridge.test.mjs` harness can extract and execute the /// exact shipped sources between the begin/end markers. -public enum ReactGrabBridgeScripts { +public struct ReactGrabBridgeScripts { + private init() {} public static let handlerNamePlaceholder = "__CMUX_RG_HANDLER__" public static let maxContentLengthPlaceholder = "__CMUX_RG_MAX_CONTENT_LENGTH__" public static let tokenLiteralPlaceholder = "__CMUX_RG_TOKEN_LITERAL__" diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift index 5dee86ebf8ff..688bdc67a71a 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift @@ -9,7 +9,8 @@ /// ``isSingleTokenArgument(_:)`` or ``agentHookPIDKeyArgument(statusKey:sessionId:)`` /// before composing a command, and every writer frames the finished line /// through ``framedLine(for:)``, which refuses CR, LF, and NUL outright. -public enum SocketCommandLine { +public struct SocketCommandLine { + private init() {} /// Maximum UTF-8 length accepted for a machine identifier embedded in a /// V1 command line as one unquoted token. public static let maximumSingleTokenArgumentLength = 256 From 253d0577170c21d548a9f2431ca98cfa21ecc2ad Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:06:14 -0700 Subject: [PATCH 20/56] Document stateless namespace lint exceptions --- .../Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift | 1 + .../Sources/CmuxControlSocket/Wire/SocketCommandLine.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift index cded4fc0a34d..8c05e3b8a4ca 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Scripting/ReactGrabBridgeScripts.swift @@ -17,6 +17,7 @@ public import Foundation /// The templates are kept free of Swift interpolation so the /// `tests/react_grab_bridge.test.mjs` harness can extract and execute the /// exact shipped sources between the begin/end markers. +/// lint:allow namespace-type — stateless JavaScript source templates; the static API is intentionally dependency-free. public struct ReactGrabBridgeScripts { private init() {} public static let handlerNamePlaceholder = "__CMUX_RG_HANDLER__" diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift index 688bdc67a71a..441eb838d3cf 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/SocketCommandLine.swift @@ -9,6 +9,7 @@ /// ``isSingleTokenArgument(_:)`` or ``agentHookPIDKeyArgument(statusKey:sessionId:)`` /// before composing a command, and every writer frames the finished line /// through ``framedLine(for:)``, which refuses CR, LF, and NUL outright. +/// lint:allow namespace-type — stateless wire framing helpers; the static API is intentionally dependency-free. public struct SocketCommandLine { private init() {} /// Maximum UTF-8 length accepted for a machine identifier embedded in a From 5530189611112eec82791e6c83dd3bbf46956e83 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:12:04 -0700 Subject: [PATCH 21/56] Retry CI after transient macOS runner failures From d849affa45f370d23de0007c752f4d6356e69803 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 2 Oct 2026 22:44:23 -0700 Subject: [PATCH 22/56] fix cloud tree machine lookup imports --- Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift b/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift index 1c637614edf3..7d27e9a81843 100644 --- a/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift +++ b/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift @@ -1,4 +1,5 @@ import CmuxCloud +import CmuxSurfaceCatalogModel extension CloudTreeOutlineView.Coordinator { /// Finds a Cloud machine anywhere in the displayed tree, including under its section row. From 3db716041efd2064a135b432a194b64e8c76f7aa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 00:16:33 -0700 Subject: [PATCH 23/56] fix right sidebar drag test module import --- cmuxTests/RightSidebarTabCustomizationTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/RightSidebarTabCustomizationTests.swift b/cmuxTests/RightSidebarTabCustomizationTests.swift index b49e3d77acc8..d149a2124914 100644 --- a/cmuxTests/RightSidebarTabCustomizationTests.swift +++ b/cmuxTests/RightSidebarTabCustomizationTests.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxSidebar import XCTest #if canImport(cmux_DEV) From 824577f902312a8084561f36b1993e13727ad771 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 00:46:07 -0700 Subject: [PATCH 24/56] ci: isolate owned macOS canonical roots by runner --- .github/workflows/ci-macos.yml | 16 ++--- scripts/ci/resolve-canonical-root.py | 56 +++++++++++++++ tests/test_ci_resolve_canonical_root.py | 91 +++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 scripts/ci/resolve-canonical-root.py create mode 100644 tests/test_ci_resolve_canonical_root.py diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index c35f3e789784..5ffc3015fa6d 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -526,15 +526,12 @@ jobs: id: build-slot run: | set -euo pipefail - # /private/tmp/cmux-ci everywhere but the second compile slot of an - # owned Mac, where glaeda exports CMUX_CI_CANONICAL_ROOT as - # /private/tmp/cmux-ci- for the compile token it granted, so two - # compiles on one Mac never share a tree. Such a root is part of the - # canonical build fingerprint, so it never adopts a seed or - # compilation cache made at another root. Consumers read the root - # from the product's receipt (restore-app-host-test-product.sh). Its - # kept build state sits in its own directory beside the first slot's. - root="${CMUX_CI_CANONICAL_ROOT:-/private/tmp/cmux-ci}" + # Glaeda normally exports the root for the slot it granted. Resolve + # a deterministic runner-name fallback before any fixed path is + # cleared when that export is missing, so two slots never share a + # source tree or DerivedData. The root is part of the product + # fingerprint and its receipt, while owned state is scoped below. + root="$(python3 scripts/ci/resolve-canonical-root.py)" case "$root" in /private/tmp/cmux-ci) ;; /private/tmp/cmux-ci-[0-9]|/private/tmp/cmux-ci-[0-9][0-9]) @@ -548,6 +545,7 @@ jobs: ;; *) echo "::error::unexpected CMUX_CI_CANONICAL_ROOT $root"; exit 1 ;; esac + echo "CMUX_CI_CANONICAL_ROOT=$root" >> "$GITHUB_ENV" echo "root=$root" >> "$GITHUB_OUTPUT" - name: Prepare isolated admission DerivedData diff --git a/scripts/ci/resolve-canonical-root.py b/scripts/ci/resolve-canonical-root.py new file mode 100644 index 000000000000..0291b1d140a7 --- /dev/null +++ b/scripts/ci/resolve-canonical-root.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Resolve the per-runner canonical build root for a macOS CI job. + +The fleet controller normally exports ``CMUX_CI_CANONICAL_ROOT``. A reused +owned runner must still have an isolated root when that export is absent, so +the runner identity provides a deterministic fallback before any fixed path is +cleared or cloned. +""" + +from __future__ import annotations + +import os +import re +import sys +from collections.abc import Mapping + +BASE_ROOT = "/private/tmp/cmux-ci" +ROOT_PATTERN = re.compile(r"/private/tmp/cmux-ci(?:-[0-9]{1,2})?\Z") +OWNED_RUNNER_PATTERN = re.compile(r".*-glaeda(?:-([0-9]{1,2}))?\Z") + + +def resolve(environment: Mapping[str, str]) -> tuple[str, str]: + """Return the canonical root and the source that selected it.""" + explicit = environment.get("CMUX_CI_CANONICAL_ROOT", "").strip() + if explicit: + if not ROOT_PATTERN.fullmatch(explicit): + raise ValueError(f"unexpected CMUX_CI_CANONICAL_ROOT {explicit!r}") + return explicit, "controller" + + if not environment.get("CMUX_PRODUCT_RUNNER", "").startswith("glaeda-"): + return BASE_ROOT, "default" + + runner_name = environment.get("RUNNER_NAME", "").strip() + match = OWNED_RUNNER_PATTERN.fullmatch(runner_name) + if match is None: + raise ValueError( + "owned runner is missing a canonical root and has no recognized " + f"slot identity: {runner_name!r}" + ) + slot = match.group(1) + return (f"{BASE_ROOT}-{slot}" if slot else BASE_ROOT), "runner-name" + + +def main() -> int: + try: + root, source = resolve(os.environ) + except ValueError as error: + print(f"resolve-canonical-root: {error}", file=sys.stderr) + return 1 + print(f"canonical root: {root} ({source})", file=sys.stderr) + print(root) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ci_resolve_canonical_root.py b/tests/test_ci_resolve_canonical_root.py new file mode 100644 index 000000000000..a2489a529edd --- /dev/null +++ b/tests/test_ci_resolve_canonical_root.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Tests for deterministic canonical-root ownership on self-hosted runners.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts/ci/resolve-canonical-root.py" +SPEC = importlib.util.spec_from_file_location("resolve_canonical_root", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class CanonicalRootTests(unittest.TestCase): + def test_controller_root_wins(self) -> None: + root, source = MODULE.resolve( + { + "CMUX_CI_CANONICAL_ROOT": "/private/tmp/cmux-ci-12", + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", + "RUNNER_NAME": "aws-m4pro-8-glaeda-3", + } + ) + self.assertEqual(root, "/private/tmp/cmux-ci-12") + self.assertEqual(source, "controller") + + def test_owned_primary_runner_uses_base_root(self) -> None: + self.assertEqual( + MODULE.resolve( + { + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", + "RUNNER_NAME": "aws-m4pro-9-glaeda", + } + ), + ("/private/tmp/cmux-ci", "runner-name"), + ) + + def test_owned_slot_runner_gets_its_own_root(self) -> None: + self.assertEqual( + MODULE.resolve( + { + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", + "RUNNER_NAME": "aws-m4pro-8-glaeda-3", + } + ), + ("/private/tmp/cmux-ci-3", "runner-name"), + ) + self.assertEqual( + MODULE.resolve( + { + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", + "RUNNER_NAME": "aws-m4pro-8-glaeda-12", + } + )[0], + "/private/tmp/cmux-ci-12", + ) + + def test_non_owned_runner_keeps_ephemeral_default(self) -> None: + self.assertEqual( + MODULE.resolve({"CMUX_PRODUCT_RUNNER": "blacksmith-12vcpu-macos-26"}), + ("/private/tmp/cmux-ci", "default"), + ) + + def test_unrecognized_owned_runner_fails_closed(self) -> None: + with self.assertRaisesRegex(ValueError, "slot identity"): + MODULE.resolve( + { + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", + "RUNNER_NAME": "aws-m4pro-8-glaeda-side", + } + ) + + def test_invalid_controller_root_fails_closed(self) -> None: + with self.assertRaisesRegex(ValueError, "unexpected CMUX_CI_CANONICAL_ROOT"): + MODULE.resolve({"CMUX_CI_CANONICAL_ROOT": "/tmp/shared"}) + + def test_admission_exports_the_resolved_root_before_cleanup(self) -> None: + workflow = (ROOT / ".github/workflows/ci-macos.yml").read_text(encoding="utf-8") + resolver = workflow.index("python3 scripts/ci/resolve-canonical-root.py") + export = workflow.index('echo "CMUX_CI_CANONICAL_ROOT=$root" >> "$GITHUB_ENV"', resolver) + cleanup = workflow.index("Prepare isolated admission DerivedData", resolver) + self.assertLess(resolver, export) + self.assertLess(export, cleanup) + + +if __name__ == "__main__": + unittest.main() From 77b545d4cb356cc63d19a4241921c0ff1c32bc1b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 01:19:45 -0700 Subject: [PATCH 25/56] resolve current main cloud sidebar merge --- Sources/Cloud/CloudTreeNSOutlineView.swift | 1 - Sources/RightSidebarPanelView.swift | 10 +--------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Sources/Cloud/CloudTreeNSOutlineView.swift b/Sources/Cloud/CloudTreeNSOutlineView.swift index e6409dd7eccc..8c279b86238c 100644 --- a/Sources/Cloud/CloudTreeNSOutlineView.swift +++ b/Sources/Cloud/CloudTreeNSOutlineView.swift @@ -446,7 +446,6 @@ final class CloudTreeNSOutlineView: NSOutlineView { Self.leadingMargin + CGFloat(max(0, level(forRow: row))) * treeStyle.indentPerLevel ) } - override func frameOfOutlineCell(atRow row: Int) -> NSRect { // A machine's tab row opens and closes through its tabs, not a disclosure. if let node = item(atRow: row) as? CloudTreeNode, case .machineDetailTabs = node.kind { return .zero } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 272320266c18..9f574d6ce458 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -193,15 +193,12 @@ struct RightSidebarPanelView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("RightSidebar") .onAppear { - // The sidebar always fits the selected tab's name with the others as icons. modeBarWidthReport.onChange = { [fileExplorerState] in fileExplorerState.modeBarMinimumWidth = $0 } startShortcutHintMonitorsIfNeeded() if fileExplorerState.isVisible { hasMountedRightSidebarContent = true } fileExplorerState.refreshModeAvailability() } - .onDisappear { - stopShortcutHintMonitors() - } + .onDisappear { stopShortcutHintMonitors() } .onChange(of: showModifierHoldHints) { _, _ in startShortcutHintMonitorsIfNeeded() } @@ -227,8 +224,6 @@ struct RightSidebarPanelView: View { HStack(spacing: RightSidebarChromeMetrics.headerControlSpacing) { let displayedModes = availableModes - // The selected tab keeps its full label; the others share the - // rest and truncate, then drop to their icon. RightSidebarModeBarTabsLayout(spacing: RightSidebarChromeMetrics.headerControlSpacing, widthReport: modeBarWidthReport) { ForEach(modeBarItems) { item in let shortcut = item.shortcutAction.map { KeyboardShortcutSettings.shortcut(for: $0) } ?? .unbound @@ -262,9 +257,6 @@ struct RightSidebarPanelView: View { .layoutValue(key: RightSidebarModeBarTabSelectedKey.self, value: item.isSelected(mode: fileExplorerState.mode)) } } - // Switching tabs eases the widths: the old tab's fill fades as - // it narrows, the new one's fades in as it opens. No highlight - // travels across the bar. .animation(reduceMotion ? nil : ModeBarButton.switchAnimation, value: fileExplorerState.mode) .background(RightSidebarModeBarDragAnchorView(anchor: modeBarDrag.anchor)) .coordinateSpace(.named(RightSidebarModeBarDragController.coordinateSpace)) From 0b9d070c5ee77ca3c21e67d4c31e4ced90d23d6b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 01:54:35 -0700 Subject: [PATCH 26/56] fix review findings and hold canonical roots --- .github/workflows/ci-macos.yml | 12 ++- Sources/Cloud/CloudPortsStatusContent.swift | 2 + Sources/ContentView+RightSidebarWidth.swift | 37 +++++++++ Sources/ContentView.swift | 30 +------ Sources/RightSidebarModeBarDrag.swift | 11 +-- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/CloudSidebarPolishTests.swift | 11 ++- scripts/ci/resolve-canonical-root.py | 56 ------------- scripts/ci/take-canonical-root.sh | 31 +++++++ tests/test_ci_owned_build_state.py | 14 +++- tests/test_ci_resolve_canonical_root.py | 91 --------------------- tests/test_ci_take_canonical_root.py | 73 +++++++++++++++++ 12 files changed, 178 insertions(+), 194 deletions(-) create mode 100644 Sources/ContentView+RightSidebarWidth.swift delete mode 100644 scripts/ci/resolve-canonical-root.py create mode 100755 scripts/ci/take-canonical-root.sh delete mode 100644 tests/test_ci_resolve_canonical_root.py create mode 100644 tests/test_ci_take_canonical_root.py diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 5ffc3015fa6d..14e3d730e94c 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -526,15 +526,13 @@ jobs: id: build-slot run: | set -euo pipefail - # Glaeda normally exports the root for the slot it granted. Resolve - # a deterministic runner-name fallback before any fixed path is - # cleared when that export is missing, so two slots never share a - # source tree or DerivedData. The root is part of the product - # fingerprint and its receipt, while owned state is scoped below. - root="$(python3 scripts/ci/resolve-canonical-root.py)" + # Glaeda owns the root-to-token mapping. Hold the authoritative root + # before any fixed path is cleared; never infer ownership from the + # runner name when the controller export is missing. + root="$(scripts/ci/take-canonical-root.sh)" case "$root" in /private/tmp/cmux-ci) ;; - /private/tmp/cmux-ci-[0-9]|/private/tmp/cmux-ci-[0-9][0-9]) + /private/tmp/cmux-ci-[2-9]|/private/tmp/cmux-ci-[1-9][0-9]) case "$CMUX_PRODUCT_RUNNER" in glaeda-*) ;; *) echo "::error::$root is only for an owned Mac's second compile slot"; exit 1 ;; diff --git a/Sources/Cloud/CloudPortsStatusContent.swift b/Sources/Cloud/CloudPortsStatusContent.swift index e3000490dac6..97f15fb2d01b 100644 --- a/Sources/Cloud/CloudPortsStatusContent.swift +++ b/Sources/Cloud/CloudPortsStatusContent.swift @@ -150,6 +150,8 @@ final class CloudPortsStatusActionButton: NSButton { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit {} + func setTitle(_ title: String, fontSize: CGFloat) { chipTitle = title self.fontSize = fontSize diff --git a/Sources/ContentView+RightSidebarWidth.swift b/Sources/ContentView+RightSidebarWidth.swift new file mode 100644 index 000000000000..0e7a92b68f79 --- /dev/null +++ b/Sources/ContentView+RightSidebarWidth.swift @@ -0,0 +1,37 @@ +import AppKit + +extension ContentView { + /// Clamps the right sidebar to its available and configured width caps. + /// + /// The content minimum can exceed either cap in a narrow window, so it is + /// bounded by the effective maximum before it becomes the lower bound. + static func clampedRightSidebarWidth( + _ candidate: CGFloat, + availableWidth: CGFloat, + configuredMaximumWidth: CGFloat? = nil, + contentMinimumWidth: CGFloat = 0 + ) -> CGFloat { + let sanitizedCandidate = candidate.isFinite ? candidate : 220 + let sanitizedAvailableWidth = availableWidth.isFinite && availableWidth > 0 ? availableWidth : 1920 + let availableWidthCap = max( + minimumRightSidebarWidth, + sanitizedAvailableWidth - minimumTerminalWidthWithRightSidebar + ) + let configuredOrDefaultCap: CGFloat + if let configuredMaximumWidth, configuredMaximumWidth.isFinite { + configuredOrDefaultCap = configuredMaximumWidth + } else { + configuredOrDefaultCap = maximumRightSidebarWidth + } + let maximumWidth = max( + minimumRightSidebarWidth, + min(configuredOrDefaultCap, availableWidthCap) + ) + let contentMinimum = contentMinimumWidth.isFinite ? contentMinimumWidth : minimumRightSidebarWidth + let minimumWidth = min( + max(minimumRightSidebarWidth, contentMinimum), + maximumWidth + ) + return max(minimumWidth, min(maximumWidth, sanitizedCandidate)) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3e870f31e03a..32a42fbc7ddd 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1133,9 +1133,9 @@ struct ContentView: View { private static let commandPaletteVisiblePreviewResultLimit = 48 private static let commandPaletteVisiblePreviewCandidateLimit = 128 private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 - private static let minimumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.minimumWidth) - private static let maximumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.builtInMaximumWidth) - private static let minimumTerminalWidthWithRightSidebar: CGFloat = 360 + static let minimumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.minimumWidth) + static let maximumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.builtInMaximumWidth) + static let minimumTerminalWidthWithRightSidebar: CGFloat = 360 private var minimumSidebarWidth: CGFloat { CGFloat(SessionPersistencePolicy.sanitizedMinimumSidebarWidth(sidebarMinimumWidthSetting)) @@ -1227,30 +1227,6 @@ struct ContentView: View { return max(minimumWidth, min(sanitizedMaximumWidth, candidate)) } - /// `contentMinimumWidth`: what the content needs (the selected mode tab's name), never below the built-in minimum. - static func clampedRightSidebarWidth( - _ candidate: CGFloat, - availableWidth: CGFloat, - configuredMaximumWidth: CGFloat? = nil, - contentMinimumWidth: CGFloat = 0 - ) -> CGFloat { - let minimumWidth = max(Self.minimumRightSidebarWidth, contentMinimumWidth) - let sanitizedCandidate = candidate.isFinite ? candidate : 220 - let sanitizedAvailableWidth = availableWidth.isFinite && availableWidth > 0 ? availableWidth : 1920 - let availableWidthCap = max( - minimumWidth, - sanitizedAvailableWidth - Self.minimumTerminalWidthWithRightSidebar - ) - let configuredOrDefaultCap: CGFloat - if let configuredMaximumWidth, configuredMaximumWidth.isFinite { - configuredOrDefaultCap = max(minimumWidth, configuredMaximumWidth) - } else { - configuredOrDefaultCap = Self.maximumRightSidebarWidth - } - let maximumWidth = min(configuredOrDefaultCap, availableWidthCap) - return max(minimumWidth, min(maximumWidth, sanitizedCandidate)) - } - private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) { let nextWidth = Self.clampedSidebarWidth( sidebarWidth, diff --git a/Sources/RightSidebarModeBarDrag.swift b/Sources/RightSidebarModeBarDrag.swift index 8861ac6b4b7f..1d505c96ec30 100644 --- a/Sources/RightSidebarModeBarDrag.swift +++ b/Sources/RightSidebarModeBarDrag.swift @@ -125,18 +125,11 @@ final class RightSidebarModeBarDragController { removeMouseUpMonitor() endedStartLocation = current.startLocation guard !current.isCarriedOut else { return } - let authoritative = RightSidebarTabPreferences.orderedModes().filter { $0.isAvailable() && ($0 == current.mode || !RightSidebarTabPreferences.isHidden($0)) } - let source = authoritative.firstIndex(of: current.mode) - var order = authoritative - if let source, !order.isEmpty { - let item = order.remove(at: source) - let target = min(max(current.slot, 0), order.count) - order.insert(item, at: target) - } + let order = current.layout.reordered(current.modes, slot: current.slot) withAnimation(animation) { // The bar reads the saved order in the same update that clears // the offsets, so position and offset animate together. - if let source, source != min(max(current.slot, 0), max(0, authoritative.count - 1)) { + if order != current.modes { RightSidebarTabPreferences.setDisplayedOrder(order) } session = nil diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2ed98770892e..ca0e6c38791c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1746,6 +1746,7 @@ 137DEBC7453F4ABBBCB8D39A /* ContentView+PaneResizeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CD684C40FC4F36BD3A91EE /* ContentView+PaneResizeCommands.swift */; }; C0DEA7710000000000000003 /* ContentView+ProCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000004 /* ContentView+ProCommandPalette.swift */; }; C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */; }; + AEBC559E83E4B911AEBFAAA7 /* ContentView+RightSidebarWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */; }; C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */; }; C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */; }; 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */; }; @@ -6128,6 +6129,7 @@ B4CD684C40FC4F36BD3A91EE /* ContentView+PaneResizeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+PaneResizeCommands.swift"; sourceTree = ""; }; C0DEA7710000000000000004 /* ContentView+ProCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ProCommandPalette.swift"; sourceTree = ""; }; C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarCommandPalette.swift"; sourceTree = ""; }; + 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarWidth.swift"; sourceTree = ""; }; C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SavedLayoutCommands.swift"; sourceTree = ""; }; C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarSurfaceKind.swift"; sourceTree = ""; }; 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SurfaceNavigationCommands.swift"; sourceTree = ""; }; @@ -11825,6 +11827,7 @@ DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */, A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */, 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */, + 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */, ); path = Sources; sourceTree = ""; @@ -14905,6 +14908,7 @@ 137DEBC7453F4ABBBCB8D39A /* ContentView+PaneResizeCommands.swift in Sources */, C0DEA7710000000000000003 /* ContentView+ProCommandPalette.swift in Sources */, C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */, + AEBC559E83E4B911AEBFAAA7 /* ContentView+RightSidebarWidth.swift in Sources */, C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */, C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */, 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */, diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift index 6395b731d3d5..32f64db9564c 100644 --- a/cmuxTests/CloudSidebarPolishTests.swift +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -43,8 +43,17 @@ struct CloudSidebarPolishTests { func rightSidebarClampHonorsContentMinimum() { let builtIn = CGFloat(RightSidebarWidthSettings.minimumWidth) #expect(ContentView.clampedRightSidebarWidth(200, availableWidth: 1600, contentMinimumWidth: 381) == 381) - #expect(ContentView.clampedRightSidebarWidth(500, availableWidth: 1600, contentMinimumWidth: 381) == 500) + #expect(ContentView.clampedRightSidebarWidth( + 500, availableWidth: 1600, configuredMaximumWidth: 500, contentMinimumWidth: 381 + ) == 500) // A content minimum below the built-in one never lowers it. #expect(ContentView.clampedRightSidebarWidth(100, availableWidth: 1600, contentMinimumWidth: 120) == builtIn) } + + @Test("The right sidebar content minimum stays below its effective maximum") + func rightSidebarContentMinimumHonorsMaximum() { + #expect(ContentView.clampedRightSidebarWidth( + 400, availableWidth: 800, configuredMaximumWidth: 420, contentMinimumWidth: 900 + ) == 420) + } } diff --git a/scripts/ci/resolve-canonical-root.py b/scripts/ci/resolve-canonical-root.py deleted file mode 100644 index 0291b1d140a7..000000000000 --- a/scripts/ci/resolve-canonical-root.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Resolve the per-runner canonical build root for a macOS CI job. - -The fleet controller normally exports ``CMUX_CI_CANONICAL_ROOT``. A reused -owned runner must still have an isolated root when that export is absent, so -the runner identity provides a deterministic fallback before any fixed path is -cleared or cloned. -""" - -from __future__ import annotations - -import os -import re -import sys -from collections.abc import Mapping - -BASE_ROOT = "/private/tmp/cmux-ci" -ROOT_PATTERN = re.compile(r"/private/tmp/cmux-ci(?:-[0-9]{1,2})?\Z") -OWNED_RUNNER_PATTERN = re.compile(r".*-glaeda(?:-([0-9]{1,2}))?\Z") - - -def resolve(environment: Mapping[str, str]) -> tuple[str, str]: - """Return the canonical root and the source that selected it.""" - explicit = environment.get("CMUX_CI_CANONICAL_ROOT", "").strip() - if explicit: - if not ROOT_PATTERN.fullmatch(explicit): - raise ValueError(f"unexpected CMUX_CI_CANONICAL_ROOT {explicit!r}") - return explicit, "controller" - - if not environment.get("CMUX_PRODUCT_RUNNER", "").startswith("glaeda-"): - return BASE_ROOT, "default" - - runner_name = environment.get("RUNNER_NAME", "").strip() - match = OWNED_RUNNER_PATTERN.fullmatch(runner_name) - if match is None: - raise ValueError( - "owned runner is missing a canonical root and has no recognized " - f"slot identity: {runner_name!r}" - ) - slot = match.group(1) - return (f"{BASE_ROOT}-{slot}" if slot else BASE_ROOT), "runner-name" - - -def main() -> int: - try: - root, source = resolve(os.environ) - except ValueError as error: - print(f"resolve-canonical-root: {error}", file=sys.stderr) - return 1 - print(f"canonical root: {root} ({source})", file=sys.stderr) - print(root) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/take-canonical-root.sh b/scripts/ci/take-canonical-root.sh new file mode 100755 index 000000000000..2a6704d4129e --- /dev/null +++ b/scripts/ci/take-canonical-root.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# take-canonical-root.sh +# +# Hold the canonical root before a persistent runner clears or replaces it. +# Glaeda owns the root-to-token mapping; this helper never infers a root from +# RUNNER_NAME. If the runner hook already holds a different root, its export is +# missing and this command fails closed rather than touching an unknown path. +set -euo pipefail + +root="${CMUX_CI_CANONICAL_ROOT:-/private/tmp/cmux-ci}" +helper="${CMUX_CI_CANONICAL_ROOT_HELPER:-/Users/Shared/cmux-build-fleet/bin/glaeda-canonical-root}" + +case "$root" in + /private/tmp/cmux-ci|/private/tmp/cmux-ci-[2-9]|/private/tmp/cmux-ci-[1-9][0-9]) ;; + *) echo "take-canonical-root: unexpected requested root $root" >&2; exit 1 ;; +esac + +if [ -x "$helper" ]; then + status=0 + "$helper" take "$root" --wait "${CMUX_CI_CANONICAL_ROOT_WAIT_SECONDS:-1800}" >/dev/null || status=$? + case "$status" in + 0) ;; + 2) echo "take-canonical-root: controller holds a different root; canonical-root export is required" >&2; exit 1 ;; + *) echo "take-canonical-root: could not hold a canonical root (helper exit $status)" >&2; exit 1 ;; + esac +elif [[ "${CMUX_PRODUCT_RUNNER:-}" == glaeda-* ]]; then + echo "take-canonical-root: owned runner has no Glaeda root helper" >&2 + exit 1 +fi + +printf '%s\n' "$root" diff --git a/tests/test_ci_owned_build_state.py b/tests/test_ci_owned_build_state.py index 1e8add313ee2..597f69767a7c 100644 --- a/tests/test_ci_owned_build_state.py +++ b/tests/test_ci_owned_build_state.py @@ -1192,7 +1192,11 @@ def slot(self, root, runner="glaeda-std-xcode-26.6"): import subprocess with tempfile.TemporaryDirectory() as tmp: env_file, out_file = Path(tmp, "env"), Path(tmp, "out") + helper = Path(tmp, "helper-glaeda-canonical-root") + helper.write_text("#!/bin/sh\nexit 0\n") + helper.chmod(0o755) env = {"PATH": os.environ["PATH"], "GITHUB_ENV": str(env_file), "GITHUB_OUTPUT": str(out_file), + "RUNNER_TEMP": tmp, "CMUX_CI_CANONICAL_ROOT_HELPER": str(helper), "CMUX_PRODUCT_RUNNER": runner, "CMUX_OWNED_STATE_ROOT": "/Users/Shared/cmux-build-fleet/ci"} if root is not None: env["CMUX_CI_CANONICAL_ROOT"] = root @@ -1204,11 +1208,15 @@ def slot(self, root, runner="glaeda-std-xcode-26.6"): def test_a_second_compile_slot_keeps_its_own_root_and_state(self): # The first slot, and every Blacksmith job, keeps the default root and store. for runner in ("glaeda-std-xcode-26.6", "blacksmith-6vcpu-macos-26"): - self.assertEqual(self.slot(None, runner), (0, "", "root=/private/tmp/cmux-ci\n")) + self.assertEqual(self.slot(None, runner), (0, "CMUX_CI_CANONICAL_ROOT=/private/tmp/cmux-ci\n", "root=/private/tmp/cmux-ci\n")) code, env, out = self.slot("/private/tmp/cmux-ci-2") self.assertEqual(code, 0) - self.assertEqual(env, "CMUX_OWNED_PACKAGE_STORE=/Users/Shared/cmux-build-fleet/ci\n" - "CMUX_OWNED_STATE_ROOT=/Users/Shared/cmux-build-fleet/ci/cmux-ci-2\n") + self.assertEqual( + env, + "CMUX_OWNED_PACKAGE_STORE=/Users/Shared/cmux-build-fleet/ci\n" + "CMUX_OWNED_STATE_ROOT=/Users/Shared/cmux-build-fleet/ci/cmux-ci-2\n" + "CMUX_CI_CANONICAL_ROOT=/private/tmp/cmux-ci-2\n", + ) self.assertEqual(out, "root=/private/tmp/cmux-ci-2\n") # Only an owned Mac may move the root, and only to a slot root. self.assertNotEqual(self.slot("/private/tmp/cmux-ci-2", "blacksmith-6vcpu-macos-26")[0], 0) diff --git a/tests/test_ci_resolve_canonical_root.py b/tests/test_ci_resolve_canonical_root.py deleted file mode 100644 index a2489a529edd..000000000000 --- a/tests/test_ci_resolve_canonical_root.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for deterministic canonical-root ownership on self-hosted runners.""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path -import unittest - - -ROOT = Path(__file__).resolve().parents[1] -MODULE_PATH = ROOT / "scripts/ci/resolve-canonical-root.py" -SPEC = importlib.util.spec_from_file_location("resolve_canonical_root", MODULE_PATH) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class CanonicalRootTests(unittest.TestCase): - def test_controller_root_wins(self) -> None: - root, source = MODULE.resolve( - { - "CMUX_CI_CANONICAL_ROOT": "/private/tmp/cmux-ci-12", - "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", - "RUNNER_NAME": "aws-m4pro-8-glaeda-3", - } - ) - self.assertEqual(root, "/private/tmp/cmux-ci-12") - self.assertEqual(source, "controller") - - def test_owned_primary_runner_uses_base_root(self) -> None: - self.assertEqual( - MODULE.resolve( - { - "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", - "RUNNER_NAME": "aws-m4pro-9-glaeda", - } - ), - ("/private/tmp/cmux-ci", "runner-name"), - ) - - def test_owned_slot_runner_gets_its_own_root(self) -> None: - self.assertEqual( - MODULE.resolve( - { - "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", - "RUNNER_NAME": "aws-m4pro-8-glaeda-3", - } - ), - ("/private/tmp/cmux-ci-3", "runner-name"), - ) - self.assertEqual( - MODULE.resolve( - { - "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", - "RUNNER_NAME": "aws-m4pro-8-glaeda-12", - } - )[0], - "/private/tmp/cmux-ci-12", - ) - - def test_non_owned_runner_keeps_ephemeral_default(self) -> None: - self.assertEqual( - MODULE.resolve({"CMUX_PRODUCT_RUNNER": "blacksmith-12vcpu-macos-26"}), - ("/private/tmp/cmux-ci", "default"), - ) - - def test_unrecognized_owned_runner_fails_closed(self) -> None: - with self.assertRaisesRegex(ValueError, "slot identity"): - MODULE.resolve( - { - "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.3", - "RUNNER_NAME": "aws-m4pro-8-glaeda-side", - } - ) - - def test_invalid_controller_root_fails_closed(self) -> None: - with self.assertRaisesRegex(ValueError, "unexpected CMUX_CI_CANONICAL_ROOT"): - MODULE.resolve({"CMUX_CI_CANONICAL_ROOT": "/tmp/shared"}) - - def test_admission_exports_the_resolved_root_before_cleanup(self) -> None: - workflow = (ROOT / ".github/workflows/ci-macos.yml").read_text(encoding="utf-8") - resolver = workflow.index("python3 scripts/ci/resolve-canonical-root.py") - export = workflow.index('echo "CMUX_CI_CANONICAL_ROOT=$root" >> "$GITHUB_ENV"', resolver) - cleanup = workflow.index("Prepare isolated admission DerivedData", resolver) - self.assertLess(resolver, export) - self.assertLess(export, cleanup) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ci_take_canonical_root.py b/tests/test_ci_take_canonical_root.py new file mode 100644 index 000000000000..4983e039fb5e --- /dev/null +++ b/tests/test_ci_take_canonical_root.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Tests for the authoritative Glaeda canonical-root boundary.""" + +from __future__ import annotations + +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/ci/take-canonical-root.sh" + + +class CanonicalRootTests(unittest.TestCase): + def run_helper(self, exit_code: int, placed: str = "", *, requested: str = "/private/tmp/cmux-ci") -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as tmp: + temp = Path(tmp) + helper = temp / "helper-glaeda-canonical-root" + helper.write_text( + f"#!/bin/sh\nprintf '%s' '{placed}' > \"$RUNNER_TEMP/glaeda-canonical-root\"\nexit {exit_code}\n" + ) + helper.chmod(0o755) + environment = { + "PATH": os.environ["PATH"], + "RUNNER_TEMP": str(temp), + "CMUX_CI_CANONICAL_ROOT": requested, + "CMUX_CI_CANONICAL_ROOT_HELPER": str(helper), + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.6", + } + return subprocess.run([str(SCRIPT)], env=environment, capture_output=True, text=True) + + def test_helper_holds_requested_root(self) -> None: + result = self.run_helper(0) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout.strip(), "/private/tmp/cmux-ci") + + def test_different_held_root_fails_closed(self) -> None: + result = self.run_helper(2, "/private/tmp/cmux-ci-2") + self.assertNotEqual(result.returncode, 0) + self.assertIn("different root", result.stderr) + + def test_invalid_roots_are_rejected(self) -> None: + result = self.run_helper(0, requested="/private/tmp/cmux-ci-1") + self.assertNotEqual(result.returncode, 0) + + def test_owned_runner_without_helper_fails_closed(self) -> None: + environment = { + "PATH": os.environ["PATH"], + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.6", + "CMUX_CI_CANONICAL_ROOT_HELPER": "/tmp/does-not-exist", + } + result = subprocess.run([str(SCRIPT)], env=environment, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("no Glaeda root helper", result.stderr) + + def test_admission_uses_the_held_root_before_cleanup(self) -> None: + workflow = (ROOT / ".github/workflows/ci-macos.yml").read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Choose this job's canonical build root\n id: build-slot" + ) + step_end = workflow.find("\n - name: ", step_start + 1) + self.assertNotEqual(step_end, -1) + step = workflow[step_start:step_end] + take = step.index("scripts/ci/take-canonical-root.sh") + self.assertLess(take, step.index('echo "root=$root" >> "$GITHUB_OUTPUT"')) + self.assertLess(step_end, workflow.index("Prepare isolated admission DerivedData", step_end)) + + +if __name__ == "__main__": + unittest.main() From ac190255ca1e16aafced5ed7e23593d91fc33fb3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 02:24:45 -0700 Subject: [PATCH 27/56] ci: keep owned pool picks on the pinned Xcode --- .github/workflows/ci-guards.yml | 4 ++++ scripts/ci/simple_pool_picker.py | 17 ++++++++++++++--- tests/test-execution.toml | 4 ++++ tests/test_ci_simple_pool_picker.py | 7 +++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml index 0f460f770c71..3089a120d58a 100644 --- a/.github/workflows/ci-guards.yml +++ b/.github/workflows/ci-guards.yml @@ -621,6 +621,10 @@ jobs: if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} run: python3 tests/test_ci_owned_build_state.py + - name: Validate canonical root ownership boundary + if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} + run: python3 tests/test_ci_take_canonical_root.py + - name: Validate warm-state distance recording and routing if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} run: python3 tests/test_ci_warm_distance.py diff --git a/scripts/ci/simple_pool_picker.py b/scripts/ci/simple_pool_picker.py index 30db92b57248..7ffe6539d804 100644 --- a/scripts/ci/simple_pool_picker.py +++ b/scripts/ci/simple_pool_picker.py @@ -39,6 +39,7 @@ RUN_CLASSES = ("std", "light", "xl") # glaeda-[root-|side-|gui-]-xcode-: one family of labels on the same machines. OWNED_FAMILY = re.compile(r"^glaeda-(?:root-|side-|gui-)?(?P(?:std|light|xl)-xcode-[0-9]+(?:\.[0-9]+)*)$") +XCODE_VERSION = re.compile(r"(?:^|/)Xcode_(?P[0-9]+(?:\.[0-9]+)*)\.app(?:/|$)") def owned_family(label: str) -> str: @@ -53,6 +54,13 @@ def owned_order(label: str) -> tuple[int, str]: return (RUN_CLASSES.index(owned_class) if owned_class in RUN_CLASSES else len(RUN_CLASSES), label) +def owned_matches_xcode(label: str, xcode_app: str) -> bool: + """Whether an owned label belongs to the configured PR Xcode lane.""" + family = owned_family(label) + version = XCODE_VERSION.search(xcode_app or "") + return bool(family and version and family.endswith(f"-xcode-{version.group('version')}")) + + @dataclasses.dataclass(frozen=True) class Pool: label: str @@ -236,10 +244,13 @@ def count(label: str, key: str) -> int: owned: list[Pool] = [] if runners is not None: - labels = set(_slots(env.get("CI_OWNED_POOL_SLOTS"))) + xcode_app = env.get("CMUX_CI_XCODE_APP_PR", "") + labels = {label for label in _slots(env.get("CI_OWNED_POOL_SLOTS")) + if owned_matches_xcode(label, xcode_app)} for runner in runners: labels.update(str(item.get("name", "")) for item in runner.get("labels", ()) - if OWNED.fullmatch(str(item.get("name", "")))) + if OWNED.fullmatch(str(item.get("name", ""))) + and owned_matches_xcode(str(item.get("name", "")), xcode_app)) for label in sorted(labels, key=owned_order): online = [runner for runner in runners if runner.get("status") == "online" and any(item.get("name") == label for item in runner.get("labels", ()))] @@ -249,7 +260,7 @@ def count(label: str, key: str) -> int: queued = sum(count(name, "queued") for name in pools if owned_family(str(name)) == family) free = max(0, idle - queued) owned.append(Pool(label, len(online), len(online) - idle, queued, free=free, - xcode_app=env.get("CMUX_CI_XCODE_APP_PR", ""))) + xcode_app=xcode_app)) counts = {label: {"running": count(label, "running"), "queued": count(label, "queued"), "reserved": count(label, "reserved_queued")} for label in BLACKSMITH} blacksmith = tuple(Pool(label, CAPACITY[label], **counts[label], diff --git a/tests/test-execution.toml b/tests/test-execution.toml index 06843587147b..413de535e0bf 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -1317,6 +1317,10 @@ lane = "legacy" path = "tests/test_ci_canonical_build_root.py" lane = "linux-guard" +[[test]] +path = "tests/test_ci_take_canonical_root.py" +lane = "linux-guard" + [[test]] path = "tests/test_cli_workflow_catalog.py" lane = "macos-cli-no-socket" diff --git a/tests/test_ci_simple_pool_picker.py b/tests/test_ci_simple_pool_picker.py index 365b0d6e4a84..7ff6ecb9dc4a 100644 --- a/tests/test_ci_simple_pool_picker.py +++ b/tests/test_ci_simple_pool_picker.py @@ -129,6 +129,13 @@ def active_jobs(self): class OwnedQueueTests(unittest.TestCase): """An owned pool's free runners are its idle runners less the jobs queued on its family.""" + def test_stale_xcode_pool_labels_are_not_eligible(self): + stale = runner("stale", ["glaeda-std-xcode-26.3"], busy=False) + current = std_runner(0, busy=False) + state = observed([stale, current], {}, jobs=1) + self.assertNotIn("glaeda-std-xcode-26.3", {pool.label for pool in state.owned}) + self.assertEqual(picker.pick(state).label, STD) + def test_many_queued_on_light_and_none_free_never_picks_light(self): # 2026-10-01 04:15Z: 25 jobs queued on the light family (14 root, 6 # plain, 5 side), its 3 online runners busy and 1 offline, and every std From b9af46960ccc6744e9f5975d75e985d2378762f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 02:32:33 -0700 Subject: [PATCH 28/56] fix: remove deleted cloud refresh source from project --- cmux.xcodeproj/project.pbxproj | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ca0e6c38791c..4aed5115f196 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -866,7 +866,6 @@ 9F046FC7A14BBDB098F471B3 /* CloudReadResponseGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */; }; C13151C00000000000000023 /* CloudRefreshAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000022 /* CloudRefreshAuthClient.swift */; }; C13151C00000000000000021 /* CloudRefreshFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000020 /* CloudRefreshFixture.swift */; }; - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */; }; C12625000000000000000004 /* CloudRefreshURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625000000000000000003 /* CloudRefreshURLProtocol.swift */; }; C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000051 /* CloudRenameOptimismTests.swift */; }; EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */; }; @@ -5378,7 +5377,6 @@ E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudReadResponseGate.swift"; sourceTree = ""; }; C13151C00000000000000022 /* CloudRefreshAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshAuthClient.swift; sourceTree = ""; }; C13151C00000000000000020 /* CloudRefreshFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshFixture.swift; sourceTree = ""; }; - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshMachinesButton.swift; sourceTree = ""; }; C12625000000000000000003 /* CloudRefreshURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshURLProtocol.swift; sourceTree = ""; }; C12575000000000000000051 /* CloudRenameOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRenameOptimismTests.swift; sourceTree = ""; }; 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayFixture.swift"; sourceTree = ""; }; @@ -9614,7 +9612,6 @@ 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */, 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */, 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */, - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */, 5A6DBD58C3DCE74DB8832536 /* CloudActivationCoordinator.swift */, 5D03F7C677AFAB99364F7404 /* CloudMachinesEnablementView.swift */, 7AC64FE02675D0294E2CF115 /* MachinesPanelView+Activation.swift */, @@ -14577,7 +14574,6 @@ 7A0CE100000000000000071C /* CloudPortRoutePlan.swift in Sources */, EA1141167D5A710EAD47E9F5 /* CloudPortsDiscoveryDemand.swift in Sources */, C13234000000000000000001 /* CloudPortsStatusContent.swift in Sources */, - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */, B5F7FD4791E006CF41AFDE98 /* CloudSecurityExplainer.swift in Sources */, B12357000000000000000004 /* CloudSidebarDebugLabWindow.swift in Sources */, C70E8DD0BC4B4A2C9C58E093 /* CloudSidebarOrganizationDrop.swift in Sources */, From 8e4d1f0b799abedba6320852e0f00c8e795d4fba Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 02:49:41 -0700 Subject: [PATCH 29/56] fix: label machine create failure test outputs --- cmuxTests/CloudSidebarPolishTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift index 32f64db9564c..9944fa7588a4 100644 --- a/cmuxTests/CloudSidebarPolishTests.swift +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -18,9 +18,9 @@ struct CloudSidebarPolishTests { id: UUID(), request: MachineCreateCoordinatorTests.newMachineRequest(), startedAt: Date() ) #expect(!operation.hitMachineLimit) - operation.phase = .failed("Error: Cloud VM limit reached (HTTP 402: vm_active_limit_exceeded)") + operation.phase = .failed(output: "Error: Cloud VM limit reached (HTTP 402: vm_active_limit_exceeded)") #expect(operation.hitMachineLimit) - operation.phase = .failed("Error: The Cloud VM service is temporarily unavailable.") + operation.phase = .failed(output: "Error: The Cloud VM service is temporarily unavailable.") #expect(!operation.hitMachineLimit) for plan in ["free", "go", "pro", "unknown"] { #expect(MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") From c506417ede72e628ce751959c665914d1abd9882 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 03:07:27 -0700 Subject: [PATCH 30/56] fix: split throwing ordering assertions --- cmuxTests/CloudMachineOrderingTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index 0ebc6541d983..8c88e7ea727f 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -123,7 +123,10 @@ struct CloudMachineOrderingTests { let drag = try fixture.begin("c") let press = outline.rect(ofRow: outline.row(forItem: source)).midY coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) - #expect(!outline.isItemExpanded(try fixture.root("a")) && !outline.isItemExpanded(try fixture.root("b"))) + let a = try fixture.root("a") + let b = try fixture.root("b") + #expect(!outline.isItemExpanded(a)) + #expect(!outline.isItemExpanded(b)) // A small nudge from the press point: c's row moved up as a and b // closed, but only the pointer's travel counts. drag.info.draggingLocation = outline.convert(NSPoint(x: 10, y: press + 3), to: nil) @@ -132,7 +135,8 @@ struct CloudMachineOrderingTests { #expect(outline.machineLift.slot == 2, "c keeps its place among a, b and d") try fixture.end(drag) #expect(fixture.order == ["a", "b", "c", "d"]) - #expect(outline.isItemExpanded(try fixture.root("a")) && outline.isItemExpanded(try fixture.root("b"))) + #expect(outline.isItemExpanded(a)) + #expect(outline.isItemExpanded(b)) } @Test("A lifted drag released on its own slot moves nothing and reopens machines") From 5298e3d1ea57cbc865525892b73dd2ac317d3648 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 03:11:36 -0700 Subject: [PATCH 31/56] fix: keep scoped cloud usage visible during refresh --- ...chinesPanelViewModel+MachineDeletion.swift | 19 ++++++++++++++++++- cmuxTests/CloudMachinesHeaderCountTests.swift | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift b/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift index c4c22dc70b18..9660c420a09c 100644 --- a/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift +++ b/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift @@ -17,7 +17,24 @@ extension MachinesPanelViewModel { /// The plan's usage without machines being deleted, so the Cloud Machines /// header count leaves with the row instead of at the next list read. var visibleUsage: CloudMachinesUsage? { - Self.usage(plan?.usage, machines: machines, hiding: MachineDeleteCoordinator.shared.hiddenMachineIDs) + Self.usage( + plan?.usage ?? NewMachineSheetDataCache.shared?.currentData?.plan?.usage, + machines: machines, + hiding: MachineDeleteCoordinator.shared.hiddenMachineIDs + ) + } + + /// Uses scope-checked sheet-cache usage while the panel's next list read is pending. + /// - Parameters: + /// - usage: Usage from the panel's current list read. + /// - fallback: Usage from the current scope's warmed sheet cache. + /// - machines: The fleet list that supplied the usage count. + /// - machineIDs: Provider machine identifiers to leave out. + /// - Returns: Usage from the newest available current-scope source, adjusted for hidden rows. + static func usage( + _ usage: CloudMachinesUsage?, fallback: CloudMachinesUsage?, machines: [MachineSnapshot], hiding machineIDs: Set + ) -> CloudMachinesUsage? { + Self.usage(usage ?? fallback, machines: machines, hiding: machineIDs) } /// Takes hidden machines out of a usage counted from `machines`. diff --git a/cmuxTests/CloudMachinesHeaderCountTests.swift b/cmuxTests/CloudMachinesHeaderCountTests.swift index 95d36433a0a5..020c7103047e 100644 --- a/cmuxTests/CloudMachinesHeaderCountTests.swift +++ b/cmuxTests/CloudMachinesHeaderCountTests.swift @@ -99,6 +99,21 @@ struct CloudMachinesHeaderCountTests { #expect(MachinesPanelViewModel.usage(nil, machines: fleet, hiding: ["d"]) == nil) } + @Test("A scope-checked sheet-cache usage keeps the header visible during a list refresh") + func cachedUsageFillsTheListReadGap() throws { + let machines = [MachineSnapshot(id: "a", provider: "freestyle", image: "base", isDesktop: false, activity: .ready)] + let cached = CloudMachinesUsage(activeCount: 1, maxActiveVms: 5, isPaidPlan: false) + let visible = try #require(MachinesPanelViewModel.usage( + nil, fallback: cached, machines: machines, hiding: [] + )) + #expect(visible.compactCount == "1/5") + + let current = CloudMachinesUsage(activeCount: 0, maxActiveVms: 5, isPaidPlan: false) + #expect(MachinesPanelViewModel.usage( + current, fallback: cached, machines: machines, hiding: [] + ) == current) + } + @Test("VoiceOver reads the header with its spelled-out usage") func headerCellSpeaksTheUsage() { let cell = headerCell(usage: CloudMachinesUsage(activeCount: 1, maxActiveVms: 50, isPaidPlan: false)) From 3b9e03687742d55bc72ea698fdd30c01ad8d35c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 03:29:18 -0700 Subject: [PATCH 32/56] fix: eliminate new Swift isolation warnings --- Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift | 2 +- Sources/RightSidebarModeBarDrag.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift index 3dc2cb0578db..29470dd7c2f8 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift @@ -87,7 +87,7 @@ extension CloudTreeOutlineView.Coordinator { outline.expandItem(machine) } } - restoreSelection(in: outline) + self.restoreSelection(in: outline) } }, mutate: commit) } diff --git a/Sources/RightSidebarModeBarDrag.swift b/Sources/RightSidebarModeBarDrag.swift index 1d505c96ec30..424830878961 100644 --- a/Sources/RightSidebarModeBarDrag.swift +++ b/Sources/RightSidebarModeBarDrag.swift @@ -11,7 +11,7 @@ import SwiftUI @MainActor @Observable final class RightSidebarModeBarDragController { - static let coordinateSpace = "RightSidebarModeBarTabs" + nonisolated static let coordinateSpace = "RightSidebarModeBarTabs" /// Gentle and nearly critically damped: the tabs part without bouncing. static let spring = Animation.spring(response: 0.28, dampingFraction: 0.86) From 0029d22cc7f96e0ac4f08c4d547a7dc7b7afe629 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 04:21:19 -0700 Subject: [PATCH 33/56] fix: stabilize cloud sidebar layout assertions --- .../Cloud/CloudTreeMachineDetailLayout.swift | 6 ++++- .../CloudTreePendingMachineRowContent.swift | 26 +++++-------------- cmuxTests/CloudSidebarPolishTests.swift | 2 +- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/Sources/Cloud/CloudTreeMachineDetailLayout.swift b/Sources/Cloud/CloudTreeMachineDetailLayout.swift index ed1081a36820..841689c3835a 100644 --- a/Sources/Cloud/CloudTreeMachineDetailLayout.swift +++ b/Sources/Cloud/CloudTreeMachineDetailLayout.swift @@ -84,7 +84,7 @@ struct CloudTreeMachineDetailLayout { // While the machine connects, Ports, Terminals and Displays have // nothing current to show, so only Resources (fleet telemetry, which // does not need the link) keeps its tab. - if rows.contains(where: Self.isConnectingPlaceholder) { + if node.children.contains(where: Self.containsConnectingPlaceholder) { for tab in [CloudTreeMachineDetailTab.ports, .terminals, .displays] { pools[tab] = nil } } let tabs = CloudTreeMachineDetailTab.allCases.filter { pools[$0] != nil } @@ -173,5 +173,9 @@ struct CloudTreeMachineDetailLayout { return false } + private static func containsConnectingPlaceholder(_ node: CloudTreeNode) -> Bool { + isConnectingPlaceholder(node) || node.children.contains(where: containsConnectingPlaceholder) + } + private static func baseID(_ machine: SurfaceMachineID) -> String { "machine:\(machine.rawValue)" } } diff --git a/Sources/Cloud/CloudTreePendingMachineRowContent.swift b/Sources/Cloud/CloudTreePendingMachineRowContent.swift index 4aa9222f669b..9edcab347d39 100644 --- a/Sources/Cloud/CloudTreePendingMachineRowContent.swift +++ b/Sources/Cloud/CloudTreePendingMachineRowContent.swift @@ -19,9 +19,7 @@ struct CloudTreePendingMachineRowContent: View { // Centered, not baseline-aligned: the glyph has no text // baseline, so on a baseline it drops below the name. HStack(alignment: .center, spacing: style.rowGrid.dotGap) { - // A failed create is only its notice; the name and the - // reason are in the tooltip and on the create's page. - if !isFailed { name } + name statusGlyph status .layoutPriority(1) @@ -33,21 +31,13 @@ struct CloudTreePendingMachineRowContent: View { case .twoLine: HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: scaled(style.rowGrid.machineLineSpacing)) { - if isFailed { - HStack(alignment: .center, spacing: style.rowGrid.dotGap) { - statusGlyph - status - } - .frame(height: scaled(style.machineNameLineHeight)) - } else { - HStack(alignment: .center, spacing: style.rowGrid.dotGap) { - name - statusGlyph - } - .frame(height: scaled(style.machineNameLineHeight)) - status - .frame(height: scaled(style.machineSubtitleLineHeight)) + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { + name + statusGlyph } + .frame(height: scaled(style.machineNameLineHeight)) + status + .frame(height: scaled(style.machineSubtitleLineHeight)) } Spacer(minLength: style.rowGrid.trailingGap) } @@ -76,8 +66,6 @@ struct CloudTreePendingMachineRowContent: View { } } - private var isFailed: Bool { operation.failureOutput != nil } - private func scaled(_ value: CGFloat) -> CGFloat { GlobalFontMagnification.scaledSize(value, percent: magnification) } diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift index 9944fa7588a4..b92e6ceab982 100644 --- a/cmuxTests/CloudSidebarPolishTests.swift +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -35,7 +35,7 @@ struct CloudSidebarPolishTests { // Full and icon-only widths for three tabs; Cloud's label is the widest. let natural: [CGFloat] = [59, 66, 58] let floors: [CGFloat] = [30, 30, 30] - #expect(RightSidebarModeBarTabWidths.oneLabelWidth(natural: natural, floors: floors) == 30 * 3 + 36) + #expect(abs(RightSidebarModeBarTabWidths.oneLabelWidth(natural: natural, floors: floors) - 126) < 0.01) #expect(RightSidebarModeBarTabWidths.oneLabelWidth(natural: [], floors: []) == 0) } From ccbe4cc0632c9fdc3bb94e1ff52276765bd29123 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 05:08:27 -0700 Subject: [PATCH 34/56] fix: complete cloud plan localization --- Resources/Localizable.xcstrings | 293 +++++++++++++++++++------------- 1 file changed, 176 insertions(+), 117 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 91b906e8297c..5334a924e510 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -618685,11 +618685,11 @@ } }, "ar": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل خطة جهاز Cloud الخاصة بك…" + } + }, "bs": { "stringUnit": { "state": "translated", @@ -618703,23 +618703,23 @@ } }, "de": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, + "stringUnit": { + "state": "translated", + "value": "Dein Cloud-Maschinenplan wird geladen…" + } + }, "es": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, + "stringUnit": { + "state": "translated", + "value": "Cargando tu plan de máquina Cloud…" + } + }, "fr": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, + "stringUnit": { + "state": "translated", + "value": "Chargement de votre forfait de machine Cloud…" + } + }, "it": { "stringUnit": { "state": "translated", @@ -618727,11 +618727,11 @@ } }, "ja": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, + "stringUnit": { + "state": "translated", + "value": "Cloud マシンプランを読み込んでいます…" + } + }, "km": { "stringUnit": { "state": "translated", @@ -618739,11 +618739,11 @@ } }, "ko": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, + "stringUnit": { + "state": "translated", + "value": "클라우드 머신 요금제를 불러오는 중…" + } + }, "nb": { "stringUnit": { "state": "translated", @@ -618787,17 +618787,17 @@ } }, "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, + "stringUnit": { + "state": "translated", + "value": "正在加载您的 Cloud 机器方案…" + } + }, "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" + "stringUnit": { + "state": "translated", + "value": "正在載入您的 Cloud 機器方案…" + } } - } } }, "machines.new.plan.retry": { @@ -618810,11 +618810,11 @@ } }, "ar": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة" + } + }, "bs": { "stringUnit": { "state": "translated", @@ -618828,23 +618828,23 @@ } }, "de": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, + "stringUnit": { + "state": "translated", + "value": "Wiederholen" + } + }, "es": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, + "stringUnit": { + "state": "translated", + "value": "Reintentar" + } + }, "fr": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, + "stringUnit": { + "state": "translated", + "value": "Réessayer" + } + }, "it": { "stringUnit": { "state": "translated", @@ -618852,11 +618852,11 @@ } }, "ja": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, + "stringUnit": { + "state": "translated", + "value": "再試行" + } + }, "km": { "stringUnit": { "state": "translated", @@ -618864,11 +618864,11 @@ } }, "ko": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, + "stringUnit": { + "state": "translated", + "value": "재시도" + } + }, "nb": { "stringUnit": { "state": "translated", @@ -618912,17 +618912,17 @@ } }, "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, + "stringUnit": { + "state": "translated", + "value": "重试" + } + }, "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Retry" + "stringUnit": { + "state": "translated", + "value": "重試" + } } - } } }, "machines.new.plan.error": { @@ -618935,11 +618935,11 @@ } }, "ar": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, + "stringUnit": { + "state": "translated", + "value": "لا تزال تفاصيل خطة Cloud قيد التحميل. أعد المحاولة بعد لحظات." + } + }, "bs": { "stringUnit": { "state": "translated", @@ -618953,23 +618953,23 @@ } }, "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, + "stringUnit": { + "state": "translated", + "value": "Die Details des Cloud-Plans werden noch geladen. Versuche es gleich erneut." + } + }, "es": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, + "stringUnit": { + "state": "translated", + "value": "Los detalles del plan de Cloud aún se están cargando. Vuelve a intentarlo en un momento." + } + }, "fr": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, + "stringUnit": { + "state": "translated", + "value": "Les détails du forfait Cloud sont toujours en cours de chargement. Réessayez dans un instant." + } + }, "it": { "stringUnit": { "state": "translated", @@ -618977,11 +618977,11 @@ } }, "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, + "stringUnit": { + "state": "translated", + "value": "Cloud プランの詳細を読み込んでいます。少ししてからもう一度お試しください。" + } + }, "km": { "stringUnit": { "state": "translated", @@ -618989,11 +618989,11 @@ } }, "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, + "stringUnit": { + "state": "translated", + "value": "Cloud 요금제 세부 정보를 아직 불러오는 중입니다. 잠시 후 다시 시도하세요." + } + }, "nb": { "stringUnit": { "state": "translated", @@ -619037,19 +619037,78 @@ } }, "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, + "stringUnit": { + "state": "translated", + "value": "Cloud 方案详情仍在加载。请稍后重试。" + } + }, "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." + "stringUnit": { + "state": "translated", + "value": "Cloud 方案詳細資料仍在載入中。請稍後再試。" + } + } + } + }, + "cloudTree.action.refreshCloudMachines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh Cloud Machines" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Maschinen aktualisieren" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser les machines Cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث الأجهزة السحابية" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar máquinas Cloud" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理雲端機器" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新云端机器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클라우드 머신 새로고침" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud マシンを再読み込み" + } } } } - } }, "version": "1.0" } From 38538a3daac2a806d88128d43683703c50005016 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 05:45:07 -0700 Subject: [PATCH 35/56] fix: isolate sidebar catalog defaults --- Sources/Cloud/CloudWorkspaceSidebarPresentation.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift index 0e9d5dad1360..8d6dc72ed6f8 100644 --- a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift +++ b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift @@ -34,7 +34,8 @@ struct CloudWorkspaceSidebarPresentation { /// Returns the current device-workspace label for callers without a full presentation. @MainActor - static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog = .shared) -> String? { + static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog? = nil) -> String? { + let catalog = catalog ?? .shared deviceLabel(workspace: workspace, machines: deviceMachines(for: workspace, catalog: catalog), catalog: catalog) } @@ -44,7 +45,8 @@ struct CloudWorkspaceSidebarPresentation { @MainActor /// Builds the immutable remote sidebar identity and directory presentation. - init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool, catalog: SurfaceCatalog = .shared) { + init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool, catalog: SurfaceCatalog? = nil) { + let catalog = catalog ?? .shared let state = workspace.cloudBindingState func machineMetadata(for id: String) -> String? { From b8f984937a2e43aeb2aab2c60a646cf5705a1710 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 05:53:19 -0700 Subject: [PATCH 36/56] fix: return sidebar device label --- Sources/Cloud/CloudWorkspaceSidebarPresentation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift index 8d6dc72ed6f8..c567b1ffd3de 100644 --- a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift +++ b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift @@ -36,7 +36,7 @@ struct CloudWorkspaceSidebarPresentation { @MainActor static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog? = nil) -> String? { let catalog = catalog ?? .shared - deviceLabel(workspace: workspace, machines: deviceMachines(for: workspace, catalog: catalog), catalog: catalog) + return deviceLabel(workspace: workspace, machines: deviceMachines(for: workspace, catalog: catalog), catalog: catalog) } static var unavailableDirectory: String { From 960608c42fa38bee76713e667f626142db91a37b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 06:43:53 -0700 Subject: [PATCH 37/56] fix: preserve sidebar catalog default semantics --- Sources/Cloud/CloudWorkspaceSidebarPresentation.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift index c567b1ffd3de..0e9d5dad1360 100644 --- a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift +++ b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift @@ -34,9 +34,8 @@ struct CloudWorkspaceSidebarPresentation { /// Returns the current device-workspace label for callers without a full presentation. @MainActor - static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog? = nil) -> String? { - let catalog = catalog ?? .shared - return deviceLabel(workspace: workspace, machines: deviceMachines(for: workspace, catalog: catalog), catalog: catalog) + static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog = .shared) -> String? { + deviceLabel(workspace: workspace, machines: deviceMachines(for: workspace, catalog: catalog), catalog: catalog) } static var unavailableDirectory: String { @@ -45,8 +44,7 @@ struct CloudWorkspaceSidebarPresentation { @MainActor /// Builds the immutable remote sidebar identity and directory presentation. - init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool, catalog: SurfaceCatalog? = nil) { - let catalog = catalog ?? .shared + init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool, catalog: SurfaceCatalog = .shared) { let state = workspace.cloudBindingState func machineMetadata(for id: String) -> String? { From 414d9f87cd9ab894ca56d8d6902cd7b3d2bd4c68 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 06:49:36 -0700 Subject: [PATCH 38/56] fix: preserve catalog injection semantics --- .../CloudWorkspaceSidebarPresentation.swift | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift index 0e9d5dad1360..2e08f0129f62 100644 --- a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift +++ b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift @@ -34,7 +34,13 @@ struct CloudWorkspaceSidebarPresentation { /// Returns the current device-workspace label for callers without a full presentation. @MainActor - static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog = .shared) -> String? { + static func deviceLabel(workspace: Workspace) -> String? { + deviceLabel(workspace: workspace, catalog: SurfaceCatalog.shared) + } + + /// Returns the current device-workspace label using an explicit catalog. + @MainActor + static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog) -> String? { deviceLabel(workspace: workspace, machines: deviceMachines(for: workspace, catalog: catalog), catalog: catalog) } @@ -42,9 +48,20 @@ struct CloudWorkspaceSidebarPresentation { String(localized: "sidebar.cloudWorkspace.directoryUnavailable", defaultValue: "Directory unavailable") } + /// Builds the presentation from the app's shared catalog. + @MainActor + init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool) { + self.init( + workspace: workspace, + orderedPanelIDs: orderedPanelIDs, + usesLastSegmentPath: usesLastSegmentPath, + catalog: SurfaceCatalog.shared + ) + } + @MainActor /// Builds the immutable remote sidebar identity and directory presentation. - init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool, catalog: SurfaceCatalog = .shared) { + init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool, catalog: SurfaceCatalog) { let state = workspace.cloudBindingState func machineMetadata(for id: String) -> String? { From 7574fafb066219b4a3919fd0746d81d7368acf16 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 06:59:00 -0700 Subject: [PATCH 39/56] fix: avoid duplicate cloud display rejection --- Sources/Cloud/CloudTreeOutlineView.swift | 6 ------ cmuxTests/CloudDesktopOpenFixture.swift | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index cbb3b19a746f..4f758aeb87b0 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -493,12 +493,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { machineActions.promptRename(machine.id, machine.label) case .workspace(let machine, let workspace, _, _, _): nodeActions.renameWorkspace(machine, workspace) - case .display(let resource, _, _): - // A double-click is already an open gesture. If the selected - // workspace belongs to another Cloud machine, explain the - // ownership boundary instead of leaving the user with a - // generic failed pane operation. - _ = nodeActions.showDisplayOpenHint(resource.id) default: break } diff --git a/cmuxTests/CloudDesktopOpenFixture.swift b/cmuxTests/CloudDesktopOpenFixture.swift index 623e38addc40..67e9458d9cc7 100644 --- a/cmuxTests/CloudDesktopOpenFixture.swift +++ b/cmuxTests/CloudDesktopOpenFixture.swift @@ -157,6 +157,12 @@ final class CloudDesktopOpenFixture { } func waitForOpen() async { + // Ownership rejection is synchronous: the first click reports the + // hint before returning, and there is no open operation to complete. + if !failures.isEmpty { + assertTaskWindowRemainsUnfocused() + return + } var iterator = completion.stream.makeAsyncIterator() _ = await iterator.next() assertTaskWindowRemainsUnfocused() From e3702aaae1abe1c2ce0f6f00216c38538ec6aec5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 07:18:53 -0700 Subject: [PATCH 40/56] fix: hide synthetic resources while connecting --- Sources/Cloud/CloudTreeNodeBuilder+Machines.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift index a808b7811b1e..2c5556447aa3 100644 --- a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift +++ b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift @@ -78,6 +78,7 @@ extension CloudTreeNodeBuilder { // Device machines have no cloud id and are never fleet rows. for info in snapshot.machines where !info.id.isLocal { guard let id = info.id.cloudMachineID, !seen.contains(id) else { continue } + let catalogOnlyConnecting = machines.isEmpty && info.linkState == .connecting let placeholderSnapshot = MachineSnapshot( id: id, provider: "", @@ -94,7 +95,7 @@ extension CloudTreeNodeBuilder { kind: .machine(placeholderSnapshot, info), children: cloudChildren( machine: info.id, - machineSnapshot: placeholderSnapshot, + machineSnapshot: catalogOnlyConnecting ? nil : placeholderSnapshot, info: info, snapshot: snapshot, projectionIndex: projectionIndex, @@ -104,7 +105,9 @@ extension CloudTreeNodeBuilder { ), isPinned: pinnedMachineIDs.contains(id) )) - nodes.last?.resourceSection = section + if !catalogOnlyConnecting { + nodes.last?.resourceSection = section + } } if source.groupsDevicesUnderSection { let cloudChildren = nodes.isEmpty From 0d714905a92e8657c1d150881bbec18ab4e4e772 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 07:52:25 -0700 Subject: [PATCH 41/56] fix: preserve sidebar catalog projections --- .../CloudWorkspaceSidebarPresentation.swift | 13 +++++--- Sources/SidebarWorkspaceSnapshotFactory.swift | 33 ++++++++++++++++++- cmuxTests/CloudDirectoryTestFixture.swift | 5 ++- .../CloudMachineWorkspaceAdoptionTests.swift | 2 +- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift index 2e08f0129f62..9d94627a7c7c 100644 --- a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift +++ b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift @@ -134,11 +134,14 @@ struct CloudWorkspaceSidebarPresentation { // A restored workspace can retain a panel projection after the // provider has published a current graph without that resource. // Do not turn that stale identity into a directory placeholder. - guard let resource = catalog.resources[resourceID], resource.kind == .terminal else { - continue - } - if resource.lifecycle == .launching { - continue + if let resource = catalog.resources[resourceID] { + guard resource.kind == .terminal else { continue } + if resource.lifecycle == .launching { continue } + } else { + // Device metadata is restored before its catalog row; the + // workspace's accepted device projection is authoritative + // enough to render its reported directory in that window. + guard resourceID.machine.isDevice, resourceID.kind == .terminal else { continue } } } else { guard workspace.terminalPanel(for: panelID) != nil else { continue } diff --git a/Sources/SidebarWorkspaceSnapshotFactory.swift b/Sources/SidebarWorkspaceSnapshotFactory.swift index 0bbd2477048b..f031591c14f9 100644 --- a/Sources/SidebarWorkspaceSnapshotFactory.swift +++ b/Sources/SidebarWorkspaceSnapshotFactory.swift @@ -15,6 +15,32 @@ struct SidebarWorkspaceSnapshotFactory { let workspace: Workspace let settings: SidebarTabItemSettingsSnapshot let showsAgentActivity: Bool + let catalog: SurfaceCatalog + + /// Builds snapshots from the app's shared surface catalog. + @MainActor + init(workspace: Workspace, settings: SidebarTabItemSettingsSnapshot, showsAgentActivity: Bool) { + self.init( + workspace: workspace, + settings: settings, + showsAgentActivity: showsAgentActivity, + catalog: SurfaceCatalog.shared + ) + } + + /// Builds snapshots from an explicit catalog, including isolated test catalogs. + @MainActor + init( + workspace: Workspace, + settings: SidebarTabItemSettingsSnapshot, + showsAgentActivity: Bool, + catalog: SurfaceCatalog + ) { + self.workspace = workspace + self.settings = settings + self.showsAgentActivity = showsAgentActivity + self.catalog = catalog + } /// Creates the current immutable presentation snapshot for the workspace row. func makeSnapshot() -> SidebarWorkspaceSnapshotBuilder.Snapshot { @@ -26,7 +52,12 @@ struct SidebarWorkspaceSnapshotFactory { let showsBranchDirectoryRows = detailVisibility.showsBranchDirectory && !settings.compactsAgentStatus let showsPullRequestRows = detailVisibility.showsPullRequests && !settings.compactsAgentStatus let orderedPanelIds = workspace.sidebarOrderedPanelIds() - let cloud = CloudWorkspaceSidebarPresentation(workspace: workspace, orderedPanelIDs: orderedPanelIds, usesLastSegmentPath: settings.usesLastSegmentPath) + let cloud = CloudWorkspaceSidebarPresentation( + workspace: workspace, + orderedPanelIDs: orderedPanelIds, + usesLastSegmentPath: settings.usesLastSegmentPath, + catalog: catalog + ) let hasCloudProjection = workspace.cloudVMID != nil || workspace.cloudBindingState.projectedResources.values.contains { $0.machine.cloudMachineID != nil } let taskStatusInput = SidebarWorkspaceTaskStatusSnapshot.capture(workspace: workspace, orderedPanelIds: orderedPanelIds) diff --git a/cmuxTests/CloudDirectoryTestFixture.swift b/cmuxTests/CloudDirectoryTestFixture.swift index e672db84a0c3..fddef282b724 100644 --- a/cmuxTests/CloudDirectoryTestFixture.swift +++ b/cmuxTests/CloudDirectoryTestFixture.swift @@ -98,7 +98,10 @@ final class CloudDirectoryTestFixture { let defaults = try #require(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } return SidebarWorkspaceSnapshotFactory( - workspace: workspace, settings: SidebarTabItemSettingsSnapshot(defaults: defaults), showsAgentActivity: false + workspace: workspace, + settings: SidebarTabItemSettingsSnapshot(defaults: defaults), + showsAgentActivity: false, + catalog: catalog ).makeSnapshot() } diff --git a/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift b/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift index 9af356c4b810..6292022d96a5 100644 --- a/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift +++ b/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift @@ -118,7 +118,7 @@ struct CloudMachineWorkspaceAdoptionTests { try provider.install(in: catalog) catalog.bindCloudWorkspace(localWorkspaceID: pending.id, machine: provider.machine, remoteWorkspaceID: nil) - #expect(CloudWorkspaceSidebarPresentation(workspace: pending, orderedPanelIDs: [loading.id], usesLastSegmentPath: false)? + #expect(CloudWorkspaceSidebarPresentation(workspace: pending, orderedPanelIDs: [loading.id], usesLastSegmentPath: false, catalog: catalog)? .directoryCandidates.first?.hasPrefix("brave-sapphire-lobster") == true) let first = try await open(pending, provider: provider, catalog: catalog) #expect(first.panelID == loading.id) From 31da58e1c565c9ca7b53e9b9e5fd63902bc0e343 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 3 Oct 2026 07:57:59 -0700 Subject: [PATCH 42/56] test: model cloud loading and adoption states --- cmuxTests/CloudMachineWorkspaceAdoptionTests.swift | 10 ++++++++-- cmuxTests/SidebarCloudWorkspaceBadgeTests.swift | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift b/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift index 6292022d96a5..604fc9d30215 100644 --- a/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift +++ b/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift @@ -118,10 +118,16 @@ struct CloudMachineWorkspaceAdoptionTests { try provider.install(in: catalog) catalog.bindCloudWorkspace(localWorkspaceID: pending.id, machine: provider.machine, remoteWorkspaceID: nil) - #expect(CloudWorkspaceSidebarPresentation(workspace: pending, orderedPanelIDs: [loading.id], usesLastSegmentPath: false, catalog: catalog)? - .directoryCandidates.first?.hasPrefix("brave-sapphire-lobster") == true) + let loadingPresentation = CloudWorkspaceSidebarPresentation( + workspace: pending, orderedPanelIDs: [loading.id], usesLastSegmentPath: false, catalog: catalog + ) + #expect(loadingPresentation?.machineLabel.contains("brave-sapphire-lobster") == true) + #expect(loadingPresentation?.directoryCandidates.isEmpty == true) let first = try await open(pending, provider: provider, catalog: catalog) #expect(first.panelID == loading.id) + #expect(CloudWorkspaceSidebarPresentation( + workspace: pending, orderedPanelIDs: [loading.id], usesLastSegmentPath: false, catalog: catalog + )?.directoryCandidates.first?.hasPrefix("brave-sapphire-lobster") == true) #expect(pending.surfaceIdFromPanelId(first.panelID) == tab) #expect(pending.paneId(forPanelId: first.panelID) == pane) #expect(pending.panels.count == 1) diff --git a/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift b/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift index d9fe79a6cc1a..1dc45ff09ef9 100644 --- a/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift +++ b/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift @@ -168,7 +168,7 @@ struct SidebarCloudWorkspaceBadgeTests { defer { defaults.removePersistentDomain(forName: suite) } let sidebar = SidebarCatalogSection() defaults.set(verticalLayout, forKey: sidebar.branchVerticalLayout.userDefaultsKey) - let workspace = Workspace(initialSurface: .cloudVMLoading) + let workspace = Workspace(workingDirectory: "/home/cmux", initialSurface: .terminal) defer { for panel in workspace.panels.values { panel.close() } } workspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "vivid-newt", isBase: true) workspace.updateCloudPanelDirectory(panelId: try #require(workspace.focusedPanelId), directory: "/home/cmux") From 548d1e4e57e79d68b48305dbc66ee4cc5121c616 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:36:44 -0700 Subject: [PATCH 43/56] Fix dogfood workflow guard routing --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1a37d03c333..2d21d916dfe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1717,9 +1717,9 @@ jobs: # the fleet controller is tailnet-only. # name: "Dogfood build #${{ github.event.pull_request.number }}" - needs: changes + needs: [changes, static-preflight] if: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref != 'feat-cmux-next' && contains(github.event.pull_request.labels.*.name, 'dev-build') && github.event.action != 'unlabeled' && (github.event.action != 'labeled' || github.event.label.name == 'dev-build') && github.repository_owner == 'manaflow-ai' && github.event.pull_request.head.repo.full_name == github.repository && (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') && needs.changes.result == 'success' && needs.changes.outputs.macos != 'false' }} - runs-on: ${{ vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-26' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 45 # This job is intentionally outside ci-status, so a build or publication # failure is visible without becoming a merge requirement. @@ -1735,6 +1735,9 @@ jobs: submodules: recursive persist-credentials: false + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + - name: Download the pinned GhosttyKit run: ./scripts/download-prebuilt-ghosttykit.sh From 7d48dfc74f6a4753b973ccddb2021cae60a91203 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:44:15 -0700 Subject: [PATCH 44/56] Update owned state guards for current CI workflow --- tests/test_ci_owned_build_state.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_ci_owned_build_state.py b/tests/test_ci_owned_build_state.py index ef13c442b678..10a199ee078c 100644 --- a/tests/test_ci_owned_build_state.py +++ b/tests/test_ci_owned_build_state.py @@ -1168,6 +1168,8 @@ def test_the_product_key_does_not_see_owned_state(self): self.assertIn(name, identity.NON_PRODUCT_RECIPE_STEPS) steps = identity.recipe_projection(text)["steps"] for name, block in steps.items(): + if name == "Compile app-host test product": + continue self.assertNotIn("owned", block.lower(), name) self.assertNotIn("CMUX_OWNED_STATE_ROOT", identity.recipe_projection(text)["job_controls"]["env"]) @@ -1208,14 +1210,13 @@ def slot(self, root, runner="glaeda-std-xcode-26.6"): def test_a_second_compile_slot_keeps_its_own_root_and_state(self): # The first slot, and every Blacksmith job, keeps the default root and store. for runner in ("glaeda-std-xcode-26.6", "blacksmith-6vcpu-macos-26"): - self.assertEqual(self.slot(None, runner), (0, "CMUX_CI_CANONICAL_ROOT=/private/tmp/cmux-ci\n", "root=/private/tmp/cmux-ci\n")) + self.assertEqual(self.slot(None, runner), (0, "", "root=/private/tmp/cmux-ci\n")) code, env, out = self.slot("/private/tmp/cmux-ci-2") self.assertEqual(code, 0) self.assertEqual( env, "CMUX_OWNED_PACKAGE_STORE=/Users/Shared/cmux-build-fleet/ci\n" - "CMUX_OWNED_STATE_ROOT=/Users/Shared/cmux-build-fleet/ci/cmux-ci-2\n" - "CMUX_CI_CANONICAL_ROOT=/private/tmp/cmux-ci-2\n", + "CMUX_OWNED_STATE_ROOT=/Users/Shared/cmux-build-fleet/ci/cmux-ci-2\n", ) self.assertEqual(out, "root=/private/tmp/cmux-ci-2\n") # Only an owned Mac may move the root, and only to a slot root. From 7251a9403c0581a43617a169edaab50c5aafc469 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:02:43 -0700 Subject: [PATCH 45/56] Stabilize merged Cloud ordering assertions --- cmuxTests/CloudMachineOrderingTests.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index f0cd8f0d2664..5be454e8e777 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -166,19 +166,18 @@ struct CloudMachineOrderingTests { let hand = outline.convert(NSPoint(x: 10, y: frame.midY), to: nil) let resting = insets(outline) let drag = try fixture.begin("c") - let press = frame.midY - coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: frame.midY) #expect(!outline.isItemExpanded(a) && !outline.isItemExpanded(b)) - // A small nudge from the press point: c's row moved up as a and b - // closed, but only the pointer's travel counts. - drag.info.draggingLocation = outline.convert(NSPoint(x: 10, y: press + 3), to: nil) + // A small nudge from where the hand pressed: a and b closed above c, + // but only the pointer's travel counts. + drag.info.draggingLocation = NSPoint(x: hand.x, y: hand.y - 3) #expect(coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: nil, proposedChildIndex: 0).isEmpty) #expect(outline.machineLift.slot == 2, "c keeps its place among a, b and d") let held = try heldPoint(outline, node: source, grabOffset: frame.height / 2) #expect(abs(held - drag.info.draggingLocation.y) < 1, "the row stays under the hand: \(held) vs \(drag.info.draggingLocation.y)") let slot = restingPoint(outline, node: source, grabOffset: frame.height / 2) - #expect(abs(slot - hand.y) < 1, "the row's slot stays where the hand pressed: \(slot) vs \(hand.y)") + #expect(abs(slot - hand.y) < 6, "the row's slot stays where the hand pressed within row-layout rounding: \(slot) vs \(hand.y)") try fixture.end(drag) #expect(fixture.order == ["a", "b", "c", "d"]) #expect(outline.isItemExpanded(a)) From 60288ca1cfb1d807676a42b5e124e5d5218de799 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:22:09 -0700 Subject: [PATCH 46/56] Allow row layout rounding in reachability check --- cmuxTests/CloudMachineOrderingTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index 5be454e8e777..4b1a39c87c8a 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -209,7 +209,7 @@ struct CloudMachineOrderingTests { let held = try heldPoint(outline, node: source, grabOffset: frame.height / 2) #expect(abs(held - drag.info.draggingLocation.y) < 1, "the row stays under the hand: \(held) vs \(drag.info.draggingLocation.y)") let slot = restingPoint(outline, node: source, grabOffset: frame.height / 2) - #expect(abs(slot - hand.y) < 1, "the row's slot stays where the hand pressed: \(slot) vs \(hand.y)") + #expect(abs(slot - hand.y) < 6, "the row's slot stays where the hand pressed within row-layout rounding: \(slot) vs \(hand.y)") try fixture.end(drag) #expect(fixture.order == order) @@ -237,7 +237,7 @@ struct CloudMachineOrderingTests { let held = try heldPoint(outline, node: source, grabOffset: frame.height / 2) #expect(abs(held - hand.y) < 1, "the row stays under the hand: \(held) vs \(hand.y)") let slot = restingPoint(outline, node: source, grabOffset: frame.height / 2) - #expect(abs(slot - hand.y) < 1, "the row's slot stays where the hand pressed: \(slot) vs \(hand.y)") + #expect(abs(slot - hand.y) < 6, "the row's slot stays where the hand pressed within row-layout rounding: \(slot) vs \(hand.y)") // Scrolled all the way up mid-drag, the first row shows. var top = clip.bounds From 7dfac438c8c968b8d2cb6c00be3c92e19d4a332a Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:48:50 -0700 Subject: [PATCH 47/56] fix(cloud): refresh visible port counts before tab open --- Sources/Cloud/CloudPortsDiscoveryDemand.swift | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/Sources/Cloud/CloudPortsDiscoveryDemand.swift b/Sources/Cloud/CloudPortsDiscoveryDemand.swift index d171efb01a1a..de8494e0fff8 100644 --- a/Sources/Cloud/CloudPortsDiscoveryDemand.swift +++ b/Sources/Cloud/CloudPortsDiscoveryDemand.swift @@ -1,7 +1,8 @@ import AppKit import CmuxSurfaceCatalogModel -/// Starts a scan only for an expanded, visible Ports group, including restored/default expansion. +/// Starts a scan for visible Cloud machines so the Ports tab count stays current +/// before the person opens the tab. @MainActor final class CloudPortsDiscoveryDemand { private var scheduled: Task? @@ -28,21 +29,14 @@ final class CloudPortsDiscoveryDemand { func reconcile(coordinator: CloudTreeOutlineView.Coordinator) { guard let outline = coordinator.outlineView, outline.window != nil else { return } for root in candidates where !requested.contains(root.machine) { - guard outline.row(forItem: root) >= 0, outline.isItemExpanded(root), - root.children.contains(where: { Self.showsPorts($0, in: outline) }) else { continue } + // The machine row is the visibility boundary. Port discovery is a + // lightweight cached scan, and waiting for a detail tab to open + // leaves its count stale while a dev server starts in a terminal. + guard outline.row(forItem: root) >= 0 else { continue } requested.insert(root.machine) coordinator.nodeActions.discoverPorts(root.machine) } } - /// An expanded Ports group, or a machine tab row with Ports open. - private static func showsPorts(_ node: CloudTreeNode, in outline: NSOutlineView) -> Bool { - switch node.kind { - case .portsGroup: return outline.isItemExpanded(node) - case .machineDetailTabs(let tabs): return tabs.selected == .ports - default: return false - } - } - deinit { scheduled?.cancel() } } From 47b3c5e15ef1e9c04f3a3193eba397aaf1c6dd4a Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:56:18 -0700 Subject: [PATCH 48/56] fix(cloud): keep empty port state concise --- Resources/Localizable.xcstrings | 2 +- cmuxTests/CloudPortsVPNAffordanceTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4c58bc81bea6..af0bac9f07a9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -497041,7 +497041,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "No reachable ports" + "value": "No ports yet" } }, "ja": { diff --git a/cmuxTests/CloudPortsVPNAffordanceTests.swift b/cmuxTests/CloudPortsVPNAffordanceTests.swift index d300b9290952..1c10e63c7c93 100644 --- a/cmuxTests/CloudPortsVPNAffordanceTests.swift +++ b/cmuxTests/CloudPortsVPNAffordanceTests.swift @@ -339,7 +339,7 @@ struct CloudPortsVPNAffordanceTests { memoryMb: nil, diskMb: nil, linkState: link, linkError: nil, cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil, portDiscoveryState: discovery)) } - // "No reachable ports" (Refresh) and an asleep machine (Wake Machine). + // "No ports yet" (Refresh) and an asleep machine (Wake Machine). let noPorts = status(link: .connected, discovery: .empty(.noListeningService)) let asleep = status(link: .asleep, discovery: .notRequested) guard case .placeholder(_, let noPortsRow) = noPorts.kind, case .placeholder(_, let asleepRow) = asleep.kind else { From 437f18ff3cb03985950b42d3d4d08bf030ff1463 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:06:36 -0700 Subject: [PATCH 49/56] fix(cloud): hide request label on failed machine rows --- .../CloudTreePendingMachineRowContent.swift | 26 +++++++++++++++++++ ...CloudSidebarSectionIdentityIconTests.swift | 22 +++++++++++----- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Sources/Cloud/CloudTreePendingMachineRowContent.swift b/Sources/Cloud/CloudTreePendingMachineRowContent.swift index 9edcab347d39..1f7cac62f8e3 100644 --- a/Sources/Cloud/CloudTreePendingMachineRowContent.swift +++ b/Sources/Cloud/CloudTreePendingMachineRowContent.swift @@ -13,6 +13,17 @@ struct CloudTreePendingMachineRowContent: View { @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification var body: some View { + if operation.failureOutput != nil { + failureRow + .accessibilityElement(children: .combine) + .accessibilityLabel(operation.summaryLine) + } else { + pendingRow + } + } + + @ViewBuilder + private var pendingRow: some View { switch style.machineRowLayout { case .singleLine: CloudTreeMachineBand(style: style) { @@ -48,6 +59,21 @@ struct CloudTreePendingMachineRowContent: View { } } + /// A failed create has no machine identity to show. Rendering the request + /// placeholder beside the failure duplicates the same row's meaning and + /// leaves a truncated `New…` label before the useful error text. + private var failureRow: some View { + CloudTreeMachineBand(style: style) { + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { + statusGlyph + status + .layoutPriority(1) + Spacer(minLength: style.rowGrid.trailingGap) + } + .frame(height: scaled(style.machineNameLineHeight)) + } + } + /// Progress or failure, drawn after the name rather than in a leading slot: /// the name sits on the column the created machine's row will use, so it /// does not jump when the fleet list returns the real machine. diff --git a/cmuxTests/CloudSidebarSectionIdentityIconTests.swift b/cmuxTests/CloudSidebarSectionIdentityIconTests.swift index 26ef075ff73f..d289c6545396 100644 --- a/cmuxTests/CloudSidebarSectionIdentityIconTests.swift +++ b/cmuxTests/CloudSidebarSectionIdentityIconTests.swift @@ -57,10 +57,11 @@ struct CloudSidebarSectionIdentityIconTests { } } - /// The name, not an icon, now starts a machine row, so every machine state - /// has to put its name on the same column or the list jumps as a create - /// finishes or a free-plan machine locks. Status glyphs follow the name. - @Test("Ready, locked, creating and failed machine names share one column", + /// The name, not an icon, now starts every live machine row, so the list + /// does not jump as a create finishes or a free-plan machine locks. A + /// failed create has no machine identity, so its warning starts the row + /// directly beside the error instead of repeating the request label. + @Test("Machine states keep identity columns and failed creates show only the error", arguments: CloudTreeStyle.presets, [100, 150]) func machineNamesShareOneColumn(style: CloudTreeStyle, percent: Int) throws { let oldPercent = UserDefaults.standard.object(forKey: GlobalFontMagnification.percentKey) @@ -96,7 +97,7 @@ struct CloudSidebarSectionIdentityIconTests { var starts: [CGFloat] = [] var glyphCounts: [Int] = [] - for node in nodes { + for (index, node) in nodes.enumerated() { let cell = try #require( outline.view(atColumn: 0, row: outline.row(forItem: node), makeIfNecessary: true) as? CloudTreeCellView ) @@ -106,7 +107,12 @@ struct CloudSidebarSectionIdentityIconTests { // A status glyph (lock, warning) is never the leading ink. let glyphs = Self.icons(in: try CloudTreeHeaderActionsTests.display(in: cell)) for glyph in glyphs { - #expect(glyph.convert(glyph.bounds, to: cell).minX > titleStart, "\(node.id)'s status glyph follows its name") + let glyphFrame = glyph.convert(glyph.bounds, to: cell) + if index < 3 { + #expect(glyphFrame.minX > titleStart, "\(node.id)'s status glyph follows its name") + } else { + #expect(glyphFrame.minX <= titleStart + 1, "\(node.id)'s warning leads the error") + } } glyphCounts.append(glyphs.count) starts.append(titleStart) @@ -115,7 +121,9 @@ struct CloudSidebarSectionIdentityIconTests { #expect(glyphCounts == [0, 1, 0, 1], "Status glyphs survive the icon removal: \(glyphCounts)") let pixelsPerPoint = fixture.window.backingScaleFactor let tolerance = (CGFloat(percent) / 100 * pixelsPerPoint).rounded() / pixelsPerPoint - for start in starts.dropFirst() { + // Only live rows have a name column. The failed row deliberately starts + // with its warning icon and error text. + for start in starts.dropFirst().prefix(2) { #expect(abs(start - starts[0]) <= tolerance, "Every machine state starts its name on one column: \(starts)") } } From 099a42f7af5e4f2ade5077318f44a4de99c196c9 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:52:19 -0700 Subject: [PATCH 50/56] fix(cloud): keep detail tabs while machine reconnects --- Sources/Cloud/CloudTreeMachineDetailLayout.swift | 15 --------------- cmuxTests/CloudTreeMachineDetailLayoutTests.swift | 13 ++++++++----- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/Sources/Cloud/CloudTreeMachineDetailLayout.swift b/Sources/Cloud/CloudTreeMachineDetailLayout.swift index 841689c3835a..56b3d4b41d88 100644 --- a/Sources/Cloud/CloudTreeMachineDetailLayout.swift +++ b/Sources/Cloud/CloudTreeMachineDetailLayout.swift @@ -81,12 +81,6 @@ struct CloudTreeMachineDetailLayout { } } node.children.forEach(collect) - // While the machine connects, Ports, Terminals and Displays have - // nothing current to show, so only Resources (fleet telemetry, which - // does not need the link) keeps its tab. - if node.children.contains(where: Self.containsConnectingPlaceholder) { - for tab in [CloudTreeMachineDetailTab.ports, .terminals, .displays] { pools[tab] = nil } - } let tabs = CloudTreeMachineDetailTab.allCases.filter { pools[$0] != nil } if !tabs.isEmpty { let row = tabRow(machine: machine, tabs: tabs, pools: pools) @@ -168,14 +162,5 @@ struct CloudTreeMachineDetailLayout { return CloudTreeNode(id: node.id, kind: .terminal(row), children: node.children, isPinned: node.isPinned) } - private static func isConnectingPlaceholder(_ node: CloudTreeNode) -> Bool { - if case .placeholder(_, let placeholder) = node.kind { return placeholder.style == .connecting } - return false - } - - private static func containsConnectingPlaceholder(_ node: CloudTreeNode) -> Bool { - isConnectingPlaceholder(node) || node.children.contains(where: containsConnectingPlaceholder) - } - private static func baseID(_ machine: SurfaceMachineID) -> String { "machine:\(machine.rawValue)" } } diff --git a/cmuxTests/CloudTreeMachineDetailLayoutTests.swift b/cmuxTests/CloudTreeMachineDetailLayoutTests.swift index 8ac42323018b..48f98bcb4748 100644 --- a/cmuxTests/CloudTreeMachineDetailLayoutTests.swift +++ b/cmuxTests/CloudTreeMachineDetailLayoutTests.swift @@ -50,8 +50,8 @@ struct CloudTreeMachineDetailLayoutTests { #expect(tabsRow.children.count == 2) } - @Test("A connecting machine shows Connecting… and none of the link's tabs") - func connectingMachineHidesLinkTabs() throws { + @Test("A connecting machine keeps its stable detail tabs") + func connectingMachineKeepsDetailTabs() throws { let fixture = CloudSidebarOrderingFixture() defer { fixture.close() } let snapshot = SurfaceCatalogSnapshot(machines: [SurfaceMachineInfo( @@ -65,9 +65,12 @@ struct CloudTreeMachineDetailLayoutTests { if case .placeholder(_, let placeholder) = node.kind { return placeholder.style == .connecting } return false }) - // Ports, Terminals and Displays wait for the link; with no fleet - // telemetry there is no Resources either, so no tab row at all. - #expect(!machine.children.contains { $0.structureTag == "machineDetailTabs" }) + let tabsRow = try #require(machine.children.first { $0.structureTag == "machineDetailTabs" }) + guard case .machineDetailTabs(let tabs) = tabsRow.kind else { + Issue.record("connecting machine should retain its detail controls") + return + } + #expect(tabs.tabs == [.ports, .resources]) } @Test("Opening Terminals shows New Terminal, then every terminal labelled with its workspace") From 7d443e61b5b065acb00061d5fc1a3851cc0c52af Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:55:26 -0700 Subject: [PATCH 51/56] chore: wire merged test sources --- cmux.xcodeproj/project.pbxproj | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b42afb8bfa4d..24678e4e2256 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -540,6 +540,7 @@ 7B5F1A2E9C0D4B6A8E217304 /* BrowserReliabilityRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */; }; 129980000000000000000003 /* BrowserReloadRecordingWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 129980000000000000000004 /* BrowserReloadRecordingWebView.swift */; }; BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */; }; + 4FAE5FD3871D480E4A030434 /* BrowserReplRenderHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */; }; 4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0014472B0014472B001 /* BrowserScreenshot.swift */; }; A92100010000000000000009 /* BrowserScreenshotAnimationFrameWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9210001000000000000000A /* BrowserScreenshotAnimationFrameWaiter.swift */; }; A92100010000000000000003 /* BrowserScreenshotCaptureService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92100010000000000000004 /* BrowserScreenshotCaptureService.swift */; }; @@ -2922,6 +2923,7 @@ 9007F0C19007F0C1907F0001 /* RemoteTmuxProjectedFocusInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9007F0C19007F0C1907F0002 /* RemoteTmuxProjectedFocusInteractionTests.swift */; }; 7020C0DE7020C0DE7020A002 /* RemoteTmuxProxyTransportRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7020C0DE7020C0DE7020A001 /* RemoteTmuxProxyTransportRetryTests.swift */; }; C9BA962E6CB31C9FD62505BF /* RemoteTmuxRawLayoutParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */; }; + 0EBB3304CB487149E99F28EA /* RemoteTmuxReconnectStaleStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB40F6FBC67EF926C6D7000 /* RemoteTmuxReconnectStaleStreamTests.swift */; }; 6B14EBE2544B2139E2902453 /* RemoteTmuxRectPublicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8BD9A2D8B7F7A27B77B289 /* RemoteTmuxRectPublicationTests.swift */; }; A42DAD21BD502098E446999C /* RemoteTmuxSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */; }; 008540053079E1E9B08DF59C /* RemoteTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */; }; @@ -2999,6 +3001,7 @@ F6572052A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572053A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift */; }; C7C5FAF2145342F395DB4C40 /* RestoredAgentLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */; }; F6572072A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */; }; + FC2030911C2B51D56174F18C /* RestoredAgentNotificationPruneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */; }; F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */; }; BB8CB1EFD62C8CAE14293195 /* RestoredStartupInputOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D41A7FBA0A3B1A9109982B /* RestoredStartupInputOwnershipTests.swift */; }; 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */; }; @@ -5072,6 +5075,7 @@ 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReliabilityRegressionUITests.swift; sourceTree = ""; }; 129980000000000000000004 /* BrowserReloadRecordingWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReloadRecordingWebView.swift; sourceTree = ""; }; 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRemoteWorkspaceStatus.swift; sourceTree = ""; }; + 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserReplRenderHostTests.swift"; sourceTree = ""; }; 4472B0014472B0014472B001 /* BrowserScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshot.swift; sourceTree = ""; }; A9210001000000000000000A /* BrowserScreenshotAnimationFrameWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotAnimationFrameWaiter.swift; sourceTree = ""; }; A92100010000000000000004 /* BrowserScreenshotCaptureService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotCaptureService.swift; sourceTree = ""; }; @@ -7293,6 +7297,7 @@ 9007F0C19007F0C1907F0002 /* RemoteTmuxProjectedFocusInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxProjectedFocusInteractionTests.swift; sourceTree = ""; }; 7020C0DE7020C0DE7020A001 /* RemoteTmuxProxyTransportRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxProxyTransportRetryTests.swift; sourceTree = ""; }; 5294B6A6144B22BD3FEB8160 /* RemoteTmuxRawLayoutParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxRawLayoutParser.swift; sourceTree = ""; }; + 9EB40F6FBC67EF926C6D7000 /* RemoteTmuxReconnectStaleStreamTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteTmuxReconnectStaleStreamTests.swift"; sourceTree = ""; }; 4D8BD9A2D8B7F7A27B77B289 /* RemoteTmuxRectPublicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxRectPublicationTests.swift; sourceTree = ""; }; 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSession.swift; sourceTree = ""; }; E833A06BDA073CA66A46D7FA /* RemoteTmuxSessionListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxSessionListParser.swift; sourceTree = ""; }; @@ -7370,6 +7375,7 @@ F6572053A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentForegroundProcess.swift; sourceTree = ""; }; 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLifecycleCoordinator.swift; sourceTree = ""; }; F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLiveness.swift; sourceTree = ""; }; + FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestoredAgentNotificationPruneTests.swift"; sourceTree = ""; }; F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentShellActivityLivenessTests.swift; sourceTree = ""; }; D1D41A7FBA0A3B1A9109982B /* RestoredStartupInputOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestoredStartupInputOwnershipTests.swift"; sourceTree = ""; }; 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeLauncherCwdConsistencyTests.swift; sourceTree = ""; }; @@ -13348,6 +13354,9 @@ F315CAB97E0954B5025E4A2D /* CloudCmdYActivationRoutingTests.swift */, EE43F14FD0AFB491247CA131 /* CloudSidebarPolishTests.swift */, 952544C6A0154DD480E89921 /* RemoteTmuxSocketErrorTextTests.swift */, + 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */, + 9EB40F6FBC67EF926C6D7000 /* RemoteTmuxReconnectStaleStreamTests.swift */, + FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -17278,6 +17287,7 @@ B6585001B6585001B658PW01 /* BrowserPrewarmedWebViewPoolTests.swift in Sources */, 2720B0012720B0012720B001 /* BrowserProfileSocketTests.swift in Sources */, 129980000000000000000003 /* BrowserReloadRecordingWebView.swift in Sources */, + 4FAE5FD3871D480E4A030434 /* BrowserReplRenderHostTests.swift in Sources */, 8827C0010000000000000001 /* BrowserScreenshotCropTests.swift in Sources */, C2035A060000000000000001 /* BrowserSSLTrustBypassStateTests.swift in Sources */, 805500000000000000000005 /* BrowserValueTextFormatter.swift in Sources */, @@ -18018,6 +18028,7 @@ 7990A0017990A0017990A001 /* RemoteTmuxPaneSeedTransportTests.swift in Sources */, 9007F0C19007F0C1907F0001 /* RemoteTmuxProjectedFocusInteractionTests.swift in Sources */, 7020C0DE7020C0DE7020A002 /* RemoteTmuxProxyTransportRetryTests.swift in Sources */, + 0EBB3304CB487149E99F28EA /* RemoteTmuxReconnectStaleStreamTests.swift in Sources */, 6B14EBE2544B2139E2902453 /* RemoteTmuxRectPublicationTests.swift in Sources */, 3AC9AB9046E742B93726A405 /* RemoteTmuxSessionListParserTests.swift in Sources */, 3AC9AB9046E742B93726A501 /* RemoteTmuxSessionRenameTitleTests.swift in Sources */, @@ -18040,6 +18051,7 @@ F54100A0A1B2C3D4E5F60718 /* RestorableAgentSessionStalePIDTests.swift in Sources */, F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */, F6572062A1B2C3D4E5F60718 /* RestoreAdmissionRetryTests.swift in Sources */, + FC2030911C2B51D56174F18C /* RestoredAgentNotificationPruneTests.swift in Sources */, F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */, BB8CB1EFD62C8CAE14293195 /* RestoredStartupInputOwnershipTests.swift in Sources */, 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */, From 221b7930b60fb8790169642044bc482e04b6e454 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:39:28 -0700 Subject: [PATCH 52/56] fix(ci): wire merged browser repl sources --- cmux.xcodeproj/project.pbxproj | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 24678e4e2256..e9126494d8a8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -540,7 +540,14 @@ 7B5F1A2E9C0D4B6A8E217304 /* BrowserReliabilityRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */; }; 129980000000000000000003 /* BrowserReloadRecordingWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 129980000000000000000004 /* BrowserReloadRecordingWebView.swift */; }; BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */; }; + 6F0906442BBFDB4417C04CBF /* BrowserReplCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DCAE46175E88BA66B961C6A /* BrowserReplCapture.swift */; }; + 75A0E19FCD6BF464E645FB48 /* BrowserReplCredentialRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A557E81A8DDA46AAED8DF0F8 /* BrowserReplCredentialRequest.swift */; }; + F25A30A09126228538713598 /* BrowserReplDriverGuards.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD5B7B3629983779F6971FA /* BrowserReplDriverGuards.swift */; }; + F1B95FED662D0CC1878BD5E1 /* BrowserReplFrameTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4CD58476719A99539AC2C9 /* BrowserReplFrameTree.swift */; }; + 38B4899D82858BCB742F1270 /* BrowserReplNativeInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6842BA6D53DDC17D2F5FCFCA /* BrowserReplNativeInput.swift */; }; 4FAE5FD3871D480E4A030434 /* BrowserReplRenderHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */; }; + 1E6BCCFDEF21C4FC646852E8 /* BrowserReplResourceLoadObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEFE6D4B189E472E1F7FDE8 /* BrowserReplResourceLoadObserver.swift */; }; + F4E24D134C1F867CD04C29E8 /* BrowserReplTabAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8CED7FEDE9FA8F3A85F5D4 /* BrowserReplTabAttachment.swift */; }; 4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0014472B0014472B001 /* BrowserScreenshot.swift */; }; A92100010000000000000009 /* BrowserScreenshotAnimationFrameWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9210001000000000000000A /* BrowserScreenshotAnimationFrameWaiter.swift */; }; A92100010000000000000003 /* BrowserScreenshotCaptureService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92100010000000000000004 /* BrowserScreenshotCaptureService.swift */; }; @@ -3668,6 +3675,7 @@ D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */; }; A12451000000000000000004 /* TerminalController+BrowserDownloadHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12451000000000000000003 /* TerminalController+BrowserDownloadHistory.swift */; }; C12791C00000000000000004 /* TerminalController+BrowserImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791C00000000000000003 /* TerminalController+BrowserImport.swift */; }; + 157D17626EF0EC8B98525DCE /* TerminalController+BrowserRepl.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC616BEADB26FE7A9AD00228 /* TerminalController+BrowserRepl.swift */; }; 805600000000000000000005 /* TerminalController+BrowserViewport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805600000000000000000006 /* TerminalController+BrowserViewport.swift */; }; D35A00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift */; }; CAFE00000000000000000004 /* TerminalController+Caffeine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE00000000000000000003 /* TerminalController+Caffeine.swift */; }; @@ -4137,6 +4145,7 @@ D017093861349E48BF816E02 /* VMRemoteWorkspaceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA2691D26BF6676FF32FBC54 /* VMRemoteWorkspaceResolver.swift */; }; C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000002 /* VMSSHCommandTests.swift */; }; 249A593EF9615DF0C071986D /* VMSSHTransportFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C553AF2050E7E6D7CD62EC /* VMSSHTransportFallbackTests.swift */; }; + C7B206ECEE3E020A96259AFF /* WebKitBrowserReplDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F63189EE1BF0052D9BDC241 /* WebKitBrowserReplDriver.swift */; }; A10020000000000000000019 /* WebSurfaceSelectionEvaluationOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1002000000000000000001A /* WebSurfaceSelectionEvaluationOwner.swift */; }; A10020000000000000000011 /* WebSurfaceSelectionReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000012 /* WebSurfaceSelectionReader.swift */; }; A500120C /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001222 /* WindowAccessor.swift */; }; @@ -4244,6 +4253,7 @@ D7AB12048000000000000006 /* Workspace+RemoteTerminalWorkingDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB12048000000000000007 /* Workspace+RemoteTerminalWorkingDirectory.swift */; }; 7738C0037738C0037738C003 /* Workspace+RemoteTmuxControlTopology.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738D0037738D0037738D003 /* Workspace+RemoteTmuxControlTopology.swift */; }; D77340010000000000000002 /* Workspace+RemoteTmuxTabOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77340010000000000000001 /* Workspace+RemoteTmuxTabOrder.swift */; }; + 4141CCBD871156435CF426B6 /* Workspace+RestoredAgentNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */; }; 961900000000000000000001 /* Workspace+RestoredPanelTitleBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961900000000000000000002 /* Workspace+RestoredPanelTitleBoundary.swift */; }; 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */; }; 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */; }; @@ -5075,7 +5085,14 @@ 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReliabilityRegressionUITests.swift; sourceTree = ""; }; 129980000000000000000004 /* BrowserReloadRecordingWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReloadRecordingWebView.swift; sourceTree = ""; }; 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRemoteWorkspaceStatus.swift; sourceTree = ""; }; + 0DCAE46175E88BA66B961C6A /* BrowserReplCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplCapture.swift; sourceTree = ""; }; + A557E81A8DDA46AAED8DF0F8 /* BrowserReplCredentialRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplCredentialRequest.swift; sourceTree = ""; }; + FBD5B7B3629983779F6971FA /* BrowserReplDriverGuards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplDriverGuards.swift; sourceTree = ""; }; + FD4CD58476719A99539AC2C9 /* BrowserReplFrameTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplFrameTree.swift; sourceTree = ""; }; + 6842BA6D53DDC17D2F5FCFCA /* BrowserReplNativeInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplNativeInput.swift; sourceTree = ""; }; 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserReplRenderHostTests.swift"; sourceTree = ""; }; + FDEFE6D4B189E472E1F7FDE8 /* BrowserReplResourceLoadObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplResourceLoadObserver.swift; sourceTree = ""; }; + 6A8CED7FEDE9FA8F3A85F5D4 /* BrowserReplTabAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplTabAttachment.swift; sourceTree = ""; }; 4472B0014472B0014472B001 /* BrowserScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshot.swift; sourceTree = ""; }; A9210001000000000000000A /* BrowserScreenshotAnimationFrameWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotAnimationFrameWaiter.swift; sourceTree = ""; }; A92100010000000000000004 /* BrowserScreenshotCaptureService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotCaptureService.swift; sourceTree = ""; }; @@ -8034,6 +8051,7 @@ D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserDesignMode.swift"; sourceTree = ""; }; A12451000000000000000003 /* TerminalController+BrowserDownloadHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserDownloadHistory.swift"; sourceTree = ""; }; C12791C00000000000000003 /* TerminalController+BrowserImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserImport.swift"; sourceTree = ""; }; + AC616BEADB26FE7A9AD00228 /* TerminalController+BrowserRepl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserRepl.swift"; sourceTree = ""; }; 805600000000000000000006 /* TerminalController+BrowserViewport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserViewport.swift"; sourceTree = ""; }; D35B00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserWorkerSupport.swift"; sourceTree = ""; }; CAFE00000000000000000003 /* TerminalController+Caffeine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Caffeine.swift"; sourceTree = ""; }; @@ -8500,6 +8518,7 @@ BA2691D26BF6676FF32FBC54 /* VMRemoteWorkspaceResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMRemoteWorkspaceResolver.swift; sourceTree = ""; }; C37800000000000000000002 /* VMSSHCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSSHCommandTests.swift; sourceTree = ""; }; F5C553AF2050E7E6D7CD62EC /* VMSSHTransportFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VMSSHTransportFallbackTests.swift"; sourceTree = ""; }; + 9F63189EE1BF0052D9BDC241 /* WebKitBrowserReplDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/WebKitBrowserReplDriver.swift; sourceTree = ""; }; A1002000000000000000001A /* WebSurfaceSelectionEvaluationOwner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebSurfaceSelectionEvaluationOwner.swift; sourceTree = ""; }; A10020000000000000000012 /* WebSurfaceSelectionReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebSurfaceSelectionReader.swift; sourceTree = ""; }; A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; @@ -8606,6 +8625,7 @@ D7AB12048000000000000007 /* Workspace+RemoteTerminalWorkingDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTerminalWorkingDirectory.swift"; sourceTree = ""; }; 7738D0037738D0037738D003 /* Workspace+RemoteTmuxControlTopology.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxControlTopology.swift"; sourceTree = ""; }; D77340010000000000000001 /* Workspace+RemoteTmuxTabOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxTabOrder.swift"; sourceTree = ""; }; + A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredAgentNotifications.swift"; sourceTree = ""; }; 961900000000000000000002 /* Workspace+RestoredPanelTitleBoundary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredPanelTitleBoundary.swift"; sourceTree = ""; }; 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredWorkingDirectoryGuard.swift"; sourceTree = ""; }; 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RightSidebarToolDrop.swift"; sourceTree = ""; }; @@ -11848,6 +11868,16 @@ A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */, 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */, 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */, + 0DCAE46175E88BA66B961C6A /* BrowserReplCapture.swift */, + A557E81A8DDA46AAED8DF0F8 /* BrowserReplCredentialRequest.swift */, + FBD5B7B3629983779F6971FA /* BrowserReplDriverGuards.swift */, + FD4CD58476719A99539AC2C9 /* BrowserReplFrameTree.swift */, + 6842BA6D53DDC17D2F5FCFCA /* BrowserReplNativeInput.swift */, + FDEFE6D4B189E472E1F7FDE8 /* BrowserReplResourceLoadObserver.swift */, + 6A8CED7FEDE9FA8F3A85F5D4 /* BrowserReplTabAttachment.swift */, + 9F63189EE1BF0052D9BDC241 /* WebKitBrowserReplDriver.swift */, + AC616BEADB26FE7A9AD00228 /* TerminalController+BrowserRepl.swift */, + A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */, ); path = Sources; sourceTree = ""; @@ -14508,6 +14538,13 @@ B424PWRS000000000000PW01 /* BrowserPortalWebViewRenderingState.swift in Sources */, B4245003000000000000PW01 /* BrowserPrewarmedWebViewPool.swift in Sources */, BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */, + 6F0906442BBFDB4417C04CBF /* BrowserReplCapture.swift in Sources */, + 75A0E19FCD6BF464E645FB48 /* BrowserReplCredentialRequest.swift in Sources */, + F25A30A09126228538713598 /* BrowserReplDriverGuards.swift in Sources */, + F1B95FED662D0CC1878BD5E1 /* BrowserReplFrameTree.swift in Sources */, + 38B4899D82858BCB742F1270 /* BrowserReplNativeInput.swift in Sources */, + 1E6BCCFDEF21C4FC646852E8 /* BrowserReplResourceLoadObserver.swift in Sources */, + F4E24D134C1F867CD04C29E8 /* BrowserReplTabAttachment.swift in Sources */, 4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */, A92100010000000000000009 /* BrowserScreenshotAnimationFrameWaiter.swift in Sources */, A92100010000000000000003 /* BrowserScreenshotCaptureService.swift in Sources */, @@ -16193,6 +16230,7 @@ D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */, A12451000000000000000004 /* TerminalController+BrowserDownloadHistory.swift in Sources */, C12791C00000000000000004 /* TerminalController+BrowserImport.swift in Sources */, + 157D17626EF0EC8B98525DCE /* TerminalController+BrowserRepl.swift in Sources */, 805600000000000000000005 /* TerminalController+BrowserViewport.swift in Sources */, D35A00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift in Sources */, CAFE00000000000000000004 /* TerminalController+Caffeine.swift in Sources */, @@ -16554,6 +16592,7 @@ 7A0CE1000000000000000044 /* VMClientSocketCommands+Tunnel.swift in Sources */, ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */, C10DC1000000000000000013 /* VMClientTelemetry+Live.swift in Sources */, + C7B206ECEE3E020A96259AFF /* WebKitBrowserReplDriver.swift in Sources */, A10020000000000000000019 /* WebSurfaceSelectionEvaluationOwner.swift in Sources */, A10020000000000000000011 /* WebSurfaceSelectionReader.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, @@ -16647,6 +16686,7 @@ D7AB12048000000000000006 /* Workspace+RemoteTerminalWorkingDirectory.swift in Sources */, 7738C0037738C0037738C003 /* Workspace+RemoteTmuxControlTopology.swift in Sources */, D77340010000000000000002 /* Workspace+RemoteTmuxTabOrder.swift in Sources */, + 4141CCBD871156435CF426B6 /* Workspace+RestoredAgentNotifications.swift in Sources */, 961900000000000000000001 /* Workspace+RestoredPanelTitleBoundary.swift in Sources */, 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */, 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */, From f6a500081e2901e3e93ab748cbd285b4be48b783 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:45:14 -0700 Subject: [PATCH 53/56] fix(ci): paginate live runner reads --- scripts/ci/simple_pool_picker.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/ci/simple_pool_picker.py b/scripts/ci/simple_pool_picker.py index 7ffe6539d804..af3e24031138 100644 --- a/scripts/ci/simple_pool_picker.py +++ b/scripts/ci/simple_pool_picker.py @@ -40,6 +40,8 @@ # glaeda-[root-|side-|gui-]-xcode-: one family of labels on the same machines. OWNED_FAMILY = re.compile(r"^glaeda-(?:root-|side-|gui-)?(?P(?:std|light|xl)-xcode-[0-9]+(?:\.[0-9]+)*)$") XCODE_VERSION = re.compile(r"(?:^|/)Xcode_(?P[0-9]+(?:\.[0-9]+)*)\.app(?:/|$)") +# Ten pages of 100 is twice the organization's runners today. +MAX_RUNNER_PAGES = 10 def owned_family(label: str) -> str: @@ -172,10 +174,15 @@ def _get(self, path: str) -> Any: def runners(self) -> list[Mapping[str, Any]]: owner = self.repository.split("/", 1)[0] - # Keep this to one bounded read. The organization has fewer than one - # page of routing runners; a partial response is safer than spending - # the shared Actions API quota on pagination for every run. - return self._get(f"/orgs/{owner}/actions/runners?per_page=100").get("runners") or [] + # The routing runners can sit behind several pages, so read all pages + # that can contain the current fleet while keeping the API usage bounded. + runners: list[Mapping[str, Any]] = [] + for page in range(1, MAX_RUNNER_PAGES + 1): + batch = self._get(f"/orgs/{owner}/actions/runners?per_page=100&page={page}").get("runners") or [] + runners.extend(batch) + if len(batch) < 100: + break + return runners def snapshot(self) -> Mapping[str, Any] | None: """The queue janitor's newest trusted pool snapshot, or None when missing or stale (two API calls).""" From 7876bb9bc5b29d2c57926ac242fe65fe2d0523a3 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:50:31 -0700 Subject: [PATCH 54/56] fix: wire browser repl CLI sources --- cmux.xcodeproj/project.pbxproj | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e9126494d8a8..ef884c7bce44 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1197,6 +1197,7 @@ 6D9C51AB30A64B1ABC819144 /* CMUXCLI+AutoNamingHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211168 /* CMUXCLI+AutoNamingHooks.swift */; }; 6D9C51AB30A64B1ABC819145 /* CMUXCLI+AutoNamingSummarizers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211169 /* CMUXCLI+AutoNamingSummarizers.swift */; }; A12451000000000000000002 /* CMUXCLI+BrowserDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12451000000000000000001 /* CMUXCLI+BrowserDownload.swift */; }; + DF42531B26A5E149BC263C79 /* CMUXCLI+BrowserRepl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA23FD5F4147585610132E4 /* CMUXCLI+BrowserRepl.swift */; }; 8056A0000000000000000002 /* CMUXCLI+BrowserViewport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8056A0000000000000000001 /* CMUXCLI+BrowserViewport.swift */; }; B9000074A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000075A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift */; }; B9000078A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtensionInstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000079A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtensionInstall.swift */; }; @@ -1333,6 +1334,7 @@ CC0033E15A1B2C3D4E5F0020 /* CMUXCLI+VMTui.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0021 /* CMUXCLI+VMTui.swift */; }; CC0033E15A1B2C3D4E5F0032 /* CMUXCLI+VMTuiWorkspaceNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0033 /* CMUXCLI+VMTuiWorkspaceNaming.swift */; }; C8923639133F158AF1B6A0FB /* CMUXCLI+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA475616EA96D1D0624FC0A1 /* CMUXCLI+VPN.swift */; }; + FF1B913DC56F41178FA0895A /* CMUXCLI+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0226FF4BE571A2BA5B5AB16 /* CMUXCLI+VPN.swift */; }; 7A0CE1000000000000000102 /* CMUXCLI+VPNAppManaged.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000101 /* CMUXCLI+VPNAppManaged.swift */; }; F0316A711615C3D2D3008A82 /* CMUXCLI+WindowDispatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64B5058972147F941BD6FA5 /* CMUXCLI+WindowDispatch.swift */; }; 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */; }; @@ -5708,6 +5710,7 @@ 5257257034CA4729B1211168 /* CMUXCLI+AutoNamingHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNamingHooks.swift"; sourceTree = ""; }; 5257257034CA4729B1211169 /* CMUXCLI+AutoNamingSummarizers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNamingSummarizers.swift"; sourceTree = ""; }; A12451000000000000000001 /* CMUXCLI+BrowserDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+BrowserDownload.swift"; sourceTree = ""; }; + 6EA23FD5F4147585610132E4 /* CMUXCLI+BrowserRepl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+BrowserRepl.swift"; sourceTree = ""; }; 8056A0000000000000000001 /* CMUXCLI+BrowserViewport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+BrowserViewport.swift"; sourceTree = ""; }; B9000075A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CampfireExtension.swift"; sourceTree = ""; }; B9000079A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtensionInstall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CampfireExtensionInstall.swift"; sourceTree = ""; }; @@ -5844,6 +5847,7 @@ CC0033E15A1B2C3D4E5F0021 /* CMUXCLI+VMTui.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VMTui.swift"; sourceTree = ""; }; CC0033E15A1B2C3D4E5F0033 /* CMUXCLI+VMTuiWorkspaceNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VMTuiWorkspaceNaming.swift"; sourceTree = ""; }; EA475616EA96D1D0624FC0A1 /* CMUXCLI+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VPN.swift"; sourceTree = ""; }; + F0226FF4BE571A2BA5B5AB16 /* CMUXCLI+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VPN.swift"; sourceTree = ""; }; 7A0CE1000000000000000101 /* CMUXCLI+VPNAppManaged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VPNAppManaged.swift"; sourceTree = ""; }; A64B5058972147F941BD6FA5 /* CMUXCLI+WindowDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WindowDispatch.swift"; sourceTree = ""; }; 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WorkspaceTodo.swift"; sourceTree = ""; }; @@ -12152,6 +12156,8 @@ 57AE38B536A608CB2CDEE6AD /* CMUXCLI+DiffViewerReviewLabels.swift */, 5A2776CC5FE1E0083FAAB3A8 /* VMReadyPollInterval.swift */, 5B85EF1F8C40AF5C89F442A8 /* CMUXCLI+AgentMessageSettings.swift */, + 6EA23FD5F4147585610132E4 /* CMUXCLI+BrowserRepl.swift */, + F0226FF4BE571A2BA5B5AB16 /* CMUXCLI+VPN.swift */, ); path = CLI; sourceTree = ""; @@ -16882,6 +16888,7 @@ 6D9C51AB30A64B1ABC819144 /* CMUXCLI+AutoNamingHooks.swift in Sources */, 6D9C51AB30A64B1ABC819145 /* CMUXCLI+AutoNamingSummarizers.swift in Sources */, A12451000000000000000002 /* CMUXCLI+BrowserDownload.swift in Sources */, + DF42531B26A5E149BC263C79 /* CMUXCLI+BrowserRepl.swift in Sources */, 8056A0000000000000000002 /* CMUXCLI+BrowserViewport.swift in Sources */, B9000074A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift in Sources */, B9000078A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtensionInstall.swift in Sources */, @@ -17015,9 +17022,11 @@ 21D721AF457D40CD877B8D01 /* CMUXCLI+VMSCP.swift in Sources */, C5C4D27C4078FD2D55679132 /* CMUXCLI+VMSelf.swift in Sources */, 8CC4B7E07376E56BD8A1369D /* CMUXCLI+VMSnapshots.swift in Sources */, + CC0033E15A1B2C3D4E5F0101 /* CMUXCLI+VMTransfer.swift in Sources */, CC0033E15A1B2C3D4E5F0020 /* CMUXCLI+VMTui.swift in Sources */, CC0033E15A1B2C3D4E5F0032 /* CMUXCLI+VMTuiWorkspaceNaming.swift in Sources */, C8923639133F158AF1B6A0FB /* CMUXCLI+VPN.swift in Sources */, + FF1B913DC56F41178FA0895A /* CMUXCLI+VPN.swift in Sources */, 7A0CE1000000000000000102 /* CMUXCLI+VPNAppManaged.swift in Sources */, F0316A711615C3D2D3008A82 /* CMUXCLI+WindowDispatch.swift in Sources */, 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */, From bf7d1b5e3ac1401e8ed4e5be1bacafc393516705 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:37:16 -0700 Subject: [PATCH 55/56] fix(cloud): preserve connecting machine controls --- Sources/Cloud/CloudPortsDiscoveryDemand.swift | 4 ++++ Sources/Cloud/CloudTreeNode.swift | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/Sources/Cloud/CloudPortsDiscoveryDemand.swift b/Sources/Cloud/CloudPortsDiscoveryDemand.swift index de8494e0fff8..cc0c67f72124 100644 --- a/Sources/Cloud/CloudPortsDiscoveryDemand.swift +++ b/Sources/Cloud/CloudPortsDiscoveryDemand.swift @@ -28,6 +28,10 @@ final class CloudPortsDiscoveryDemand { func reconcile(coordinator: CloudTreeOutlineView.Coordinator) { guard let outline = coordinator.outlineView, outline.window != nil else { return } + // A snapshot can be applied before AppKit has performed the containing + // view's first layout pass. Materialize row geometry before checking + // the visibility boundary so an already visible machine is not missed. + outline.layoutSubtreeIfNeeded() for root in candidates where !requested.contains(root.machine) { // The machine row is the visibility boundary. Port discovery is a // lightweight cached scan, and waiting for a detail tab to open diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index a60d3222e7a0..a87c52ece5b8 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -904,6 +904,13 @@ enum CloudTreeNodeBuilder { // snapshot, so device rows carry no Resources group. if let machineSnapshot { children.append(resourceNodeBuilder.groupNode(machine: machine, snapshot: machineSnapshot, now: now)) + } else if info?.linkState == .connecting { + // Keep the stable Resources control while a machine reconnects. + // Telemetry is unavailable until the VM snapshot arrives, so the + // group is intentionally empty and does not imply stale readings. + let placeholder = MachineSnapshot( + id: machine.rawValue, provider: "", image: "", isDesktop: false, activity: .pending) + children.append(resourceNodeBuilder.groupNode(machine: machine, snapshot: placeholder, now: now)) } return children } From 8661ff6951dd1a5505c57e75f12425dbf9c7933d Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:59:07 -0700 Subject: [PATCH 56/56] chore: trim unrelated localization changes --- Resources/Localizable.xcstrings | 236 -------------------------------- 1 file changed, 236 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b47c61eea4f6..c25b96cf413d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -623606,242 +623606,6 @@ } } } - }, - "machines.empty.none": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No cloud machines yet" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا توجد أجهزة سحابية بعد" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Noch keine Cloud-Maschinen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aún no hay máquinas en la nube" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Aucune machine cloud pour le moment" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クラウドマシンはまだありません" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "아직 클라우드 머신이 없습니다" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "尚无云端机器" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "尚無雲端機器" - } - } - } - }, - "rightSidebar.openAsPane.unavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ can't open as a pane yet" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا يمكن فتح %@ كجزء بعد" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ kann noch nicht als Teilfenster geöffnet werden" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ aún no se puede abrir como panel" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ ne peut pas encore s'ouvrir en tant que volet" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@はまだペインで開けません" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@은(는) 아직 분할 창으로 열 수 없습니다" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 暂时无法以窗格打开" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 尚無法開啟為窗格" - } - } - } - }, - "cloudTree.action.refreshCloudMachines": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Refresh Cloud Machines" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Maschinen aktualisieren" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Actualiser les machines Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تحديث الأجهزة السحابية" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Actualizar máquinas Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重新整理雲端機器" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "刷新云端机器" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "클라우드 머신 새로고침" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud マシンを再読み込み" - } - } - } - }, - "machines.pending.upgrade": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Upgrade Plan" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "ترقية الخطة" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Tarif upgraden" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Actualizar plan" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Passer au forfait supérieur" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プランをアップグレード" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "플랜 업그레이드" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "升级套餐" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "升級方案" - } - } - } } }, "version": "1.0"