Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions Sources/NotificationsPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
}

Expand Down
44 changes: 44 additions & 0 deletions Sources/TerminalNotification+Clipboard.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 3 additions & 0 deletions Sources/Update/NotificationPopoverRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2624,13 +2624,15 @@
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 */; };
A5C41101A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41102A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift */; };
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 */; };
Expand Down Expand Up @@ -5642,13 +5644,15 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef
859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinator.swift; sourceTree = "<group>"; };
859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinatorTests.swift; sourceTree = "<group>"; };
85911000000000000000000A /* TerminalLinkOpenRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenRequest.swift; sourceTree = "<group>"; };
A5C0B1E00000000000000002 /* TerminalNotification+Clipboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotification+Clipboard.swift"; sourceTree = "<group>"; };
D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotification+NavigationSnapshot.swift"; sourceTree = "<group>"; };
A5F10000000000000000000C /* TerminalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotification.swift; sourceTree = "<group>"; };
B7F100080000000000000002 /* TerminalNotificationArrivalDisposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationArrivalDisposition.swift; sourceTree = "<group>"; };
A5C41102A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationCallerResolver.swift; sourceTree = "<group>"; };
A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationCallerTests.swift; sourceTree = "<group>"; };
A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClearAllTests.swift; sourceTree = "<group>"; };
468110000000000000000008 /* TerminalNotificationClickAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClickAction.swift; sourceTree = "<group>"; };
A5C0B1E00000000000000004 /* TerminalNotificationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClipboardTests.swift; sourceTree = "<group>"; };
B7F00007 /* TerminalNotificationDeliveryDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationDeliveryDecision.swift; sourceTree = "<group>"; };
A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationDirectInteractionTests.swift; sourceTree = "<group>"; };
7939000EAA11BB22CC33DD0E /* TerminalNotificationLiveRetargetDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationLiveRetargetDelivery.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
58 changes: 58 additions & 0 deletions cmuxTests/TerminalNotificationClipboardTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}