Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 116 additions & 34 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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 }
return types.contains(.tiff) || types.contains(.png)
}
let attributed = try? NSAttributedString(
data: data,
options: [
.documentType: documentType,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)

/// 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 ?? []
let sanitized = attributed?.string
.split(separator: objectReplacementCharacter, omittingEmptySubsequences: false)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)

// 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 }
guard let sanitized, !sanitized.isEmpty else { return nil }
return sanitized
}

// 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 }
private static func hasImageData(in pasteboard: NSPasteboard) -> Bool {
let types = pasteboard.types ?? []
return types.contains(.tiff) || types.contains(.png)
}

guard pngData.count <= maxClipboardImageSize else {
private static func htmlHasNoVisibleText(_ html: String) -> Bool {
let withoutComments = html.replacingOccurrences(
of: "<!--[\\s\\S]*?-->",
with: " ",
options: .regularExpression
)
let withoutTags = withoutComments.replacingOccurrences(
of: "<[^>]+>",
with: " ",
options: .regularExpression
)
let normalized = withoutTags
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&#160;", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty
}

/// 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 }

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"
}

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
}
Expand All @@ -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)")
Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
55 changes: 55 additions & 0 deletions cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>Hello <strong>world</strong></p>", 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("<meta charset='utf-8'><img src=\"https://example.com/keyboard.png\">", 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("<p>Hello <img src=\"https://example.com/keyboard.png\"></p>", 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 {
Expand Down
Loading