diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 969c3320d8fb..3112b3ad041b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -146756,6 +146756,23 @@ } } }, + "notifications.copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー" + } + } + } + }, "notifications.dismiss": { "extractionState": "manual", "localizations": { diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 12af0fb8608b..06e58bab6920 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -381,6 +381,18 @@ struct NotificationRow: View, Equatable { RoundedRectangle(cornerRadius: 10) .fill(Color(nsColor: .controlBackgroundColor)) ) + .contextMenu { + Button(String(localized: "notifications.open", defaultValue: "Open")) { + onOpen() + } + Button(String(localized: "notifications.copy", defaultValue: "Copy")) { + TerminalNotificationClipboard.copy(notification, workspaceTitle: tabTitle) + } + Divider() + Button(String(localized: "notifications.dismiss", defaultValue: "Dismiss"), role: .destructive) { + onClear() + } + } } } diff --git a/Sources/TerminalNotification+Clipboard.swift b/Sources/TerminalNotification+Clipboard.swift new file mode 100644 index 000000000000..9f7e9c418c78 --- /dev/null +++ b/Sources/TerminalNotification+Clipboard.swift @@ -0,0 +1,44 @@ +import AppKit +import Foundation + +extension TerminalNotification { + /// Plain text written to the pasteboard when a user copies this notification. + /// + /// Mirrors what the notification rows render: the workspace title (when known), + /// the notification title, then the detail line. Detail follows the same rule + /// the sidebar and menu bar use (`body`, falling back to `subtitle`), so the + /// copied text matches the text on screen. + func clipboardText(workspaceTitle: String? = nil) -> String { + var lines: [String] = [] + if let workspaceTitle = workspaceTitle?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceTitle.isEmpty { + lines.append(workspaceTitle) + } + let title = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + lines.append(title) + } + let detail = (body.isEmpty ? subtitle : body).trimmingCharacters(in: .whitespacesAndNewlines) + if !detail.isEmpty, detail != title { + lines.append(detail) + } + return lines.joined(separator: "\n") + } +} + +/// Single copy path shared by every notification surface (popover row, Notifications +/// pane). Entry points call this instead of touching `NSPasteboard` themselves. +enum TerminalNotificationClipboard { + @MainActor + @discardableResult + static func copy( + _ notification: TerminalNotification, + workspaceTitle: String? = nil, + pasteboard: NSPasteboard = .general + ) -> String { + let text = notification.clipboardText(workspaceTitle: workspaceTitle) + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + return text + } +} diff --git a/Sources/Update/NotificationPopoverRow.swift b/Sources/Update/NotificationPopoverRow.swift index 598111dd0938..f47bfbd864eb 100644 --- a/Sources/Update/NotificationPopoverRow.swift +++ b/Sources/Update/NotificationPopoverRow.swift @@ -74,6 +74,9 @@ struct NotificationPopoverRow: View, Equatable { Button(String(localized: "notifications.open", defaultValue: "Open")) { onOpen() } + Button(String(localized: "notifications.copy", defaultValue: "Copy")) { + TerminalNotificationClipboard.copy(notification, workspaceTitle: workspaceTitle) + } if notification.isRead { Button(String(localized: "notifications.markAsUnread", defaultValue: "Mark as Unread")) { onToggleRead() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d036111b0bb7..f0ded2cc1979 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2624,6 +2624,7 @@ 859110000000000000000007 /* TerminalLinkOpenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */; }; 859100000000000000000001 /* TerminalLinkOpenCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */; }; 859110000000000000000009 /* TerminalLinkOpenRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85911000000000000000000A /* TerminalLinkOpenRequest.swift */; }; + A5C0B1E00000000000000001 /* TerminalNotification+Clipboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0B1E00000000000000002 /* TerminalNotification+Clipboard.swift */; }; D79020000000000000000001 /* TerminalNotification+NavigationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */; }; A5F10000000000000000000B /* TerminalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10000000000000000000C /* TerminalNotification.swift */; }; B7F100080000000000000001 /* TerminalNotificationArrivalDisposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F100080000000000000002 /* TerminalNotificationArrivalDisposition.swift */; }; @@ -2631,6 +2632,7 @@ A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */; }; A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */; }; 468110000000000000000007 /* TerminalNotificationClickAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468110000000000000000008 /* TerminalNotificationClickAction.swift */; }; + A5C0B1E00000000000000003 /* TerminalNotificationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0B1E00000000000000004 /* TerminalNotificationClipboardTests.swift */; }; B7F00008 /* TerminalNotificationDeliveryDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F00007 /* TerminalNotificationDeliveryDecision.swift */; }; A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */; }; 7939000DAA11BB22CC33DD0D /* TerminalNotificationLiveRetargetDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7939000EAA11BB22CC33DD0E /* TerminalNotificationLiveRetargetDelivery.swift */; }; @@ -5642,6 +5644,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinator.swift; sourceTree = ""; }; 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinatorTests.swift; sourceTree = ""; }; 85911000000000000000000A /* TerminalLinkOpenRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenRequest.swift; sourceTree = ""; }; + A5C0B1E00000000000000002 /* TerminalNotification+Clipboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotification+Clipboard.swift"; sourceTree = ""; }; D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotification+NavigationSnapshot.swift"; sourceTree = ""; }; A5F10000000000000000000C /* TerminalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotification.swift; sourceTree = ""; }; B7F100080000000000000002 /* TerminalNotificationArrivalDisposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationArrivalDisposition.swift; sourceTree = ""; }; @@ -5649,6 +5652,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationCallerTests.swift; sourceTree = ""; }; A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClearAllTests.swift; sourceTree = ""; }; 468110000000000000000008 /* TerminalNotificationClickAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClickAction.swift; sourceTree = ""; }; + A5C0B1E00000000000000004 /* TerminalNotificationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClipboardTests.swift; sourceTree = ""; }; B7F00007 /* TerminalNotificationDeliveryDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationDeliveryDecision.swift; sourceTree = ""; }; A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationDirectInteractionTests.swift; sourceTree = ""; }; 7939000EAA11BB22CC33DD0E /* TerminalNotificationLiveRetargetDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationLiveRetargetDelivery.swift; sourceTree = ""; }; @@ -7672,6 +7676,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DEFEF80000000000000002 /* NotificationFeedHistoryTopLevelSnapshotHeaderScanner.swift */, A5F10000000000000000000C /* TerminalNotification.swift */, D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */, + A5C0B1E00000000000000002 /* TerminalNotification+Clipboard.swift */, A5001092 /* TerminalNotificationStore.swift */, 468110000000000000000008 /* TerminalNotificationClickAction.swift */, A5F10000000000000000000E /* TerminalNotificationStore+WorkspaceNotifications.swift */, @@ -9119,6 +9124,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 7837E0027837E0027837E002 /* CmuxSocketEventMapperTests.swift */, A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */, A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */, + A5C0B1E00000000000000004 /* TerminalNotificationClipboardTests.swift */, A5E380710000000000000002 /* TerminalNotificationOpenPanelFallbackTests.swift */, A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */, A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */, @@ -11409,6 +11415,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 859110000000000000000005 /* TerminalLinkOpenContainer.swift in Sources */, 859110000000000000000007 /* TerminalLinkOpenCoordinator.swift in Sources */, 859110000000000000000009 /* TerminalLinkOpenRequest.swift in Sources */, + A5C0B1E00000000000000001 /* TerminalNotification+Clipboard.swift in Sources */, D79020000000000000000001 /* TerminalNotification+NavigationSnapshot.swift in Sources */, A5F10000000000000000000B /* TerminalNotification.swift in Sources */, B7F100080000000000000001 /* TerminalNotificationArrivalDisposition.swift in Sources */, @@ -12605,6 +12612,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 859100000000000000000001 /* TerminalLinkOpenCoordinatorTests.swift in Sources */, A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */, + A5C0B1E00000000000000003 /* TerminalNotificationClipboardTests.swift in Sources */, A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */, A5E380710000000000000001 /* TerminalNotificationOpenPanelFallbackTests.swift in Sources */, A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */, diff --git a/cmuxTests/TerminalNotificationClipboardTests.swift b/cmuxTests/TerminalNotificationClipboardTests.swift new file mode 100644 index 000000000000..f4ba182dd827 --- /dev/null +++ b/cmuxTests/TerminalNotificationClipboardTests.swift @@ -0,0 +1,58 @@ +import AppKit +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalNotificationClipboardTests: XCTestCase { + private func makeNotification( + title: String = "Task finished", + subtitle: String = "", + body: String = "All 12 tests passed" + ) -> TerminalNotification { + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: title, + subtitle: subtitle, + body: body, + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + } + + func testClipboardTextIncludesWorkspaceTitleTitleAndBody() { + let text = makeNotification().clipboardText(workspaceTitle: "cmux") + XCTAssertEqual(text, "cmux\nTask finished\nAll 12 tests passed") + } + + func testClipboardTextOmitsEmptyWorkspaceTitle() { + XCTAssertEqual(makeNotification().clipboardText(workspaceTitle: " "), "Task finished\nAll 12 tests passed") + XCTAssertEqual(makeNotification().clipboardText(workspaceTitle: nil), "Task finished\nAll 12 tests passed") + } + + func testClipboardTextFallsBackToSubtitleWhenBodyIsEmpty() { + let text = makeNotification(subtitle: "Claude Code", body: "").clipboardText() + XCTAssertEqual(text, "Task finished\nClaude Code") + } + + func testClipboardTextDropsDetailThatRepeatsTitle() { + let text = makeNotification(body: "Task finished").clipboardText() + XCTAssertEqual(text, "Task finished") + } + + func testCopyWritesPlainTextToPasteboard() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("cmux.tests.notificationClipboard.\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + + let written = TerminalNotificationClipboard.copy(makeNotification(), workspaceTitle: "cmux", pasteboard: pasteboard) + + XCTAssertEqual(written, "cmux\nTask finished\nAll 12 tests passed") + XCTAssertEqual(pasteboard.string(forType: .string), written) + } +}