Fix terminal Cmd+V clipboard payload handling (#1305)
* Add clipboard payload regression tests * Fix terminal clipboard payload handling
This commit is contained in:
parent
f95a32ea52
commit
e94daa0bcf
2 changed files with 169 additions and 32 deletions
|
|
@ -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: "<!--[\\s\\S]*?-->",
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue