diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 233956a29dc3..04238852b4d3 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -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() } @@ -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)) diff --git a/Sources/Cloud/CloudTreeMachineRowContent.swift b/Sources/Cloud/CloudTreeMachineRowContent.swift index 31a24b3ec904..ba605b3b6a49 100644 --- a/Sources/Cloud/CloudTreeMachineRowContent.swift +++ b/Sources/Cloud/CloudTreeMachineRowContent.swift @@ -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: ", ") } @@ -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. @@ -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")) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 524184f656f4..ebd645b17edc 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -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, @@ -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) { diff --git a/Sources/Cloud/CloudTreeRowDescription.swift b/Sources/Cloud/CloudTreeRowDescription.swift new file mode 100644 index 000000000000..371a448b3b67 --- /dev/null +++ b/Sources/Cloud/CloudTreeRowDescription.swift @@ -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 +} diff --git a/Sources/Cloud/CloudTreeRowToolTip.swift b/Sources/Cloud/CloudTreeRowToolTip.swift new file mode 100644 index 000000000000..9663afa3f3ee --- /dev/null +++ b/Sources/Cloud/CloudTreeRowToolTip.swift @@ -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 + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cea49d509400..e9854da47fd5 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -907,9 +907,12 @@ EEF4BF6D13124E43BC712704 /* CloudTreeRemoteWorkspaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 952C5F710BB34A9AA8652CEF /* CloudTreeRemoteWorkspaces.swift */; }; D12406000000000000000002 /* CloudTreeResizeMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12406000000000000000001 /* CloudTreeResizeMenu.swift */; }; CF30E2713423E3919EAE5C73 /* CloudTreeRowContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53075CD5172053B8F994DA2B /* CloudTreeRowContentView.swift */; }; + C15326000000000000000003 /* CloudTreeRowDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15326000000000000000001 /* CloudTreeRowDescription.swift */; }; 46B69D1B0F705975988FFF7F /* CloudTreeRowHeight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 333CFD2BE68E128395477836 /* CloudTreeRowHeight.swift */; }; E46FF9A1C016449BB38FF7BC /* CloudTreeRowHoverButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15190300163941E780877031 /* CloudTreeRowHoverButtons.swift */; }; FA1253800000000000000005 /* CloudTreeRowIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1253800000000000000006 /* CloudTreeRowIcon.swift */; }; + C15326000000000000000004 /* CloudTreeRowToolTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15326000000000000000002 /* CloudTreeRowToolTip.swift */; }; + 2BEB2760152A81D7F27D99A8 /* CloudTreeRowToolTipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C38B9AFEEE4FAE6089C250F /* CloudTreeRowToolTipTests.swift */; }; A12481200000000000000001 /* CloudTreeRowUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12481200000000000000002 /* CloudTreeRowUpdate.swift */; }; DEABB0C438031ADB363DE082 /* CloudTreeRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB22532809A3E370F5EE466 /* CloudTreeRowView.swift */; }; C16B6FB2E23E52FB6F4FA4A7 /* CloudTreeStyleGalleryWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E4DB2770E4B0CBF4C388E5 /* CloudTreeStyleGalleryWindow.swift */; }; @@ -5093,9 +5096,12 @@ 952C5F710BB34A9AA8652CEF /* CloudTreeRemoteWorkspaces.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeRemoteWorkspaces.swift; sourceTree = ""; }; D12406000000000000000001 /* CloudTreeResizeMenu.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeResizeMenu.swift; sourceTree = ""; }; 53075CD5172053B8F994DA2B /* CloudTreeRowContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeRowContentView.swift; sourceTree = ""; }; + C15326000000000000000001 /* CloudTreeRowDescription.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeRowDescription.swift; sourceTree = ""; }; 333CFD2BE68E128395477836 /* CloudTreeRowHeight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeRowHeight.swift"; sourceTree = ""; }; 15190300163941E780877031 /* CloudTreeRowHoverButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeRowHoverButtons.swift; sourceTree = ""; }; FA1253800000000000000006 /* CloudTreeRowIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeRowIcon.swift; sourceTree = ""; }; + C15326000000000000000002 /* CloudTreeRowToolTip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeRowToolTip.swift; sourceTree = ""; }; + 1C38B9AFEEE4FAE6089C250F /* CloudTreeRowToolTipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeRowToolTipTests.swift"; sourceTree = ""; }; 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 = ""; }; 46E4DB2770E4B0CBF4C388E5 /* CloudTreeStyleGalleryWindow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeStyleGalleryWindow.swift; sourceTree = ""; }; @@ -8818,6 +8824,8 @@ 5F0E1505C0F4E87D29858B72 /* CloudTreeDevicesEmptyCell.swift */, DEC1CE000000000000000035 /* CloudTreeDeviceRowContent.swift */, 53075CD5172053B8F994DA2B /* CloudTreeRowContentView.swift */, + C15326000000000000000001 /* CloudTreeRowDescription.swift */, + C15326000000000000000002 /* CloudTreeRowToolTip.swift */, 4EF7BCD47CC347BC8A5660AD /* CloudSidebarOrganizationStore.swift */, EA34C8F7DABB44E193EF9294 /* CloudSidebarOrganizationTree.swift */, F5DCB7412E584AB1AAA90BA5 /* CloudSidebarRowDecoration.swift */, @@ -12359,6 +12367,7 @@ 3D5E43A566AB0B441162EC5A /* CloudTreeNodeCacheTests.swift */, 23E35F95320CBED308AD98CF /* CloudTreeTogglePerformanceTests.swift */, 0C37EA14DE74095FB1BB23E1 /* CloudTreeDisclosureIntentTests.swift */, + 1C38B9AFEEE4FAE6089C250F /* CloudTreeRowToolTipTests.swift */, 6F27EA9972F21B98FB4A7312 /* SurfaceCatalogObservationTests.swift */, DEAB02E73CAA231F5E266CF7 /* ClaudeStopFailureStatusTests.swift */, 5D45948E0E0E282796BE8936 /* NotificationSuppressWhenAppFocusedTests.swift */, @@ -13611,9 +13620,11 @@ EEF4BF6D13124E43BC712704 /* CloudTreeRemoteWorkspaces.swift in Sources */, D12406000000000000000002 /* CloudTreeResizeMenu.swift in Sources */, CF30E2713423E3919EAE5C73 /* CloudTreeRowContentView.swift in Sources */, + C15326000000000000000003 /* CloudTreeRowDescription.swift in Sources */, 46B69D1B0F705975988FFF7F /* CloudTreeRowHeight.swift in Sources */, E46FF9A1C016449BB38FF7BC /* CloudTreeRowHoverButtons.swift in Sources */, FA1253800000000000000005 /* CloudTreeRowIcon.swift in Sources */, + C15326000000000000000004 /* CloudTreeRowToolTip.swift in Sources */, A12481200000000000000001 /* CloudTreeRowUpdate.swift in Sources */, DEABB0C438031ADB363DE082 /* CloudTreeRowView.swift in Sources */, C16B6FB2E23E52FB6F4FA4A7 /* CloudTreeStyleGalleryWindow.swift in Sources */, @@ -16244,6 +16255,7 @@ C986A0030000000000000001 /* CloudTreeNativeDragOwnershipTests.swift in Sources */, B3D054B84DA1EEBD9DD499A2 /* CloudTreeNodeCacheTests.swift in Sources */, 45F29E8BB64E4EE4BF204AC7 /* CloudTreeOneMachineManyWorkspacesTests.swift in Sources */, + 2BEB2760152A81D7F27D99A8 /* CloudTreeRowToolTipTests.swift in Sources */, 71DB787610BE94EC84EDA5F4 /* CloudTreeTogglePerformanceTests.swift in Sources */, A12367000000000000000002 /* CloudTreeWorkspaceTitleLayoutTests.swift in Sources */, A838AA211BDA47268C683501 /* CloudTuiManualIOConnectionTests.swift in Sources */, diff --git a/cmuxTests/CloudTreeRowToolTipTests.swift b/cmuxTests/CloudTreeRowToolTipTests.swift new file mode 100644 index 000000000000..0784550af1f0 --- /dev/null +++ b/cmuxTests/CloudTreeRowToolTipTests.swift @@ -0,0 +1,454 @@ +import AppKit +import CmuxCloud +import CmuxSurfaceCatalogModel +import CmuxWorkspacePresence +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Hover text belongs to the cell, not to the hosted SwiftUI content: the +/// display host never hit-tests, so a `.help()` inside a row view can never be +/// reached by a pointer. These cover the rows whose secondary information was +/// only ever attached that way, plus the workspace row whose presence tooltip +/// was written and then reset inside the same `configure` call. +@MainActor +@Suite("Cloud rows carry their hover text on the cell", .serialized) +struct CloudTreeRowToolTipTests { + @Test("A workspace row off-window still describes itself") + func workspaceRowHasToolTip() throws { + let node = Self.workspaceNode() + let cell = Self.cell(presence: []) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + let toolTip = try #require(cell.toolTip) + #expect(toolTip.contains("api-refactor")) + #expect(toolTip.contains("~/src/api")) + #expect(toolTip.contains(CloudTreeRowContentView.count(3))) + } + + @Test("A workspace row lists its collaborators without dropping its own name") + func workspaceRowKeepsPresenceAndName() throws { + let node = Self.workspaceNode() + let cell = Self.cell(presence: [Self.participant(name: "Robin")]) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + let toolTip = try #require(cell.toolTip) + #expect(toolTip.contains("api-refactor")) + #expect(toolTip.contains("Robin")) + #expect(cell.accessibilityLabel()?.contains("Robin") == true) + } + + @Test("A workspace with nothing to add beyond its name has no hover text") + func bareWorkspaceRowHasNoToolTip() { + let node = CloudTreeNode( + id: "workspace/tooltip-test/ws-bare", + kind: .workspace( + machine: .cloud("tooltip-test"), + SurfaceRemoteWorkspace(id: "ws-bare", name: "workspace 2", index: 1, focused: false), + terminalCount: 0, + hiddenTabCount: 0, + openIn: nil + ) + ) + let cell = Self.cell(presence: []) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + #expect(cell.toolTip == nil) + #expect(cell.accessibilityLabel() == "workspace 2") + } + + @Test("A placeholder row does not pop its own text back at the pointer") + func placeholderRowHasNoToolTip() { + let node = CloudTreeNode( + id: "placeholder/tooltip-test/ports", + kind: .placeholder( + machine: .cloud("tooltip-test"), + CloudTreePlaceholder(text: "No forwarded ports", style: .dimmed) + ) + ) + let cell = Self.cell(presence: []) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + #expect(cell.toolTip == nil) + } + + @Test("A terminal row's directory and agent reach the pointer") + func terminalRowHasToolTip() throws { + let node = Self.terminalNode() + let cell = Self.cell(presence: []) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + let toolTip = try #require(cell.toolTip) + let expected = CloudTreeTerminalRowContent(row: Self.terminalRow(), style: CloudTreeStyleStore.current).toolTip + #expect(!expected.isEmpty) + #expect(toolTip == expected) + } + + @Test("An untitled terminal says its fallback before its secondary facts") + func untitledTerminalRowIsLabelled() { + let cell = Self.cell(presence: []) + cell.configure( + node: Self.terminalNode(title: "", machine: .local, detail: "/tmp/work"), + machineActions: Self.machineActions(), + nodeActions: Self.nodeActions() + ) + #expect(cell.toolTip == "/tmp/work") + #expect(cell.accessibilityLabel() == "terminal\n/tmp/work") + } + + @Test("A titled terminal with no secondary facts has no redundant hover text") + func bareTitledTerminalRowHasNoToolTip() { + let cell = Self.cell(presence: []) + cell.configure( + node: Self.terminalNode(title: "build", machine: .local), + machineActions: Self.machineActions(), + nodeActions: Self.nodeActions() + ) + #expect(cell.toolTip == nil) + #expect(cell.accessibilityLabel() == "build") + } + + @Test("A titled terminal keeps its secondary facts in hover text and accessibility") + func titledTerminalRowKeepsDetails() { + let cell = Self.cell(presence: []) + cell.configure( + node: Self.terminalNode(title: "build", machine: .local, detail: "/tmp/work"), + machineActions: Self.machineActions(), + nodeActions: Self.nodeActions() + ) + #expect(cell.toolTip == "/tmp/work") + #expect(cell.accessibilityLabel() == "build\n/tmp/work") + } + + @Test("A display row names its transport on hover") + func displayRowHasToolTip() throws { + let node = Self.displayNode() + let cell = Self.cell(presence: []) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + let toolTip = try #require(cell.toolTip) + #expect(toolTip.contains(":1")) + } + + @Test("A port row's full link survives a truncated title") + func portRowHasToolTip() throws { + let node = Self.portNode() + let cell = Self.cell(presence: []) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + let toolTip = try #require(cell.toolTip) + #expect(toolTip.contains("http://10.0.0.4:3000")) + } + + @Test("An untitled browser row is still labelled for assistive technology") + func untitledBrowserRowIsLabelled() throws { + let node = Self.browserNode(title: "") + let cell = Self.cell(presence: []) + cell.configure(node: node, machineActions: Self.machineActions(), nodeActions: Self.nodeActions()) + #expect(cell.accessibilityLabel()?.isEmpty == false) + } + + @Test("Section headers keep their bare label", arguments: ["workspaces", "terminals", "ports"]) + func sectionHeadersHaveNoToolTip(kindName: String) { + let machine = SurfaceMachineID.cloud("tooltip-test") + let kind: CloudTreeNode.Kind = switch kindName { + case "workspaces": .workspacesGroup(machine: machine) + case "terminals": .terminalsPool(machine: machine, count: 2) + default: .portsGroup(machine: machine) + } + let cell = Self.cell(presence: []) + cell.configure( + node: CloudTreeNode(id: kindName, kind: kind), + machineActions: Self.machineActions(), + nodeActions: Self.nodeActions() + ) + #expect(cell.toolTip == nil) + } + + @Test("A machine row's age is measured against the clock it was given") + func machineSubtitleUsesInjectedClock() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let machine = Self.machine(now: now) + let content = CloudTreeMachineRowContent(machine: machine, style: .defaultStyle, now: now) + let hoursLater = CloudTreeMachineRowContent( + machine: machine, + style: .defaultStyle, + now: now.addingTimeInterval(20 * 60 * 60) + ) + #expect(content.subtitle != hoursLater.subtitle) + } + + /// The compact preset is `machineRowLayout: .singleLine`, so the subtitle is + /// the one place the machine id and its age are written, and the pointer + /// reaches it through the tooltip. Assistive technology has no pointer, so + /// leaving the subtitle out of the label is the same row saying less to the + /// people who can least afford to lose it. + @Test("A machine row tells assistive technology its id and its age, like its hover text does") + func machineAccessibilityLabelCarriesIdentityAndAge() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let machine = Self.machine(now: now) + let content = CloudTreeMachineRowContent(machine: machine, style: .defaultStyle, now: now) + #expect(content.accessibilityLabel.contains("vm-abc")) + #expect(content.accessibilityLabel.contains(machine.kindLabel)) + // The age, stated against a clock the test owns rather than against the + // label's own text: comparing the label to `content.subtitle` would pass + // for any subtitle at all, including an empty one. + let older = CloudTreeMachineRowContent( + machine: machine, + style: .defaultStyle, + now: now.addingTimeInterval(20 * 60 * 60) + ) + #expect(content.accessibilityLabel != older.accessibilityLabel) + } + + /// An unlabelled machine's `displayName` is its id, and `showsName` is false + /// so the subtitle leaves the id out. Saying it twice is what a label that + /// pasted the subtitle in unconditionally would do, and VoiceOver reads + /// every word of it on every arrow key. + @Test("An unlabelled machine says its id once") + func unlabelledMachineDoesNotRepeatItsID() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let content = CloudTreeMachineRowContent( + machine: Self.machine(now: now, label: nil), + style: .defaultStyle, + now: now + ) + #expect(content.accessibilityLabel.components(separatedBy: "vm-abc").count - 1 == 1) + } + + /// The surface catalog finds a machine before the fleet list names it and + /// builds it with `image: info.image ?? ""`. The tooltip put that straight + /// into its line list, so hovering such a row opened a popup with a blank + /// line in the middle of it. + @Test("A machine whose image is not known yet has no blank line in its hover text") + func machineToolTipDropsAnEmptyImageLine() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let content = CloudTreeMachineRowContent( + machine: Self.machine(now: now, image: ""), + style: .defaultStyle, + now: now + ) + #expect(content.toolTip.contains("\n\n") == false) + #expect(content.toolTip.hasSuffix("\n") == false) + // The rest of the tooltip is unchanged: dropping the empty line must not + // drop the facts around it. + #expect(content.toolTip.contains("vm-abc")) + #expect(content.toolTip.contains("build box")) + } + + /// `CloudTreeBrowserDetail.text` returns the URL host, else the local + /// workspace showing the page. A browser with neither has nothing past the + /// title the row already draws, and a popup that repeats the row covers the + /// rows under it while saying nothing. + @Test("A browser row with nothing past its own title has no hover text") + func bareBrowserRowHasNoToolTip() { + let cell = Self.cell(presence: []) + cell.configure( + node: Self.browserNode(title: "Design docs", url: nil), + machineActions: Self.machineActions(), + nodeActions: Self.nodeActions() + ) + #expect(cell.toolTip == nil) + } + + /// The untitled case is the one `node.searchableTitle` cannot speak for: it + /// is the empty resource title, while the row and the tooltip both draw the + /// word "browser". The comparison has to be against what is drawn. + @Test("An untitled browser row with no address does not pop its own placeholder back") + func bareUntitledBrowserRowHasNoToolTip() { + let cell = Self.cell(presence: []) + cell.configure( + node: Self.browserNode(title: "", url: nil), + machineActions: Self.machineActions(), + nodeActions: Self.nodeActions() + ) + #expect(cell.toolTip == nil) + // Still labelled: quiet for the pointer is not quiet for VoiceOver. + #expect(cell.accessibilityLabel()?.isEmpty == false) + } + + @Test("A port row whose link is its whole title has no hover text") + func barePortRowHasNoToolTip() { + let cell = Self.cell(presence: []) + cell.configure( + node: Self.barePortNode(), + machineActions: Self.machineActions(), + nodeActions: Self.nodeActions() + ) + #expect(cell.toolTip == nil) + } + + // MARK: - Fixtures + + /// Labelled, so `showsName` is true and the subtitle carries the id, and + /// three hours old, so the relative age is a stable non-empty string. + /// `image` is a parameter because the catalog builds a machine it found + /// before the fleet list named it with `image: info.image ?? ""`. + private static func machine(now: Date, image: String = "devbox", label: String? = "build box") -> MachineSnapshot { + MachineSnapshot( + id: "vm-abc", + provider: "freestyle", + image: image, + isDesktop: false, + activity: .ready, + createdAt: now.addingTimeInterval(-3 * 60 * 60), + label: label + ) + } + + private static func cell(presence: [WorkspacePresenceParticipant]) -> CloudTreeCellView { + CloudTreeCellView(frame: NSRect(x: 0, y: 0, width: 260, height: 24), collaborators: { _, _ in presence }) + } + + private static func participant(name: String) -> WorkspacePresenceParticipant { + WorkspacePresenceParticipant(id: "user-\(name)", displayName: name) + } + + private static func workspaceNode() -> CloudTreeNode { + let workspace = SurfaceRemoteWorkspace( + id: "ws-1", + name: "api-refactor", + index: 0, + focused: false, + detail: "~/src/api" + ) + return CloudTreeNode( + id: "workspace/tooltip-test/ws-1", + kind: .workspace( + machine: .cloud("tooltip-test"), + workspace, + terminalCount: 3, + hiddenTabCount: 0, + openIn: nil + ) + ) + } + + private static func terminalRow( + title: String = "build", + machine: SurfaceMachineID = .cloud("tooltip-test"), + detail: String? = nil + ) -> CloudTreeTerminalRow { + let resource = SurfaceResource( + id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term-1"), + title: title, + detail: detail, + lifecycle: .running, + agent: nil, + remoteWorkspace: nil, + remoteViews: nil, + port: nil, + url: nil + ) + return CloudTreeTerminalRow(resource: resource, isOpen: false, viewBadge: nil) + } + + private static func terminalNode( + title: String = "build", + machine: SurfaceMachineID = .cloud("tooltip-test"), + detail: String? = nil + ) -> CloudTreeNode { + CloudTreeNode( + id: "terminal/tooltip-test/term-1", + kind: .terminal(terminalRow(title: title, machine: machine, detail: detail)) + ) + } + + private static func displayNode() -> CloudTreeNode { + let resource = SurfaceResource( + id: SurfaceResourceID(machine: .cloud("tooltip-test"), kind: .display, key: "display:1"), + title: "", + detail: nil, + lifecycle: .running, + agent: nil, + remoteWorkspace: nil, + remoteViews: nil, + port: nil, + url: nil + ) + return CloudTreeNode(id: "display/tooltip-test/1", kind: .display(resource, openIn: nil, remoteView: nil)) + } + + private static func portNode() -> CloudTreeNode { + let resource = SurfaceResource( + id: SurfaceResourceID( + machine: .cloud("tooltip-test"), + kind: .browser, + key: SurfaceResourceID.portKey(3_000) + ), + title: "3000", + detail: "vite", + lifecycle: .running, + agent: nil, + remoteWorkspace: nil, + remoteViews: nil, + port: 3_000, + url: nil + ) + return CloudTreeNode( + id: "port/tooltip-test/3000", + kind: .port(resource, url: "http://10.0.0.4:3000", openIn: nil) + ) + } + + /// `url` is a parameter because a browser that has not navigated yet has + /// none, and that is the row whose hover text has nothing to add. + private static func browserNode(title: String, url: String? = "https://example.com/docs") -> CloudTreeNode { + let resource = SurfaceResource( + id: SurfaceResourceID(machine: .cloud("tooltip-test"), kind: .browser, key: "browser-1"), + title: title, + detail: nil, + lifecycle: .running, + agent: nil, + remoteWorkspace: nil, + remoteViews: nil, + port: nil, + url: url + ) + return CloudTreeNode( + id: "browser/tooltip-test/browser-1", + kind: .browser(CloudTreeBrowserRow(resource: resource, isOpen: false, workspaceTitle: nil)) + ) + } + + /// A forwarded port the daemon reported with no process name and no detail, + /// so its link is the only fact the row has and the row already draws it. + private static func barePortNode() -> CloudTreeNode { + let resource = SurfaceResource( + id: SurfaceResourceID( + machine: .cloud("tooltip-test"), + kind: .browser, + key: SurfaceResourceID.portKey(3_000) + ), + title: "", + detail: nil, + lifecycle: .running, + agent: nil, + remoteWorkspace: nil, + remoteViews: nil, + port: 3_000, + url: nil + ) + return CloudTreeNode( + id: "port/tooltip-test/3000", + kind: .port(resource, url: "http://10.0.0.4:3000", openIn: nil) + ) + } + + private static func machineActions() -> MachineRowActions { + MachineRowActions( + openShell: { _ in }, openDesktop: { _ in }, runCommand: { _, _ in }, + confirmDelete: { _ in }, promptRename: { _, _ in }, resizeDisk: { _, _ in }, + resizeCPU: { _, _ in }, resizeMemory: { _, _ in }, promptUpgrade: {} + ) + } + + private static func nodeActions() -> CloudTreeNodeActions { + CloudTreeNodeActions( + project: { _, _, _ in }, projectRemoteView: { _, _, _, _ in }, + projectInLocalWorkspace: { _, _ in }, projectRemoteViewInLocalWorkspace: { _, _, _ in }, + newTerminal: { _, _ in }, openGroup: { _, _, _, _ in }, openGroupAsWorkspace: { _, _, _ in }, + newWorkspace: { _ in }, closeTerminal: { _ in }, closeWorkspace: { _, _ in }, + renameWorkspace: { _, _ in }, renameTerminal: { _, _ in }, selectLocalWorkspace: { _ in }, + copyToPasteboard: { _ in }, copyPortLink: { _ in }, refresh: {} + ) + } +}