diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 65e50eaa..2bb71abf 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -75,6 +75,7 @@ private enum GhosttyPasteboardHelper { ) private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text") private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" + private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!) static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? { switch location { @@ -99,13 +100,35 @@ private enum GhosttyPasteboardHelper { return value } - return pasteboard.string(forType: utf8PlainTextType) + if let value = pasteboard.string(forType: utf8PlainTextType) { + return value + } + + if hasImageData(in: pasteboard), + let html = pasteboard.string(forType: .html), + htmlHasNoVisibleText(html) { + return nil + } + + if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) { + return htmlText + } + + if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) { + return rtfText + } + + return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd) } static func hasString(for location: ghostty_clipboard_e) -> Bool { guard let pasteboard = pasteboard(for: location) else { return false } - if let text = stringContents(from: pasteboard), !text.isEmpty { return true } - return clipboardHasImageOnly() + let types = pasteboard.types ?? [] + if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType) + || types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) { + return true + } + return hasImageData(in: pasteboard) } static func writeString(_ string: String, to location: ghostty_clipboard_e) { @@ -122,40 +145,85 @@ private enum GhosttyPasteboardHelper { return result } - private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB + private static func attributedStringContents( + from pasteboard: NSPasteboard, + type: NSPasteboard.PasteboardType, + documentType: NSAttributedString.DocumentType + ) -> String? { + let data = + pasteboard.data(forType: type) + ?? pasteboard.string(forType: type)?.data(using: .utf8) + guard let data else { return nil } - /// Quick check: does the clipboard have image data and no text? - static func clipboardHasImageOnly() -> Bool { - let pb = NSPasteboard.general - let types = pb.types ?? [] - let hasText = types.contains(.string) || types.contains(.html) - || types.contains(.rtf) || types.contains(.rtfd) - if hasText { return false } + let attributed = try? NSAttributedString( + data: data, + options: [ + .documentType: documentType, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) + + let sanitized = attributed?.string + .split(separator: objectReplacementCharacter, omittingEmptySubsequences: false) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let sanitized, !sanitized.isEmpty else { return nil } + return sanitized + } + + private static func hasImageData(in pasteboard: NSPasteboard) -> Bool { + let types = pasteboard.types ?? [] return types.contains(.tiff) || types.contains(.png) } - /// When the clipboard contains only image data (no text/HTML), saves it as - /// a temporary PNG file and returns the shell-escaped file path. Returns nil - /// if the clipboard contains text or no image. - static func saveClipboardImageIfNeeded() -> String? { - let pb = NSPasteboard.general - let types = pb.types ?? [] + private static func htmlHasNoVisibleText(_ html: String) -> Bool { + let withoutComments = html.replacingOccurrences( + of: "", + with: " ", + options: .regularExpression + ) + let withoutTags = withoutComments.replacingOccurrences( + of: "<[^>]+>", + with: " ", + options: .regularExpression + ) + let normalized = withoutTags + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: " ", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty + } - // If pasteboard has text/HTML, this is a normal copy. - let hasText = types.contains(.string) || types.contains(.html) - || types.contains(.rtf) || types.contains(.rtfd) - if hasText { return nil } + /// When the clipboard contains only image data (or rich text that resolves to + /// an attachment-only image), saves it as a temporary PNG file and returns the + /// shell-escaped file path. Returns nil if the clipboard contains text or no image. + static func saveClipboardImageIfNeeded( + from pasteboard: NSPasteboard = .general, + assumeNoText: Bool = false + ) -> String? { + if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } - // Check for image types (TIFF from screenshots, PNG from some tools). - guard types.contains(.tiff) || types.contains(.png) else { return nil } - guard let image = NSImage(pasteboard: pb), - let tiffData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + let imageData: Data + let fileExtension: String + if let pngData = pasteboard.data(forType: .png) { + imageData = pngData + fileExtension = "png" + } else { + guard hasImageData(in: pasteboard), + let image = NSImage(pasteboard: pasteboard), + let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + imageData = pngData + fileExtension = "png" + } - guard pngData.count <= maxClipboardImageSize else { + let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB + guard imageData.count <= maxClipboardImageSize else { #if DEBUG - dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)") + dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") #endif return nil } @@ -164,11 +232,11 @@ private enum GhosttyPasteboardHelper { formatter.dateFormat = "yyyy-MM-dd-HHmmss" formatter.locale = Locale(identifier: "en_US_POSIX") let timestamp = formatter.string(from: Date()) - let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png" + let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)" let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) do { - try pngData.write(to: URL(fileURLWithPath: path)) + try imageData.write(to: URL(fileURLWithPath: path)) } catch { #if DEBUG dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") @@ -180,6 +248,16 @@ private enum GhosttyPasteboardHelper { } } +#if DEBUG +func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? { + GhosttyPasteboardHelper.stringContents(from: pasteboard) +} + +func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? { + GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard) +} +#endif + enum TerminalOpenURLTarget: Equatable { case embeddedBrowser(URL) case external(URL) @@ -877,7 +955,11 @@ class GhosttyApp { // When clipboard has only image data (e.g. screenshot), save as temp // PNG and paste the file path so CLI tools can receive images. - if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() { + if value.isEmpty, + let imagePath = pasteboard.flatMap({ + GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true) + }) + { value = imagePath } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 357e4b67..a1e8d179 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -872,6 +872,61 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +@MainActor +final class GhosttyPasteboardHelperTests: XCTestCase { + func testHTMLOnlyPasteboardExtractsPlainText() { + let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello world

", forType: .html) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testImageHTMLClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.red.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".png")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testImageHTMLClipboardWithVisibleTextPrefersText() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello

", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.blue.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } +} + @MainActor final class AppDelegateWindowContextRoutingTests: XCTestCase { private func makeMainWindow(id: UUID) -> NSWindow {