From acd09c0a6cdda07a073047087388c49a76d8fd8c Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 3 Jul 2026 10:34:21 +0200 Subject: [PATCH 1/2] macos: add tests for NSPasteboard.getOpinionatedStringContents --- macos/Tests/NSPasteboardTests.swift | 224 +++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 1 deletion(-) diff --git a/macos/Tests/NSPasteboardTests.swift b/macos/Tests/NSPasteboardTests.swift index 9db17ca330d..9f01c235401 100644 --- a/macos/Tests/NSPasteboardTests.swift +++ b/macos/Tests/NSPasteboardTests.swift @@ -2,7 +2,8 @@ // NSPasteboardTests.swift // GhosttyTests // -// Tests for NSPasteboard.PasteboardType MIME type conversion. +// Tests for NSPasteboard.PasteboardType MIME type conversion and +// NSPasteboard.getOpinionatedStringContents. // import Testing @@ -31,3 +32,224 @@ struct NSPasteboardTypeExtensionTests { #expect(pasteboardType == .png) } } + +/// Tests for `NSPasteboard.getOpinionatedStringContents`, which per its documented +/// semantics must, for each pasteboard item: +/// - prefer the absolute filesystem path of a file URL, shell-escaped, +/// - otherwise fall back to any plain string on the item, +/// and return nil when nothing usable is found. Multiple results join with a space. +struct NSPasteboardOpinionatedContentsTests { + // MARK: - Test Helpers + + /// Creates a uniquely-named pasteboard so tests never touch the user's + /// general pasteboard and can run concurrently. + private func makePasteboard() -> NSPasteboard { + let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)")) + pasteboard.clearContents() + return pasteboard + } + + /// Builds an item carrying a plain string (public.utf8-plain-text). + private func stringItem(_ string: String) -> NSPasteboardItem { + let item = NSPasteboardItem() + item.setString(string, forType: .string) + return item + } + + /// Builds an item carrying a file URL (public.file-url). The string stored on + /// the pasteboard is the URL string form, e.g. "file:///Users/test%20file.txt", + /// which is exactly what AppKit registers when a file URL is copied. + private func fileURLItem(_ urlString: String) -> NSPasteboardItem { + let item = NSPasteboardItem() + item.setString(urlString, forType: .fileURL) + return item + } + + // MARK: - Plain String Contents + + @Test func testSingleStringItem() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([stringItem("hello world")]) + + #expect(pasteboard.getOpinionatedStringContents() == "hello world") + } + + @Test func testMultipleStringItemsJoinedWithSpace() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([stringItem("first"), stringItem("second")]) + + #expect(pasteboard.getOpinionatedStringContents() == "first second") + } + + /// A remote URL that is present as plain text is returned verbatim, not + /// treated as a file. + @Test func testStringContainingRemoteURL() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([stringItem("https://example.com/page")]) + + #expect(pasteboard.getOpinionatedStringContents() == "https://example.com/page") + } + + // MARK: - File URL Contents + + /// A file URL must produce the absolute filesystem path, not the URL string. + @Test func testSingleFileURL() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([fileURLItem("file:///Users/test/document.txt")]) + + #expect(pasteboard.getOpinionatedStringContents() == "/Users/test/document.txt") + } + + /// Percent-encoded characters must be decoded to the real path, and + /// shell-sensitive characters escaped for insertion into a terminal buffer. + @Test func testFileURLWithCharactersNeedingEscaping() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([fileURLItem("file:///Users/test/my%20file%20(1).txt")]) + + #expect(pasteboard.getOpinionatedStringContents() == #"/Users/test/my\ file\ \(1\).txt"#) + } + + @Test func testMultipleFileURLsJoinedWithSpace() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([ + fileURLItem("file:///Users/test/a.txt"), + fileURLItem("file:///Users/test/b.txt"), + ]) + + #expect(pasteboard.getOpinionatedStringContents() == "/Users/test/a.txt /Users/test/b.txt") + } + + /// When an item carries both a file URL and a string, the file path wins. + @Test func testFileURLTakesPrecedenceOverString() { + let pasteboard = makePasteboard() + let item = NSPasteboardItem() + item.setString("file:///Users/test/document.txt", forType: .fileURL) + item.setString("document.txt", forType: .string) + pasteboard.writeObjects([item]) + + #expect(pasteboard.getOpinionatedStringContents() == "/Users/test/document.txt") + } + + @Test func testMixedFileURLAndStringItems() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([ + fileURLItem("file:///Users/test/a.txt"), + stringItem("plain text"), + ]) + + #expect(pasteboard.getOpinionatedStringContents() == "/Users/test/a.txt plain text") + } + + /// A mailto URL present as plain text is returned verbatim, like any string. + @Test func testMailtoStringReturnedVerbatim() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([stringItem("mailto:exam@ple.com")]) + + #expect(pasteboard.getOpinionatedStringContents() == "mailto:exam@ple.com") + } + + /// A non-file URL stored under the file-URL type must not be treated as a + /// filesystem path (its path would be empty); the string fallback wins. + @Test func testMailtoUnderFileURLTypeFallsBackToString() { + let pasteboard = makePasteboard() + let item = NSPasteboardItem() + item.setString("mailto:exam@ple.com", forType: .fileURL) + item.setString("exam@ple.com", forType: .string) + pasteboard.writeObjects([item]) + + #expect(pasteboard.getOpinionatedStringContents() == "exam@ple.com") + } + + // MARK: - Remote File Promises + + /// Builds an item mimicking a remote-file drag (e.g. Panic Transmit/Nova): + /// file-promise metadata plus a remote public.url, but no public.file-url. + private func remoteFilePromiseItem(url urlString: String, string: String?) -> NSPasteboardItem { + let item = NSPasteboardItem() + item.setString("file.txt", forType: .init("com.apple.pasteboard.promised-file-name")) + item.setString("public.data", forType: .init("com.apple.pasteboard.promised-file-content-type")) + item.setData(Data([0x00]), forType: .init("com.apple.NSFilePromiseItemMetaData")) + item.setData(Data([0x00]), forType: .init("com.apple.pasteboard.NSFilePromiseID")) + item.setString(urlString, forType: .init("public.url")) + if let string { + item.setString(string, forType: .string) + } + return item + } + + /// A remote file promise has no local filesystem path, so the item's plain + /// string is returned as-is: no file-path treatment, no shell escaping. + @Test func testRemoteFilePromiseFallsBackToString() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([ + remoteFilePromiseItem( + url: "sftp://example.com/remote%20dir/file.txt", + string: "sftp://example.com/remote%20dir/file.txt" + ), + ]) + + #expect(pasteboard.getOpinionatedStringContents() == "sftp://example.com/remote%20dir/file.txt") + } + + @Test func testMultipleRemoteFilePromisesJoinedWithSpace() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([ + remoteFilePromiseItem(url: "sftp://example.com/a.txt", string: "sftp://example.com/a.txt"), + remoteFilePromiseItem(url: "sftp://example.com/b.txt", string: "sftp://example.com/b.txt"), + ]) + + #expect(pasteboard.getOpinionatedStringContents() == "sftp://example.com/a.txt sftp://example.com/b.txt") + } + + /// A promise item that offers no plain string (only promise metadata and a + /// remote URL) contributes nothing. + @Test func testRemoteFilePromiseWithoutStringReturnsNil() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([ + remoteFilePromiseItem(url: "sftp://example.com/file.txt", string: nil), + ]) + + #expect(pasteboard.getOpinionatedStringContents() == nil) + } + + /// A local file drag next to a remote promise: the local item yields its + /// escaped path, the remote one its string. + @Test func testMixedLocalFileAndRemoteFilePromise() { + let pasteboard = makePasteboard() + pasteboard.writeObjects([ + fileURLItem("file:///Users/test/local.txt"), + remoteFilePromiseItem(url: "sftp://example.com/remote.txt", string: "sftp://example.com/remote.txt"), + ]) + + #expect(pasteboard.getOpinionatedStringContents() == "/Users/test/local.txt sftp://example.com/remote.txt") + } + + // MARK: - Nothing Usable + + @Test func testEmptyPasteboardReturnsNil() { + let pasteboard = makePasteboard() + + #expect(pasteboard.getOpinionatedStringContents() == nil) + } + + /// An item with only a binary type has no string or file path to offer. + @Test func testNonStringItemReturnsNil() { + let pasteboard = makePasteboard() + let item = NSPasteboardItem() + item.setData(Data([0x89, 0x50, 0x4e, 0x47]), forType: .png) + pasteboard.writeObjects([item]) + + #expect(pasteboard.getOpinionatedStringContents() == nil) + } + + /// A remote URL item (public.url, no file URL and no string rep) is dropped: + /// only file URLs are read from the clipboard. + @Test func testRemoteURLOnlyItemReturnsNil() { + let pasteboard = makePasteboard() + let item = NSPasteboardItem() + item.setString("https://example.com/page", forType: .init("public.url")) + pasteboard.writeObjects([item]) + + #expect(pasteboard.getOpinionatedStringContents() == nil) + } +} From 49806fc4cca56b8edaef18c8ccaae6bf26ac424b Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:45:16 +0200 Subject: [PATCH 2/2] macOS: read string contents per pasteboard item in order Pasteboards mixing file URLs with other items will now be pasted as joined string. --- .../Extensions/NSPasteboard+Extension.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index a54735fde21..9dbed46141f 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -38,14 +38,20 @@ extension NSPasteboard { /// - Tries to get any string from the pasteboard. /// If all of the above fail, returns None. func getOpinionatedStringContents() -> String? { - if let urls = readObjects(forClasses: [NSURL.self]) as? [URL], - urls.count > 0 { - return urls - .map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString } - .joined(separator: " ") + let strings = (pasteboardItems ?? []).compactMap { item in + if let plist = item.propertyList(forType: .fileURL), + let fileURL = NSURL(pasteboardPropertyList: plist, ofType: .fileURL) as URL?, + fileURL.isFileURL { + return Ghostty.Shell.escape(fileURL.path) + } else { + return item.string(forType: .string) + } } - return self.string(forType: .string) + guard !strings.isEmpty else { + return nil + } + return strings.joined(separator: " ") } /// The pasteboard for the Ghostty enum type.