diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Services/Pasteboard/TerminalPasteboardService+ImageMaterialization.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Services/Pasteboard/TerminalPasteboardService+ImageMaterialization.swift index c67e5c3d7da9..120c353ef057 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Services/Pasteboard/TerminalPasteboardService+ImageMaterialization.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Services/Pasteboard/TerminalPasteboardService+ImageMaterialization.swift @@ -8,6 +8,8 @@ extension TerminalPasteboardService: TerminalImagePasteWriting { /// Attempts to materialize a decodable pasteboard image into a temporary file. /// `rejectedImagePayload` means a real image was found but could not be used, /// so callers should not fall back to auxiliary plain text or URLs. + /// `rejectedOversizedImagePayload` is the same rejection for an image over + /// ``maxClipboardImageSize``. public func materializeImageFileURLIfNeeded( from pasteboard: NSPasteboard = .general ) -> TerminalImageFileMaterialization { @@ -20,6 +22,8 @@ extension TerminalPasteboardService: TerminalImagePasteWriting { return .noDecodableImagePayload case .rejectedImagePayload: return .rejectedImagePayload + case .rejectedOversizedImagePayload: + return .rejectedOversizedImagePayload } } @@ -106,7 +110,7 @@ extension TerminalPasteboardService { logDebugEvent("terminal.paste.image.rejected reason=tooLarge bytes=\(representation.data.count)") #endif cleanupTransferredTemporaryImageFiles(fileURLs) - return .rejectedImagePayload + return .rejectedOversizedImagePayload } let fileURL = temporaryImageFileURL(fileExtension: representation.fileExtension) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift index 51f7494b0969..2fc1b874026f 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift @@ -325,14 +325,26 @@ struct ImageMaterializationTests { scratch.pasteboard.declareTypes([.png], owner: nil) scratch.pasteboard.setData(Data(count: 10 * 1024 * 1024 + 1), forType: .png) + // Oversized is reported separately from a failed write so a paste + // can say why nothing arrived. #expect( service.materializeImageFileURLIfNeeded(from: scratch.pasteboard) - == .rejectedImagePayload + == .rejectedOversizedImagePayload + ) + #expect( + service.materializeImageFileURLsIfNeeded(from: scratch.pasteboard) + == .rejectedOversizedImagePayload ) let leftovers = try FileManager.default.contentsOfDirectory(atPath: scratchDir.path) #expect(leftovers.isEmpty) } + /// The app's paste notice says "Image is larger than 10 MB". Changing the + /// cap must change that string too. + @Test func clipboardImageCapMatchesThePasteNoticeText() { + #expect(TerminalPasteboardService.maxClipboardImageSize == 10 * 1024 * 1024) + } + @Test func emptyPasteboardHasNoDecodableImagePayload() { let scratch = ScratchPasteboard() let service = TerminalPasteboardService() diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileListMaterialization.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileListMaterialization.swift index 0e56ba2aef58..09a5e6c32339 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileListMaterialization.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileListMaterialization.swift @@ -5,7 +5,8 @@ public import Foundation /// `rejectedImagePayload` means at least one real image was found but the /// batch could not be used (an item was too large or failed to write; any /// files already written are cleaned up), so callers must not fall back to -/// auxiliary plain text or URLs. +/// auxiliary plain text or URLs. `rejectedOversizedImagePayload` is the same +/// rejection when the reason is that an item exceeded the clipboard image cap. public enum TerminalImageFileListMaterialization: Equatable, Sendable { /// Every image was written; the URLs preserve pasteboard order. case saved([URL]) @@ -15,4 +16,8 @@ public enum TerminalImageFileListMaterialization: Equatable, Sendable { /// A real image payload was found but the batch could not be materialized. case rejectedImagePayload + + /// An image in the batch is larger than the clipboard image cap; any files + /// already written are cleaned up. + case rejectedOversizedImagePayload } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileMaterialization.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileMaterialization.swift index 487993a4a292..049c6a2b4387 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileMaterialization.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalImageFileMaterialization.swift @@ -2,9 +2,10 @@ public import Foundation /// The outcome of materializing one pasteboard image into a temporary file. /// -/// `rejectedImagePayload` means a real image was found but could not be used -/// (too large, or the write failed), so callers must not fall back to -/// auxiliary plain text or URLs. +/// `rejectedOversizedImagePayload` and `rejectedImagePayload` both mean a real +/// image was found but could not be used (too large, or the write failed), so +/// callers must not fall back to auxiliary plain text or URLs. The oversized +/// case is separate so a paste can tell the user why nothing arrived. public enum TerminalImageFileMaterialization: Equatable, Sendable { /// The image was written to the given temporary file. case saved(URL) @@ -14,4 +15,8 @@ public enum TerminalImageFileMaterialization: Equatable, Sendable { /// A real image payload was found but could not be materialized. case rejectedImagePayload + + /// A real image payload was found but it is larger than the clipboard + /// image cap, so nothing was written. + case rejectedOversizedImagePayload } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 730079e87ce7..bd1597cbbece 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -554316,6 +554316,124 @@ } } } + }, + "terminal.paste.notice.imageTooLarge": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الصورة أكبر من 10 ميغابايت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bild ist größer als 10 MB" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Image is larger than 10 MB" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La imagen supera los 10 MB" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’image dépasse 10 Mo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像が 10 MB を超えています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지가 10MB보다 큽니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图片大于 10 MB" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片大於 10 MB" + } + } + } + }, + "terminal.paste.notice.timedOut": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة اللصق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung beim Einfügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste timed out" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se agotó el tiempo para pegar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le collage a expiré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "貼り付けがタイムアウトしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "붙여넣기 시간이 초과되었습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "粘贴超时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "貼上逾時" + } + } + } } }, "version": "1.0" diff --git a/Sources/GhosttyApp+RuntimeClipboardRead.swift b/Sources/GhosttyApp+RuntimeClipboardRead.swift index 3fa134471997..c4c9f3586c99 100644 --- a/Sources/GhosttyApp+RuntimeClipboardRead.swift +++ b/Sources/GhosttyApp+RuntimeClipboardRead.swift @@ -136,11 +136,13 @@ extension GhosttyApp { .map(\.rawValue) .joined(separator: ",") - let preparedContent = await TerminalImageTransferPlanner.prepare( - pasteboard: pasteboard, - mode: .paste, - using: preparationService - ) + let preparationOutcome = await TerminalImageTransferPlanner + .prepareReportingFailure( + pasteboard: pasteboard, + mode: .paste, + using: preparationService + ) + let preparedContent = preparationOutcome.content pasteboardReadLease.finish() guard !operation.isCancelled else { @@ -170,8 +172,15 @@ extension GhosttyApp { ) #endif + // The beep for a timed-out worker already played in the + // preparation service; an oversized image was silent. Both now + // also get a brief notice over the pasting terminal. + if let notice = TerminalPasteFailureNotice.notice(for: preparationOutcome) { + requestTerminalSurface.hostedView.showPasteFailureNotice(notice) + } + switch preparedContent { - case .reject: + case .reject, .rejectOversizedImage: completeClipboardRequest(with: "") case .insertText(let text): completeClipboardRequest(with: text) diff --git a/Sources/GhosttyNSView+PreparedImageTransfer.swift b/Sources/GhosttyNSView+PreparedImageTransfer.swift index 3d006171c7c6..ef32975dc012 100644 --- a/Sources/GhosttyNSView+PreparedImageTransfer.swift +++ b/Sources/GhosttyNSView+PreparedImageTransfer.swift @@ -20,7 +20,7 @@ extension GhosttyNSView { return true } switch preparedContent { - case .reject: + case .reject, .rejectOversizedImage: return false case .insertText(let text): return terminalSurface?.sendText(text) ?? false diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 251f8a88525e..e9097fdb0d1e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4960,7 +4960,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } - if payload != .reject { + if !payload.isRejection { let payloadBytes = result.payloadBytes guard payloadBytes <= Self.maximumPendingPastePayloadBytes, pendingPastePayloadBytes <= @@ -5141,7 +5141,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let (next, overflowed) = total.addingReportingOverflow(payloadBytes) total = overflowed ? .max : next } - case .reject: + case .reject, .rejectOversizedImage: return 0 } } @@ -10224,6 +10224,7 @@ final class GhosttySurfaceScrollView: NSView { private var searchOverlayHostingView: NSHostingView? private let deferredSearchOverlayMutationScheduler = MainActorDeferredActionScheduler() private let imageTransferIndicatorShowScheduler = MainActorDeferredActionScheduler() + private lazy var pasteFailureNoticePresenter = TerminalPasteFailureNoticePresenter() private var activeImageTransferOperation: TerminalImageTransferOperation? private var activeImageTransferCancelHandler: (() -> Void)? private var lastSearchOverlayStateID: ObjectIdentifier? @@ -11497,6 +11498,12 @@ final class GhosttySurfaceScrollView: NSView { imageTransferIndicatorSpinner.stopAnimation(nil) imageTransferIndicatorContainerView.isHidden = true } + + /// Shows a brief, non-modal notice over this terminal for a paste that + /// produced nothing (see ``TerminalPasteFailureNotice``). + func showPasteFailureNotice(_ notice: TerminalPasteFailureNotice) { + pasteFailureNoticePresenter.show(notice, over: self) + } private func makeSearchOverlayRootView( terminalSurface: TerminalSurface, searchState: TerminalSurface.SearchState diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index 2ae0b05c444e..e0a6f67e3ab6 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -19,6 +19,16 @@ enum TerminalImageTransferPreparedContent: Codable, Equatable, Sendable { case insertText(String) case fileURLs([URL]) case reject + /// Rejected because the pasteboard image is over the clipboard image cap. + /// Handled exactly like `reject`, except that a paste can say why. + case rejectOversizedImage + + var isRejection: Bool { + switch self { + case .reject, .rejectOversizedImage: return true + case .insertText, .fileURLs: return false + } + } } enum TerminalImageTransferExecutionError: Error { @@ -132,7 +142,7 @@ enum TerminalImageTransferPlanner { ) -> TerminalImageTransferPlan { let preparedContent = prepareSynchronously(pasteboard: pasteboard, mode: mode) switch preparedContent { - case .insertText, .reject: + case .insertText, .reject, .rejectOversizedImage: return plan(preparedContent: preparedContent, target: .local, mode: mode) case .fileURLs: return plan(preparedContent: preparedContent, target: resolveTarget(), mode: mode) @@ -159,6 +169,21 @@ enum TerminalImageTransferPlanner { ) } + /// Like ``prepare(pasteboard:mode:using:)``, but also reports why an + /// accepted request produced no content (for example, a worker timeout). + @MainActor + static func prepareReportingFailure( + pasteboard: NSPasteboard, + mode: TerminalImageTransferMode, + using preparationService: TerminalImageTransferPreparationService + ) async -> TerminalImageTransferPreparationOutcome { + let request = TerminalPasteboardReadRequest(pasteboard: pasteboard) + return await preparationService.prepareReportingFailure( + request: request, + mode: mode + ) + } + static func prepareSynchronously( pasteboard: NSPasteboard, mode: TerminalImageTransferMode @@ -199,7 +224,7 @@ enum TerminalImageTransferPlanner { return .insertText(text) case .fileURLs(let fileURLs): return plan(fileURLs: fileURLs, target: target, mode: mode) - case .reject: + case .reject, .rejectOversizedImage: return .reject } } @@ -375,6 +400,8 @@ enum TerminalImageTransferPlanner { return .fileURLs([imageURL]) case .rejectedImagePayload: return .reject + case .rejectedOversizedImagePayload: + return .rejectOversizedImage case .noDecodableImagePayload: break } @@ -467,7 +494,7 @@ enum TerminalImageTransferPlanner { } switch pasteboardService.materializeImageFileURLsIfNeeded(from: pasteboard) { case .saved(let urls): return urls - case .rejectedImagePayload: return nil + case .rejectedImagePayload, .rejectedOversizedImagePayload: return nil case .noDecodableImagePayload: return durableURLs() } } diff --git a/Sources/TerminalImageTransferPreparationOutcome.swift b/Sources/TerminalImageTransferPreparationOutcome.swift new file mode 100644 index 000000000000..1035d1cc928e --- /dev/null +++ b/Sources/TerminalImageTransferPreparationOutcome.swift @@ -0,0 +1,7 @@ +/// What terminal paste preparation produced, and why it produced nothing when +/// an accepted request failed (for example, the worker ran past its deadline). +struct TerminalImageTransferPreparationOutcome: Equatable, Sendable { + let content: TerminalImageTransferPreparedContent + /// Nil when the worker returned content, including a `reject`. + let failure: TerminalPastePreparationFailure? +} diff --git a/Sources/TerminalImageTransferPreparationService.swift b/Sources/TerminalImageTransferPreparationService.swift index 3f48b14ef6ae..1cf2708916b5 100644 --- a/Sources/TerminalImageTransferPreparationService.swift +++ b/Sources/TerminalImageTransferPreparationService.swift @@ -74,6 +74,16 @@ actor TerminalImageTransferPreparationService { request: TerminalPasteboardReadRequest, mode: TerminalImageTransferMode ) async -> TerminalImageTransferPreparedContent { + await prepareReportingFailure(request: request, mode: mode).content + } + + /// Same as ``prepare(request:mode:)``, but also reports why an accepted + /// request produced no content, so a paste can tell the user. The failure + /// signal (the beep) fires exactly as it does for `prepare`. + func prepareReportingFailure( + request: TerminalPasteboardReadRequest, + mode: TerminalImageTransferMode + ) async -> TerminalImageTransferPreparationOutcome { let outcome = await submit( TerminalPastePreparationRequest( pasteboard: request, @@ -83,12 +93,12 @@ actor TerminalImageTransferPreparationService { ) switch outcome { case .success(.terminal(let content)): - return content + return TerminalImageTransferPreparationOutcome(content: content, failure: nil) case .success: - return .reject + return TerminalImageTransferPreparationOutcome(content: .reject, failure: nil) case .failure(let failure): await signalFailureIfNeeded(failure) - return .reject + return TerminalImageTransferPreparationOutcome(content: .reject, failure: failure) } } diff --git a/Sources/TerminalImageTransferPreparedContent+DebugDescription.swift b/Sources/TerminalImageTransferPreparedContent+DebugDescription.swift index f241a830fde8..720b2540a830 100644 --- a/Sources/TerminalImageTransferPreparedContent+DebugDescription.swift +++ b/Sources/TerminalImageTransferPreparedContent+DebugDescription.swift @@ -8,6 +8,8 @@ extension TerminalImageTransferPreparedContent { return "fileURLs(count:\(fileURLs.count))" case .reject: return "reject" + case .rejectOversizedImage: + return "rejectOversizedImage" } } } diff --git a/Sources/TerminalPasteFailureNotice.swift b/Sources/TerminalPasteFailureNotice.swift new file mode 100644 index 000000000000..a060576a87cd --- /dev/null +++ b/Sources/TerminalPasteFailureNotice.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A paste failure worth a brief on-screen notice in addition to the beep. +/// +/// Only two failures qualify: an image over the clipboard image cap (which +/// used to fail silently) and a paste worker that ran past its deadline (which +/// used to be only a beep). Every other failure keeps its existing behavior. +enum TerminalPasteFailureNotice: Equatable, Sendable { + case imageTooLarge + case timedOut + + /// The notice for a terminal paste outcome, or nil when there is nothing + /// to show. Pure, so a test can check the mapping without an app host. + static func notice( + for outcome: TerminalImageTransferPreparationOutcome + ) -> TerminalPasteFailureNotice? { + if outcome.failure == .deadlineExceeded { return .timedOut } + if outcome.content == .rejectOversizedImage { return .imageTooLarge } + return nil + } + + var message: String { + switch self { + case .imageTooLarge: + // Matches TerminalPasteboardService.maxClipboardImageSize (10 MiB); + // CmuxTerminal's tests pin that constant to this text. + return String( + localized: "terminal.paste.notice.imageTooLarge", + defaultValue: "Image is larger than 10 MB" + ) + case .timedOut: + return String( + localized: "terminal.paste.notice.timedOut", + defaultValue: "Paste timed out" + ) + } + } +} diff --git a/Sources/TerminalPasteFailureNoticePresenter.swift b/Sources/TerminalPasteFailureNoticePresenter.swift new file mode 100644 index 000000000000..eb2ab87d30b9 --- /dev/null +++ b/Sources/TerminalPasteFailureNoticePresenter.swift @@ -0,0 +1,53 @@ +import AppKit +import CmuxFoundation + +/// Shows a ``TerminalPasteFailureNotice`` briefly over one terminal. +/// +/// Reuses the warning badge a rejected drop shows (``FileDropHintBadgeView``, +/// as ``SurfaceDropFeedback`` does). The badge is a subview of the terminal's +/// scroll view, like the image-transfer indicator, so it goes away with the +/// terminal. It ignores hit testing, never takes focus, and hides itself after +/// ``displayDuration``. A newer notice replaces the one on screen. +@MainActor +final class TerminalPasteFailureNoticePresenter { + static let displayDuration: Duration = .seconds(3) + + private var badgeView: FileDropHintBadgeView? + private var deadline: MainActorCoalescingDeadlineTimer? + + func show(_ notice: TerminalPasteFailureNotice, over host: NSView) { + let badge = badgeView ?? FileDropHintBadgeView(frame: .zero) + badgeView = badge + // Re-adding moves the badge above overlays mounted since the last notice. + badge.removeFromSuperview() + host.addSubview(badge, positioned: .above, relativeTo: nil) + let message = notice.message + badge.show( + text: message, + centeredIn: host.bounds, + clippedTo: host.bounds, + warning: true + ) + if let application = NSApp { + NSAccessibility.post( + element: application, + notification: .announcementRequested, + userInfo: [ + .announcement: message, + .priority: NSAccessibilityPriorityLevel.high.rawValue, + ] + ) + } + if deadline == nil { + // A display deadline for the notice, not a retry loop. + deadline = MainActorCoalescingDeadlineTimer(owner: self) { $0.dismiss() } + } + deadline?.schedule(after: Self.displayDuration) + } + + func dismiss() { + deadline?.cancel() + badgeView?.hideImmediately() + badgeView?.removeFromSuperview() + } +} diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 0d8ea306f857..f2416003faf8 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -2785,7 +2785,7 @@ struct TextBoxInputContainer: View { return true case .fileURLs(let fileURLs): return attachFileURLs(fileURLs, into: textView) - case .reject: + case .reject, .rejectOversizedImage: return false } } diff --git a/Sources/TextBoxPastePreparationService.swift b/Sources/TextBoxPastePreparationService.swift index 069911d684eb..513a3fdec911 100644 --- a/Sources/TextBoxPastePreparationService.swift +++ b/Sources/TextBoxPastePreparationService.swift @@ -13,7 +13,7 @@ struct TextBoxPastePreparationService: Sendable { switch preparedContent { case .insertText(let text): return .insertText(text) - case .reject: + case .reject, .rejectOversizedImage: return .reject case .fileURLs(let fileURLs): var attachments: [TextBoxPreparedAttachment] = [] diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6a9effc1b3d9..ab6bdb2aba8a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3473,6 +3473,7 @@ B8835A000000000000000005 /* TerminalImageTransferConcurrencyTests+Timeouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835A000000000000000006 /* TerminalImageTransferConcurrencyTests+Timeouts.swift */; }; B88350000000000000000001 /* TerminalImageTransferConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88350000000000000000002 /* TerminalImageTransferConcurrencyTests.swift */; }; 124760000000000000000029 /* TerminalImageTransferPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124770000000000000000029 /* TerminalImageTransferPlan.swift */; }; + B8835B000000000000000001 /* TerminalImageTransferPreparationOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835B000000000000000002 /* TerminalImageTransferPreparationOutcome.swift */; }; B88351000000000000000003 /* TerminalImageTransferPreparationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88351000000000000000004 /* TerminalImageTransferPreparationService.swift */; }; B88351000000000000000005 /* TerminalImageTransferPreparedContent+Cleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88351000000000000000006 /* TerminalImageTransferPreparedContent+Cleanup.swift */; }; B88357000000000000000007 /* TerminalImageTransferPreparedContent+DebugDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000008 /* TerminalImageTransferPreparedContent+DebugDescription.swift */; }; @@ -3534,6 +3535,9 @@ 6819AF60AEA6455DB57CC7FE /* TerminalPanelUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 811B03E4004A4D1FB6650158 /* TerminalPanelUnavailableView.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; B88351000000000000000001 /* TerminalPasteboardReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88351000000000000000002 /* TerminalPasteboardReadRequest.swift */; }; + B8835B000000000000000003 /* TerminalPasteFailureNotice.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835B000000000000000004 /* TerminalPasteFailureNotice.swift */; }; + B8835B000000000000000005 /* TerminalPasteFailureNoticePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835B000000000000000006 /* TerminalPasteFailureNoticePresenter.swift */; }; + 4E96608F6F558E792BE96C3B /* TerminalPasteFailureNoticeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFD49B60E1A7F637C0ADFEF4 /* TerminalPasteFailureNoticeTests.swift */; }; B88355000000000000000001 /* TerminalPastePreparationDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88355000000000000000002 /* TerminalPastePreparationDestination.swift */; }; B88357000000000000000009 /* TerminalPastePreparationFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835700000000000000000A /* TerminalPastePreparationFailure.swift */; }; B88355000000000000000003 /* TerminalPastePreparationJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88355000000000000000004 /* TerminalPastePreparationJob.swift */; }; @@ -7442,6 +7446,7 @@ B8835A000000000000000006 /* TerminalImageTransferConcurrencyTests+Timeouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalImageTransferConcurrencyTests+Timeouts.swift"; sourceTree = ""; }; B88350000000000000000002 /* TerminalImageTransferConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransferConcurrencyTests.swift; sourceTree = ""; }; 124770000000000000000029 /* TerminalImageTransferPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/TerminalImageTransferPlan.swift"; sourceTree = SOURCE_ROOT; }; + B8835B000000000000000002 /* TerminalImageTransferPreparationOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransferPreparationOutcome.swift; sourceTree = ""; }; B88351000000000000000004 /* TerminalImageTransferPreparationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransferPreparationService.swift; sourceTree = ""; }; B88351000000000000000006 /* TerminalImageTransferPreparedContent+Cleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalImageTransferPreparedContent+Cleanup.swift"; sourceTree = ""; }; B88357000000000000000008 /* TerminalImageTransferPreparedContent+DebugDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalImageTransferPreparedContent+DebugDescription.swift"; sourceTree = ""; }; @@ -7503,6 +7508,9 @@ 811B03E4004A4D1FB6650158 /* TerminalPanelUnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelUnavailableView.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; B88351000000000000000002 /* TerminalPasteboardReadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPasteboardReadRequest.swift; sourceTree = ""; }; + B8835B000000000000000004 /* TerminalPasteFailureNotice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPasteFailureNotice.swift; sourceTree = ""; }; + B8835B000000000000000006 /* TerminalPasteFailureNoticePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPasteFailureNoticePresenter.swift; sourceTree = ""; }; + AFD49B60E1A7F637C0ADFEF4 /* TerminalPasteFailureNoticeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalPasteFailureNoticeTests.swift"; sourceTree = ""; }; B88355000000000000000002 /* TerminalPastePreparationDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPastePreparationDestination.swift; sourceTree = ""; }; B8835700000000000000000A /* TerminalPastePreparationFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPastePreparationFailure.swift; sourceTree = ""; }; B88355000000000000000004 /* TerminalPastePreparationJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPastePreparationJob.swift; sourceTree = ""; }; @@ -9258,6 +9266,9 @@ B8835A000000000000000002 /* TerminalClipboardInputSequencer+State.swift */, B88357000000000000000008 /* TerminalImageTransferPreparedContent+DebugDescription.swift */, B8835700000000000000000A /* TerminalPastePreparationFailure.swift */, + B8835B000000000000000006 /* TerminalPasteFailureNoticePresenter.swift */, + B8835B000000000000000004 /* TerminalPasteFailureNotice.swift */, + B8835B000000000000000002 /* TerminalImageTransferPreparationOutcome.swift */, B88357000000000000000010 /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift */, B88357000000000000000012 /* GhosttyApp+RuntimeClipboardRead.swift */, B88357000000000000000014 /* TerminalClipboardRequestSurfaceIdentity.swift */, @@ -12161,6 +12172,7 @@ AA11C5ADC15512A75E032CCA /* TerminalSurfaceTestTeardown.swift */, 8E40AA0639D63F947BB321B4 /* UpdateRelaunchBlockersTests.swift */, A634CA3FCA39C6F02FA78DC7 /* UpdateRelaunchAgentResumeTests.swift */, + AFD49B60E1A7F637C0ADFEF4 /* TerminalPasteFailureNoticeTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -14921,6 +14933,7 @@ C86270000000000000000001 /* TerminalHTMLFileBrowserAction.swift in Sources */, A5001542 /* TerminalImageTransfer.swift in Sources */, 124760000000000000000029 /* TerminalImageTransferPlan.swift in Sources */, + B8835B000000000000000001 /* TerminalImageTransferPreparationOutcome.swift in Sources */, B88351000000000000000003 /* TerminalImageTransferPreparationService.swift in Sources */, B88351000000000000000005 /* TerminalImageTransferPreparedContent+Cleanup.swift in Sources */, B88357000000000000000007 /* TerminalImageTransferPreparedContent+DebugDescription.swift in Sources */, @@ -14968,6 +14981,8 @@ 6819AF60AEA6455DB57CC7FE /* TerminalPanelUnavailableView.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, B88351000000000000000001 /* TerminalPasteboardReadRequest.swift in Sources */, + B8835B000000000000000003 /* TerminalPasteFailureNotice.swift in Sources */, + B8835B000000000000000005 /* TerminalPasteFailureNoticePresenter.swift in Sources */, B88355000000000000000001 /* TerminalPastePreparationDestination.swift in Sources */, B88357000000000000000009 /* TerminalPastePreparationFailure.swift in Sources */, B88355000000000000000003 /* TerminalPastePreparationJob.swift in Sources */, @@ -16650,6 +16665,7 @@ A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */, 11189010A1B2C3D4E5F60718 /* TerminalNotificationSocketAttributionTests.swift in Sources */, 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */, + 4E96608F6F558E792BE96C3B /* TerminalPasteFailureNoticeTests.swift in Sources */, B12715000000000000000001 /* TerminalPastePreparationReapingTests.swift in Sources */, C12758000000000000000010 /* TerminalPasteWorkerStartupBenchmark.swift in Sources */, C12758000000000000000006 /* TerminalPlainTextPastePTYTests.swift in Sources */, diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index e0b70977c2e3..d717784d18b6 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -10302,7 +10302,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { case .insertText(let text): textView.insertText(text, replacementRange: textView.selectedRange()) return true - case .reject: + case .reject, .rejectOversizedImage: return false } } diff --git a/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift b/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift index 3ac6c27111cb..6b5ab46d1bf5 100644 --- a/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift +++ b/cmuxTests/TerminalImageTransferConcurrencyTests+Timeouts.swift @@ -284,6 +284,76 @@ extension TerminalImageTransferConcurrencyTests { ) } + @MainActor + @Test("a timed-out paste reports the deadline to its caller and still beeps") + func timedOutPasteReportsDeadlineToCaller() async { + let operation = ControlledPastePreparationOperation() + let deadlines = ControlledPastePreparationDeadlines() + let failures = PastePreparationFailureProbe() + let service = TerminalImageTransferPreparationService( + deadline: .seconds(30), + deadlineSleep: { _ in try await deadlines.sleep() }, + admissionSignal: { operation.signalAdmission($0) }, + operation: { try await operation.run($0) }, + cleanup: { _ in }, + failureSignal: { failures.record($0) } + ) + var started = operation.startedEvents().makeAsyncIterator() + var reportedFailures = failures.events().makeAsyncIterator() + let (pasteboard, request) = makeReadRequest(label: "timeout-notice") + defer { + pasteboard.clearContents() + pasteboard.releaseGlobally() + } + + let task = Task { + await service.prepareReportingFailure(request: request, mode: .paste) + } + await deadlines.waitForArrivalCount(1) + #expect(await started.next() == request.pasteboardName) + #expect(await deadlines.fireNext()) + + let outcome = await task.value + #expect(outcome.content == .reject) + #expect(outcome.failure == .deadlineExceeded) + // The failure signal (the beep in the app) still fires. + #expect(await reportedFailures.next() == .deadlineExceeded) + #expect(TerminalPasteFailureNotice.notice(for: outcome) == .timedOut) + } + + @MainActor + @Test("a completed paste reports no failure to its caller") + func completedPasteReportsNoFailure() async { + let operation = ControlledPastePreparationOperation() + let deadlines = ControlledPastePreparationDeadlines() + let service = TerminalImageTransferPreparationService( + deadline: .seconds(30), + deadlineSleep: { _ in try await deadlines.sleep() }, + admissionSignal: { operation.signalAdmission($0) }, + operation: { try await operation.run($0) }, + cleanup: { _ in }, + failureSignal: { _ in } + ) + var started = operation.startedEvents().makeAsyncIterator() + let (pasteboard, request) = makeReadRequest(label: "completed-notice") + defer { + pasteboard.clearContents() + pasteboard.releaseGlobally() + } + + let task = Task { + await service.prepareReportingFailure(request: request, mode: .paste) + } + await deadlines.waitForArrivalCount(1) + #expect(await started.next() == request.pasteboardName) + await operation.release(request.pasteboardName) + + let outcome = await task.value + #expect(outcome.content == .insertText(request.pasteboardName)) + #expect(outcome.failure == nil) + #expect(TerminalPasteFailureNotice.notice(for: outcome) == nil) + } + @MainActor func makeReadRequest( label: String diff --git a/cmuxTests/TerminalPasteFailureNoticeTests.swift b/cmuxTests/TerminalPasteFailureNoticeTests.swift new file mode 100644 index 000000000000..829a9ca7e9b8 --- /dev/null +++ b/cmuxTests/TerminalPasteFailureNoticeTests.swift @@ -0,0 +1,122 @@ +import AppKit +import CmuxTerminal +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A Cmd+V that produces nothing used to be a beep (worker timeout) or silence +/// (image over the 10 MB cap). These pin the two cases that now also get a +/// brief notice, and that every other outcome stays as it was. +@MainActor +@Suite(.serialized) +struct TerminalPasteFailureNoticeTests { + @Test("an oversized pasteboard image is rejected distinctly on paste") + func oversizedImagePasteIsDistinct() throws { + let (pasteboard, directory, service) = try makeOversizedImagePasteboard() + defer { + pasteboard.clearContents() + pasteboard.releaseGlobally() + try? FileManager.default.removeItem(at: directory) + } + + let prepared = TerminalImageTransferPlanner.prepareSynchronously( + pasteboard: pasteboard, + mode: .paste, + pasteboardService: service + ) + + #expect(prepared == .rejectOversizedImage) + #expect(prepared.isRejection) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + // It plans and prepares for the composer exactly like any rejection. + #expect(TerminalImageTransferPlanner.plan(preparedContent: prepared, target: .local) == .reject) + #expect(TextBoxPastePreparationService().prepare(preparedContent: prepared) == .reject) + } + + @Test("an oversized image drop keeps the plain rejection") + func oversizedImageDropIsUnchanged() throws { + let (pasteboard, directory, service) = try makeOversizedImagePasteboard() + defer { + pasteboard.clearContents() + pasteboard.releaseGlobally() + try? FileManager.default.removeItem(at: directory) + } + + let prepared = TerminalImageTransferPlanner.prepareSynchronously( + pasteboard: pasteboard, + mode: .drop, + pasteboardService: service + ) + + #expect(prepared == .reject) + } + + @Test("only an oversized image and a worker timeout get a notice") + func noticeMapping() { + func notice( + _ content: TerminalImageTransferPreparedContent, + _ failure: TerminalPastePreparationFailure? + ) -> TerminalPasteFailureNotice? { + TerminalPasteFailureNotice.notice( + for: TerminalImageTransferPreparationOutcome(content: content, failure: failure) + ) + } + + #expect(notice(.rejectOversizedImage, nil) == .imageTooLarge) + #expect(notice(.reject, .deadlineExceeded) == .timedOut) + #expect(notice(.reject, nil) == nil) + #expect(notice(.reject, .cancelled) == nil) + #expect(notice(.reject, .queueFull) == nil) + #expect(notice(.reject, .workerFailed) == nil) + #expect(notice(.insertText("hello"), nil) == nil) + #expect(notice(.fileURLs([URL(fileURLWithPath: "/tmp/x.png")]), nil) == nil) + } + + @Test("both notices have text") + func noticeMessages() { + #expect(!TerminalPasteFailureNotice.imageTooLarge.message.isEmpty) + #expect(!TerminalPasteFailureNotice.timedOut.message.isEmpty) + #expect(TerminalPasteFailureNotice.imageTooLarge.message != TerminalPasteFailureNotice.timedOut.message) + } + + @Test("the notice is a non-interactive badge over the terminal until dismissed") + func presenterShowsAndDismissesBadge() throws { + let host = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 320)) + let presenter = TerminalPasteFailureNoticePresenter() + + presenter.show(.imageTooLarge, over: host) + + let badge = try #require(host.subviews.last as? FileDropHintBadgeView) + #expect(!badge.isHidden) + #expect(badge.accessibilityLabel() == TerminalPasteFailureNotice.imageTooLarge.message) + #expect(badge.hitTest(NSPoint(x: badge.frame.midX, y: badge.frame.midY)) == nil) + #expect(host.bounds.contains(badge.frame)) + + // A second notice reuses the same badge instead of stacking another. + presenter.show(.timedOut, over: host) + #expect(host.subviews.filter { $0 is FileDropHintBadgeView }.count == 1) + #expect(badge.accessibilityLabel() == TerminalPasteFailureNotice.timedOut.message) + + presenter.dismiss() + #expect(badge.superview == nil) + #expect(badge.isHidden) + } + + private func makeOversizedImagePasteboard() throws -> (NSPasteboard, URL, TerminalPasteboardService) { + let pasteboard = NSPasteboard( + name: .init("cmux-tests-paste-notice-\(UUID().uuidString)") + ) + pasteboard.clearContents() + pasteboard.declareTypes([.png], owner: nil) + pasteboard.setData(Data(count: 10 * 1024 * 1024 + 1), forType: .png) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-paste-notice-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return (pasteboard, directory, TerminalPasteboardService(temporaryDirectory: directory)) + } +}