diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7244cc28a4d3..b363a8bb0bf8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11618,6 +11618,9 @@ struct VerticalTabsSidebar: View, Equatable { } let rowActions = SidebarAppKitRowActions( commands: commands, + onOpenStatusURL: { url in + NSWorkspace.shared.open(url) + }, onOpenPullRequest: { [prefer = input.settings.openPullRequestLinksInCmuxBrowser] url in openInBrowser(url, prefer) }, diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 97be455ebe01..518a3f65ccb2 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -574,7 +574,14 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } else { entryColor = explicitColor ?? .secondaryLabelColor } - metadataRows[index].configureMetadataEntry(entry, model: model, color: entryColor) + metadataRows[index].configureMetadataEntry( + entry, + model: model, + color: entryColor + ) { [weak self] url in + self?.actions?.commands.updateSelection() + self?.actions?.onOpenStatusURL(url) + } } let toggleFont = NSFont.systemFont(ofSize: model.scaled(10), weight: .semibold) let toggleColor = model.isActive diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift index 964f0cde4320..eb3acf3bc8cf 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift @@ -55,6 +55,7 @@ struct SidebarWorkspaceRowModel: Equatable { @MainActor struct SidebarAppKitRowActions { let commands: SidebarWorkspaceRowCommands + let onOpenStatusURL: (URL) -> Void let onOpenPullRequest: (URL) -> Void let onOpenPort: (Int) -> Void let onToggleChecklistExpansion: () -> Void diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift index 4b8dd4274d62..591e016f2961 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift @@ -52,6 +52,7 @@ final class SidebarRowIconTextLine: NSView { private let iconView = NSImageView() private let iconLabel = NSTextField(labelWithString: "") private let textView = SidebarRowTextView(lines: 1) + private let metadataButton = SidebarRowLinkButton() private let secondTextView = SidebarRowTextView(lines: 1) private var iconSize: CGFloat = 0 private var stacked = false @@ -64,6 +65,9 @@ final class SidebarRowIconTextLine: NSView { addSubview(iconView) addSubview(iconLabel) addSubview(textView) + metadataButton.alignment = .left + metadataButton.isHidden = true + addSubview(metadataButton) secondTextView.isHidden = true addSubview(secondTextView) } @@ -75,7 +79,8 @@ final class SidebarRowIconTextLine: NSView { func configureMetadataEntry( _ entry: SidebarStatusEntry, model: SidebarWorkspaceRowModel, - color: NSColor + color: NSColor, + onOpenURL: @escaping (URL) -> Void ) { stacked = false secondTextView.isHidden = true @@ -106,9 +111,25 @@ final class SidebarRowIconTextLine: NSView { } } } - textView.stringValue = entry.sidebarDisplayText - textView.font = .systemFont(ofSize: model.scaled(10)) - textView.textColor = color + let font = NSFont.systemFont(ofSize: model.scaled(10)) + if let url = entry.url { + textView.isHidden = true + metadataButton.isHidden = false + metadataButton.configure( + title: entry.sidebarDisplayText, + font: font, + color: color, + underlined: true, + toolTip: url.absoluteString, + onClick: { onOpenURL(url) } + ) + } else { + metadataButton.isHidden = true + textView.isHidden = false + textView.stringValue = entry.sidebarDisplayText + textView.font = font + textView.textColor = color + } needsLayout = true } @@ -118,6 +139,8 @@ final class SidebarRowIconTextLine: NSView { palette: SidebarRowPalette ) { stacked = false + metadataButton.isHidden = true + textView.isHidden = false secondTextView.isHidden = true iconLabel.isHidden = true let iconName: String @@ -163,6 +186,8 @@ final class SidebarRowIconTextLine: NSView { model: SidebarWorkspaceRowModel, palette: SidebarRowPalette ) { + metadataButton.isHidden = true + textView.isHidden = false iconView.isHidden = true iconLabel.isHidden = true iconSize = 0 @@ -207,7 +232,9 @@ final class SidebarRowIconTextLine: NSView { func measuredHeight(width: CGFloat) -> CGFloat { resolveCandidates(width: width) - let first = textView.measuredHeight(width: max(10, width - iconSize)) + let first = metadataButton.isHidden + ? textView.measuredHeight(width: max(10, width - iconSize)) + : ceil(metadataButton.intrinsicContentSize.height) let second = secondTextView.isHidden ? 0 : secondTextView.measuredHeight(width: max(10, width - iconSize)) + 1 return first + second } @@ -240,8 +267,12 @@ final class SidebarRowIconTextLine: NSView { icon.frame = NSRect(x: 0, y: 1, width: side, height: side) x = side + 4 } - let firstHeight = textView.measuredHeight(width: max(10, bounds.width - x)) - textView.frame = NSRect(x: x, y: 0, width: max(10, bounds.width - x), height: firstHeight) + let availableWidth = max(10, bounds.width - x) + let firstHeight = metadataButton.isHidden + ? textView.measuredHeight(width: availableWidth) + : ceil(metadataButton.intrinsicContentSize.height) + let primaryView: NSView = metadataButton.isHidden ? textView : metadataButton + primaryView.frame = NSRect(x: x, y: 0, width: availableWidth, height: firstHeight) if !secondTextView.isHidden { let secondHeight = secondTextView.measuredHeight(width: max(10, bounds.width - x)) secondTextView.frame = NSRect(x: x, y: firstHeight + 1, width: max(10, bounds.width - x), height: secondHeight) diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 673ea7282766..c1968be53f60 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -8,7 +8,10 @@ import Testing @Suite @MainActor struct SidebarAppKitRowCellTests { - private static func makeSnapshot(title: String = "Workspace") -> SidebarWorkspaceSnapshotBuilder.Snapshot { + private static func makeSnapshot( + title: String = "Workspace", + metadataEntries: [SidebarStatusEntry] = [] + ) -> SidebarWorkspaceSnapshotBuilder.Snapshot { SidebarWorkspaceSnapshotBuilder.Snapshot( presentationKey: SidebarWorkspaceSnapshotFactory.presentationKey( settings: SidebarTabItemSettingsSnapshot(defaults: UserDefaults(suiteName: UUID().uuidString)!), @@ -24,7 +27,7 @@ struct SidebarAppKitRowCellTests { showsRemoteReconnectAffordance: false, copyableSidebarSSHError: nil, latestConversationMessage: nil, - metadataEntries: [], + metadataEntries: metadataEntries, metadataBlocks: [], latestLog: nil, progress: nil, @@ -52,14 +55,15 @@ struct SidebarAppKitRowCellTests { workspaceId: UUID = UUID(), isActive: Bool = false, canClose: Bool = true, - settings: SidebarTabItemSettingsSnapshot? = nil + settings: SidebarTabItemSettingsSnapshot? = nil, + metadataEntries: [SidebarStatusEntry] = [] ) -> SidebarWorkspaceRowModel { let resolvedSettings = settings ?? SidebarTabItemSettingsSnapshot(defaults: UserDefaults(suiteName: UUID().uuidString)!) return SidebarWorkspaceRowModel( workspaceId: workspaceId, index: 0, - snapshot: makeSnapshot(), + snapshot: makeSnapshot(metadataEntries: metadataEntries), settings: resolvedSettings, isActive: isActive, isMultiSelected: false, @@ -141,7 +145,10 @@ struct SidebarAppKitRowCellTests { UserDefaults(suiteName: "SidebarAppKitRowCellTests.\(UUID().uuidString)")! } - private static func makeActions(model: SidebarWorkspaceRowModel) -> SidebarAppKitRowActions { + private static func makeActions( + model: SidebarWorkspaceRowModel, + onOpenStatusURL: @escaping (URL) -> Void = { _ in } + ) -> SidebarAppKitRowActions { let commands = SidebarWorkspaceRowCommands( tab: Workspace(), tabManager: nil, @@ -163,6 +170,7 @@ struct SidebarAppKitRowCellTests { ) return SidebarAppKitRowActions( commands: commands, + onOpenStatusURL: onOpenStatusURL, onOpenPullRequest: { _ in }, onOpenPort: { _ in }, onToggleChecklistExpansion: {}, @@ -178,12 +186,13 @@ struct SidebarAppKitRowCellTests { } private static func configuredCell( - model: SidebarWorkspaceRowModel + model: SidebarWorkspaceRowModel, + onOpenStatusURL: @escaping (URL) -> Void = { _ in } ) -> SidebarWorkspaceRowTableCellView { let cell = SidebarWorkspaceRowTableCellView() cell.configure( model: model, - actions: makeActions(model: model), + actions: makeActions(model: model, onOpenStatusURL: onOpenStatusURL), isPointerHovering: false, contextMenuDidOpen: {}, contextMenuDidClose: {} @@ -191,6 +200,10 @@ struct SidebarAppKitRowCellTests { return cell } + private static func descendants(of view: NSView) -> [NSView] { + view.subviews + view.subviews.flatMap { descendants(of: $0) } + } + @Test(arguments: zip(["codex", "claude_code"], ["Running", "Needs input"])) func metadataStatusTextOmitsRawAgentKey(_ key: String, _ status: String) throws { let model = Self.makeModel() @@ -199,7 +212,8 @@ struct SidebarAppKitRowCellTests { row.configureMetadataEntry( SidebarStatusEntry(key: key, value: status, icon: "bolt.fill"), model: model, - color: .labelColor + color: .labelColor, + onOpenURL: { _ in } ) let textView = try #require(row.subviews.compactMap { $0 as? SidebarRowTextView }.first) @@ -207,6 +221,24 @@ struct SidebarAppKitRowCellTests { #expect(!textView.stringValue.contains(key)) } + @Test + func metadataStatusURLRendersAnActionBoundToItsDestination() throws { + let url = try #require(URL(string: "https://example.com/issues/8520")) + let model = Self.makeModel( + metadataEntries: [SidebarStatusEntry(key: "repro_link", value: "click me", url: url)] + ) + var openedURL: URL? + let cell = Self.configuredCell(model: model) { openedURL = $0 } + let buttons = Self.descendants(of: cell).compactMap { $0 as? NSButton } + + let link = try #require(buttons.first { $0.toolTip == url.absoluteString }) + #expect(link.action != nil) + #expect(link.target != nil) + #expect(link.isEnabled) + link.performClick(nil) + #expect(openedURL == url) + } + @Test func hoverEnforcementShortCircuitsWhenAlreadyCorrect() { let model = Self.makeModel()