From 7a7f6f499f1c914859d628e9ee2271bfddbfcf06 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 27 Sep 2026 03:36:24 -0700 Subject: [PATCH] Show a brief notice when Cmd+V fails on an oversized image or a timeout A Cmd+V that produced nothing gave little to go on: an image over the 10 MB clipboard cap was dropped silently, and a paste worker that ran past its 5 s deadline only beeped. - Image materialization now reports an oversized image separately from a failed write (`rejectedOversizedImagePayload`), and terminal paste preparation carries it as `rejectOversizedImage`. Every consumer treats it exactly like `reject`; drops keep the plain rejection. - `TerminalImageTransferPreparationService.prepareReportingFailure` returns the prepared content plus the preparation failure, so the Cmd+V path can see a deadline. The failure signal (the beep) is unchanged. - The Cmd+V path shows "Image is larger than 10 MB" or "Paste timed out" for three seconds over the pasting terminal, using the warning badge that rejected drops already show (FileDropHintBadgeView). It is posted to VoiceOver, ignores hit testing and never takes focus. No other failure changes. - Both strings are localized in all nine app locales. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...steboardService+ImageMaterialization.swift | 6 +- .../TerminalPasteboardServiceTests.swift | 14 +- ...TerminalImageFileListMaterialization.swift | 7 +- .../TerminalImageFileMaterialization.swift | 11 +- Resources/Localizable.xcstrings | 118 +++++++++++++++++ Sources/GhosttyApp+RuntimeClipboardRead.swift | 21 ++- .../GhosttyNSView+PreparedImageTransfer.swift | 2 +- Sources/GhosttyTerminalView.swift | 11 +- Sources/TerminalImageTransfer.swift | 33 ++++- ...minalImageTransferPreparationOutcome.swift | 7 + ...minalImageTransferPreparationService.swift | 16 ++- ...sferPreparedContent+DebugDescription.swift | 2 + Sources/TerminalPasteFailureNotice.swift | 38 ++++++ .../TerminalPasteFailureNoticePresenter.swift | 53 ++++++++ Sources/TextBoxInput.swift | 2 +- Sources/TextBoxPastePreparationService.swift | 2 +- cmux.xcodeproj/project.pbxproj | 16 +++ .../AppDelegateShortcutRoutingTests.swift | 2 +- ...ageTransferConcurrencyTests+Timeouts.swift | 70 ++++++++++ .../TerminalPasteFailureNoticeTests.swift | 122 ++++++++++++++++++ 20 files changed, 529 insertions(+), 24 deletions(-) create mode 100644 Sources/TerminalImageTransferPreparationOutcome.swift create mode 100644 Sources/TerminalPasteFailureNotice.swift create mode 100644 Sources/TerminalPasteFailureNoticePresenter.swift create mode 100644 cmuxTests/TerminalPasteFailureNoticeTests.swift 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 5c3daff98f22..954ccf8faf98 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift @@ -315,14 +315,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 4e2f2d296a22..33229b211caa 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -561905,6 +561905,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 4f10f9fc62e9..a0d48839b924 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 */; }; @@ -7440,6 +7444,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 = ""; }; @@ -7501,6 +7506,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 = ""; }; @@ -9254,6 +9262,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 */, @@ -12155,6 +12166,7 @@ DA19B32D9CDA472886E0646F /* SessionSnapshotOverwriteGuardAppTests.swift */, AA11C5ADC15512A75E032CCA /* TerminalSurfaceTestTeardown.swift */, A634CA3FCA39C6F02FA78DC7 /* UpdateRelaunchAgentResumeTests.swift */, + AFD49B60E1A7F637C0ADFEF4 /* TerminalPasteFailureNoticeTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -14915,6 +14927,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 */, @@ -14962,6 +14975,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 */, @@ -16643,6 +16658,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 356f14ea0592..f695eb3e77df 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)) + } +}