Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e05a8bc
test: cover Cloud row hover text and the machine row's injected clock
teamleaderleo Sep 28, 2026
6d120a1
fix: give every Cloud row reachable hover text and one description path
teamleaderleo Sep 28, 2026
7d06ae9
fix: keep a Cloud row's hover text to what the row does not already say
teamleaderleo Sep 28, 2026
c492a7f
test(cloud): a machine row's id and age never reach assistive technology
teamleaderleo Sep 28, 2026
2c0914b
fix(cloud): say a machine's id and age in its accessibility label
teamleaderleo Sep 28, 2026
aeed570
Merge main (0c753fe28dfd) into cloud-sidebar-machine-age
teamleaderleo Sep 28, 2026
74fc91d
test(cloud): hover text that repeats the row, and a blank line in a m…
teamleaderleo Sep 28, 2026
3831b2a
fix(cloud): stop a row's hover text repeating the row, and close a bl…
teamleaderleo Sep 28, 2026
ec157ab
Merge remote-tracking branch 'upstream/main' into cloud-sidebar-machi…
teamleaderleo Sep 28, 2026
31f3347
test(cloud): an untitled terminal loses its accessible fallback
teamleaderleo Sep 28, 2026
58a07bb
fix(cloud): keep untitled terminals accessible
teamleaderleo Sep 28, 2026
676e9ed
Merge remote-tracking branch 'upstream/main' into cloud-sidebar-machi…
teamleaderleo Sep 28, 2026
32a567e
test(cloud): separate terminal titles from hover details
teamleaderleo Sep 28, 2026
47b743b
fix(cloud): lead terminal accessibility with its title
teamleaderleo Sep 28, 2026
32c4aef
refactor(cloud): split row description types by file
teamleaderleo Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 6 additions & 31 deletions Sources/Cloud/CloudTreeCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,32 +166,6 @@ final class CloudTreeCellView: NSTableCellView {
buttonsHost?.isHidden = true
buttonsLeadingConstraint?.isActive = false
}
if case .machine(let machine, _) = node.kind {
toolTip = CloudTreeMachineRowContent(machine: machine, style: style, resources: node.resourceSection).toolTip
} else if case .pendingMachine(let operation) = node.kind {
// The failure's first line rides along so a red row explains itself on hover.
toolTip = operation.summaryLine
} else if case .localMachine(let row) = node.kind {
toolTip = row.name
} else if case .device(let row) = node.kind {
// Full status and counts: the row itself carries only a dim fact.
toolTip = CloudTreeDeviceRowContent(row: row, style: style).toolTip
} else {
toolTip = nil
}
if case .machine(let machine, _) = node.kind {
setAccessibilityLabel(CloudTreeMachineRowContent(machine: machine, style: style, resources: node.resourceSection).accessibilityLabel)
} else if case .device(let row) = node.kind {
setAccessibilityLabel(CloudTreeDeviceRowContent(row: row, style: style).accessibilityLabel)
} else if case .resource(_, let row) = node.kind {
setAccessibilityLabel(row.accessibilityLabel)
} else if case .terminal(let row) = node.kind {
setAccessibilityLabel(CloudTreeTerminalRowContent(row: row, style: style).toolTip)
} else if case .display(let resource, _, _) = node.kind {
setAccessibilityLabel([node.searchableTitle, CloudTreeRowContentView.text(for: resource)].joined(separator: ", "))
} else {
setAccessibilityLabel(node.searchableTitle)
}
updatePresenceSubscription()
}

Expand All @@ -200,11 +174,12 @@ final class CloudTreeCellView: NSTableCellView {
guard case .workspace(let machine, let workspace, _, _, _) = node.kind else { return [] }
return collaborators(machine, workspace.id)
}()
if case .workspace = node.kind {
let names = WorkspacePresencePolicy.accessibilityLabel(presenceHeads)
toolTip = presenceHeads.isEmpty ? nil : names
setAccessibilityLabel(presenceHeads.isEmpty ? node.searchableTitle : "\(node.searchableTitle), \(names)")
}
// The one place that assigns hover text and the accessibility label.
// Both used to be written twice, here and again in `configure`, and the
// second pass reset a workspace row's presence tooltip to nil.
let description = CloudTreeRowToolTip.describe(node: node, style: style, presenceHeads: presenceHeads)
toolTip = description.toolTip
setAccessibilityLabel(description.accessibilityLabel)
displayHost.rootView = AnyView(
CloudTreeRowContentView(kind: node.kind, presenceHeads: presenceHeads, style: style, resources: node.resourceSection)
.modifier(CloudSidebarRowDecoration(isPinned: node.isPinned, showsAttentionSlot: node.showsAttentionSlot, hasUnreadNotification: node.hasUnreadAttention, attentionSlot: style.rowGrid.attentionSlot))
Expand Down
22 changes: 20 additions & 2 deletions Sources/Cloud/CloudTreeMachineRowContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,16 @@ struct CloudTreeMachineRowContent: View {
}

/// Combines this machine's identity, activity, and resource readings for assistive technology.
///
/// `subtitle` in the same position the tooltip puts it: the default preset
/// is single-line, so the id and the created-at are not rendered anywhere
/// and the pointer only reaches them by hovering. Assistive technology has
/// no pointer, so without this the row says less to the people who have the
/// least other way to get it. `subtitle` always has at least the kind, so
/// there is no empty component to filter.
var accessibilityLabel: String {
var parts = [machine.displayName, machine.activityLabel, metrics.summary]
parts.append(subtitle)
parts.append(usageSummary)
return parts.joined(separator: ", ")
}
Expand All @@ -74,7 +82,13 @@ struct CloudTreeMachineRowContent: View {
lines.append(subtitle)
lines.append(machine.image)
lines.append(usageSummary)
return lines.joined(separator: "\n")
// A machine the catalog found before the fleet list named it is built
// with `image: info.image ?? ""`, and an empty line in the middle of a
// popup reads as a missing fact rather than an absent one.
return lines
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n")
}

/// A missing backend report remains visible instead of looking like a removed feature.
Expand Down Expand Up @@ -130,7 +144,11 @@ struct CloudTreeMachineRowContent: View {
}
parts.append(machine.kindLabel)
if let createdAt = machine.createdAt {
parts.append(Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date()))
// `now`, not `Date()`: every other part of this struct reads the
// injected clock, so the age was the one value a test could not
// pin. Both shipping call sites leave `now` at its default, so
// this changes no rendered text today.
parts.append(Self.relativeFormatter.localizedString(for: createdAt, relativeTo: now))
}
if machine.freeAccess == .expired {
parts.append(String(localized: "machines.row.locked", defaultValue: "Locked"))
Expand Down
15 changes: 12 additions & 3 deletions Sources/Cloud/CloudTreeRowContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,11 @@ struct CloudTreeTerminalRowContent: View {
var style: CloudTreeStyle = CloudTreeStyleStore.current

private var terminal: SurfaceResource { row.resource }
private var resolvedTitle: String {
row.displayTitle.isEmpty
? String(localized: "cloudTree.terminal.untitled", defaultValue: "terminal")
: row.displayTitle
}

/// Detached styling is reserved for a live terminal whose resolved daemon
/// view list is empty. A stale exited record can have the same empty list,
Expand All @@ -341,17 +346,21 @@ struct CloudTreeTerminalRowContent: View {
icon: glyph,
tint: CloudTreeIconPalette.terminal,
iconAsset: terminal.terminalAgentIconAssetName,
title: row.displayTitle.isEmpty ? String(localized: "cloudTree.terminal.untitled", defaultValue: "terminal") : row.displayTitle,
title: resolvedTitle,
titleDimmed: terminal.lifecycle == .exited || showsDetachedState
)
.help(toolTip)
.accessibilityElement(children: .ignore)
.accessibilityLabel(toolTip)
.accessibilityLabel(accessibilityLabel)
}

var accessibilityLabel: String {
[resolvedTitle, toolTip].filter { !$0.isEmpty }.joined(separator: "\n")
}

/// Keep secondary information on hover so the narrow row gives its width to the title.
var toolTip: String {
var details = [row.displayTitle, row.directoryHelp, agentLabel].compactMap { $0 }
var details = [row.directoryHelp, agentLabel].compactMap { $0 }
if showsDetachedState {
details.append(String(localized: "cloudTree.terminal.detached.help", defaultValue: "Still running on the machine, but no tab shows it. Click to open it in a pane; right-click to kill it."))
} else if let views = Self.multiplierBadge(row.viewBadge) {
Expand Down
14 changes: 14 additions & 0 deletions Sources/Cloud/CloudTreeRowDescription.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// The hover text and the assistive-technology label for one Cloud row.
///
/// Both belong to the cell rather than to the hosted SwiftUI content.
/// `CloudTreePassthroughHostingView` returns nil from `hitTest` so the outline
/// owns every pointer event, which also means a `.help()` inside a row view
/// never receives the hover that would show it. Rows that kept their secondary
/// information "on hover" had no way to present it to a pointer; computing it
/// here and letting `CloudTreeCellView` assign `toolTip` gives every row kind
/// one tooltip path and one accessibility path.
struct CloudTreeRowDescription: Equatable {
/// nil leaves the cell without hover text: short, fixed group labels.
let toolTip: String?
let accessibilityLabel: String
}
131 changes: 131 additions & 0 deletions Sources/Cloud/CloudTreeRowToolTip.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import CmuxCloud
import CmuxSurfaceCatalogModel
import CmuxWorkspacePresence
import Foundation

enum CloudTreeRowToolTip {
/// Hover text and accessibility label for `node`, including the presence
/// heads the cell resolved for a workspace row.
@MainActor
static func describe(
node: CloudTreeNode,
style: CloudTreeStyle,
presenceHeads: [WorkspacePresenceParticipant]
) -> CloudTreeRowDescription {
switch node.kind {
case .machine(let machine, _):
let content = CloudTreeMachineRowContent(machine: machine, style: style, resources: node.resourceSection)
return .init(toolTip: content.toolTip, accessibilityLabel: content.accessibilityLabel)
case .pendingMachine(let operation):
// The failure's first line rides along so a red row explains itself on hover.
return .init(toolTip: operation.summaryLine, accessibilityLabel: node.searchableTitle)
case .localMachine(let row):
return .init(toolTip: row.name, accessibilityLabel: node.searchableTitle)
case .device(let row):
// Full status and counts: the row itself carries only a dim fact.
let content = CloudTreeDeviceRowContent(row: row, style: style)
return .init(toolTip: content.toolTip, accessibilityLabel: content.accessibilityLabel)
case .workspace(_, let workspace, let terminalCount, _, _):
let lines = workspaceLines(workspace, terminalCount: terminalCount, presenceHeads: presenceHeads)
let names = WorkspacePresencePolicy.accessibilityLabel(presenceHeads)
return .init(
toolTip: joined(lines, beyond: node.searchableTitle),
accessibilityLabel: presenceHeads.isEmpty
? node.searchableTitle
: "\(node.searchableTitle), \(names)"
)
case .localWorkspace(let row):
return .init(
toolTip: joined([row.title], beyond: node.searchableTitle),
accessibilityLabel: node.searchableTitle
)
case .terminal(let row):
let content = CloudTreeTerminalRowContent(row: row, style: style)
return .init(
toolTip: content.toolTip.isEmpty ? nil : content.toolTip,
accessibilityLabel: content.accessibilityLabel
)
case .display(let resource, _, _):
// `searchableTitle` already resolves the remote view name, the
// resource title and the "Desktop" fallback in that order.
//
// No `beyond:` here, unlike the browser and port cases: `text(for:)`
// always returns at least the transport ("noVNC"), so a display's
// hover text can never reduce to the title the row drew.
let detail = CloudTreeRowContentView.text(for: resource)
return .init(
toolTip: joined([node.searchableTitle, detail]),
accessibilityLabel: [node.searchableTitle, detail].joined(separator: ", ")
)
case .browser(let row):
// An untitled browser's `searchableTitle` is the empty resource
// title, which would leave the row unlabelled for VoiceOver.
let title = row.resource.title.isEmpty
? String(localized: "cloudTree.browser.untitled", defaultValue: "browser")
: row.resource.title
return .init(
// `beyond: title`, not `beyond: node.searchableTitle`: for an
// untitled browser that is the empty resource title, while the
// row draws the same "browser" resolved above. The comparison
// has to be against what the row draws.
toolTip: joined([title, row.resource.url, CloudTreeBrowserDetail.text(for: row)], beyond: title),
accessibilityLabel: title
)
case .port(let resource, let url, _):
return .init(
toolTip: joined([url, resource.title, resource.detail], beyond: node.searchableTitle),
accessibilityLabel: node.searchableTitle
)
case .resource(_, let row):
return .init(
toolTip: joined([row.accessibilityLabel], beyond: node.searchableTitle),
accessibilityLabel: row.accessibilityLabel
)
case .placeholder(_, let placeholder):
return .init(
toolTip: joined([placeholder.text], beyond: node.searchableTitle),
accessibilityLabel: node.searchableTitle
)
case .terminalsPool, .displaysPool, .workspacesGroup, .browsersGroup, .portsGroup,
.resourcesPool, .devicesSection, .cloudMachinesSection, .devicesEmpty:
// Fixed section labels: they never truncate, so hover text would only
// repeat what the row already reads. `.devicesEmpty` never reaches a
// `CloudTreeCellView`, it has its own cell class; it is here so the
// switch stays exhaustive over `Kind`.
return .init(toolTip: nil, accessibilityLabel: node.searchableTitle)
}
}

/// Identity, size and occupancy for a cmux-tui workspace. A workspace row
/// otherwise shows only a name the machine generated, with no way to tell
/// two of them apart.
private static func workspaceLines(
_ workspace: SurfaceRemoteWorkspace,
terminalCount: Int,
presenceHeads: [WorkspacePresenceParticipant]
) -> [String?] {
var lines: [String?] = [workspace.name, workspace.detail]
// "0 terminals" is not occupancy, it is the absence of it, and the row
// already reads as empty. Only a count worth knowing earns a line.
if terminalCount > 0 {
lines.append(CloudTreeRowContentView.count(terminalCount))
}
if !presenceHeads.isEmpty {
lines.append(WorkspacePresencePolicy.accessibilityLabel(presenceHeads))
}
return lines
}

/// One tooltip line per fact, dropping empties. nil when nothing is left,
/// and nil when everything left is `title`: a popup that repeats the row's
/// own text tells the pointer nothing and covers the rows under it.
private static func joined(_ lines: [String?], beyond title: String? = nil) -> String? {
let text = lines
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n")
if text.isEmpty { return nil }
if text == title?.trimmingCharacters(in: .whitespacesAndNewlines) { return nil }
return text
}
}
Loading
Loading