A file copied in Finder becomes a card's attachment — ⌘V's reserved clause finally answers

The image branch's precedence ladder always had a second clause: a file URL on the
pasteboard suppresses it, "a different gesture with a different answer" that the
code deliberately declined rather than guessed at. This fills it in: one or more
file URLs paste through the same `importAttachments` a Finder drop takes — one
collision ladder, one folder refusal (`FinderDrop.partition`), one set of banners
— outranking raw image data riding beside it (a Finder-copied image file carries
both; the actual file lands, not a re-encoded copy of its bytes) while still
deferring to the app's own clipboard type. Both ⌘V surfaces read the same
`ClipboardStore.fileURLPayload`, so the board's fallback and the card window's own
branch stay in step by construction rather than by two hand-kept-in-sync checks.

Fixed a real leak in the body editor's paste yield along the way: `public.file-url`
conforms to `public.url`, which `NSTextView` legitimately reads for a pasted
hyperlink, and `NSPasteboard.availableType(from:)` matches by conformance rather
than exact type — so a Finder copy carrying a generic URL representation beside
its file URL would have been silently swallowed as text and never reached the
window's attachment branch at all. The yield now declines outright on any
file-URL pasteboard before the generic capability check runs.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 08:38:07 -04:00
parent d0c546179f
commit 0fe92bdf38
9 changed files with 565 additions and 38 deletions
+335
View File
@@ -0,0 +1,335 @@
import Foundation
import Testing
import UniformTypeIdentifiers
@testable import Kanban
/// **Pasting a file URL as an attachment** the image-data branch's reserved clause 2, filled
/// (04-interactions.md Clipboard, ruled 2026-08-09; follow-up to the paste-image card, 92a9ea6a).
/// A Finder copy of one or more files pastes them into the anchor card's `attachments/` through the
/// same `BoardStore.importAttachments` path a Finder drop takes: one collision ladder, one folder
/// refusal (via `FinderDrop.partition`), one set of banners.
///
/// Like every other write suite here the landing tests drive a **real store over a real temp board**
/// and read back through the loader or the raw bytes, never through a snapshot the store handed out.
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `FakePasteboard`,
/// `ClipboardHarness`, `makeClipboardHarness` and the board fixture come from `ClipboardTests.swift`;
/// `encodedImage` comes from `PasteImageTests.swift`.
// MARK: - Sources on disk
/// Real files (and folders) **outside the board** for a fake pasteboard's file URLs to point at a
/// file URL is a reference to something the filesystem actually has, unlike the image branch's raw
/// bytes, and `FinderDrop.partition`'s directory check reads the real filesystem rather than a
/// declared type. `DropSources`' own shape, one file over (`FileDropWriteTests.swift`).
private struct PasteFileSources {
let root: URL
init() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("PasteFileTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: root)
}
@discardableResult
func file(_ relativePath: String, _ bytes: Data = Data([0x01])) throws -> URL {
let url = root.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try bytes.write(to: url)
return url
}
@discardableResult
func folder(_ relativePath: String) throws -> URL {
let url = root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
}
/// Attachment names as the loader sees them never the store's snapshot, which a write deliberately
/// does not touch (the one-way flow). `PasteImageWriteTests`' own helper, one file over.
@MainActor
private func fileTestAttachments(_ card: String, in fixture: WriterFixture) throws -> [String] {
let model = try BoardLoader.load(boardRoot: fixture.root).model
for lane in model.lanes {
if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments }
}
return []
}
// MARK: - Classification
@Suite("PastedImage ▸ carriesFileURL, a web URL is not a file")
struct FileURLPresenceTests {
/// `public.url` is the conforms-*from* direction every file URL is one, not the reverse so a
/// browser's Copy Link never trips the branch a Finder copy does.
@Test("A plain web URL does not conform to public.file-url")
func webURLsDoNotCount() {
#expect(!PastedImage.carriesFileURL([UTType.url.identifier]))
}
}
// MARK: - Where a file lands on the board
@MainActor
@Suite("Paste ▸ files into a card")
struct PasteFileWriteTests {
@Test("A Finder-copied file lands under its own name in the anchor card")
func aFileLands() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
harness.pasteboard.seedFileURLs([shot])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteFiles(into: harness.store))
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["shot.png"])
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png") == Data([0x89, 0x50]))
#expect(try Data(contentsOf: shot) == Data([0x89, 0x50]), "a copy's origin, never its casualty")
#expect(harness.store.banners.oneShots.isEmpty)
#expect(harness.store.banners.losses.isEmpty)
}
@Test("A multi-file Finder copy lands every file, in pasteboard order")
func multipleFilesLand() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
let notes = try sources.file("notes.txt", Data([0x41, 0x42]))
harness.pasteboard.seedFileURLs([shot, notes])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteFiles(into: harness.store))
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["notes.txt", "shot.png"])
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png") == Data([0x89, 0x50]))
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/notes.txt") == Data([0x41, 0x42]))
#expect(harness.store.banners.oneShots.isEmpty)
}
@Test("A name already taken climbs the Finder ladder rather than overwriting")
func collisionsRename() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let mine = Data("hand placed".utf8)
try harness.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png", mine)
let shot = try sources.file("shot.png", Data([0x02]))
harness.pasteboard.seedFileURLs([shot])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteFiles(into: harness.store))
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png") == mine)
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["shot 2.png", "shot.png"])
}
@Test("A mixed paste imports its files and names the folder it skipped")
func mixedPasteSkipsFolders() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
let directory = try sources.folder("Project")
harness.pasteboard.seedFileURLs([shot, directory])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteFiles(into: harness.store))
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["shot.png"])
#expect(!harness.fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments/Project"))
#expect(harness.store.banners.oneShots.isEmpty, "nothing failed: the folder was never attempted")
#expect(harness.store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
}
@Test("A folders-only paste writes nothing at all and only names the loss")
func foldersOnlyPasteWritesNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let directory = try sources.folder("Project")
harness.pasteboard.seedFileURLs([directory])
harness.store.select([clipboardCard2], in: .board)
#expect(!harness.clipboard.pasteFiles(into: harness.store))
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture).isEmpty)
#expect(harness.store.banners.oneShots.isEmpty)
#expect(harness.store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
}
/// The pasteboard declared `public.file-url` (so `canPasteFiles` reads true), but produced no
/// actual URL objects when asked the honest outcome is the one where the card is untouched,
/// same as the image branch's lying-pasteboard case.
@Test("A pasteboard that declares a file URL but resolves none writes nothing at all")
func aLyingPasteboardWritesNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.fileURL.identifier, Data())])
harness.clipboard.refresh()
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.canPasteFiles(into: harness.store))
#expect(!harness.clipboard.pasteFiles(into: harness.store))
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture).isEmpty)
#expect(harness.store.banners.oneShots.isEmpty)
#expect(harness.store.banners.losses.isEmpty)
}
/// The card window's branch: the target is the window's own card, whatever the board's selection
/// happens to be `PasteImageWriteTests.theCardWindowsOwnCard`'s own shape.
@Test("The card window pastes onto its own card, not onto the board's selection")
func theCardWindowsOwnCard() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let shot = try sources.file("shot.png")
harness.pasteboard.seedFileURLs([shot])
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.pasteFiles(intoCard: clipboardCard4, in: harness.store))
#expect(try fileTestAttachments(Ident.card4, in: harness.fixture) == ["shot.png"])
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture).isEmpty)
}
/// **The recorded precedence call**: a Finder-copied image file carries both a file URL and an
/// image flavor on the same pasteboard. The file branch wins the actual file lands, byte for
/// byte, rather than a re-encoded "Pasted Image.png" beside it which is `imagePayload == nil`
/// whenever `fileURLPayload` is true, `refresh()`'s own reading of `PastedImage.flavor`'s clause
/// order.
@Test("A file URL outranks image data riding beside it — the actual file lands, not a picture copy")
func fileURLOutranksImageData() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let png = encodedImage(.png)
let photo = try sources.file("photo.png", png)
harness.pasteboard.seedFileURLs([photo], also: [(UTType.png.identifier, png)])
harness.clipboard.refresh()
harness.store.select([clipboardCard2], in: .board)
#expect(harness.clipboard.fileURLPayload)
#expect(harness.clipboard.imagePayload == nil, "the image branch defers to the file URL")
#expect(harness.clipboard.canPasteFiles(into: harness.store))
#expect(!harness.clipboard.canPasteImage(into: harness.store))
#expect(harness.clipboard.pasteFiles(into: harness.store))
#expect(try fileTestAttachments(Ident.card2, in: harness.fixture) == ["photo.png"])
#expect(try harness.fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/photo.png") == png)
}
}
// MARK: - Validation
@MainActor
@Suite("Paste ▸ file menu validation")
struct PasteFileValidationTests {
@Test("The board branch needs a file URL and a card to put it on")
func theBoardBranchsClauses() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let shot = try sources.file("shot.png")
harness.store.select([clipboardCard1], in: .board)
#expect(!harness.clipboard.canPasteFiles(into: harness.store), "nothing on the pasteboard")
harness.pasteboard.seedFileURLs([shot])
harness.clipboard.refresh()
#expect(harness.clipboard.canPasteFiles(into: harness.store))
// A lane anchors no card, so the row greys out and so does the paste.
harness.store.select([clipboardLane1], in: .board)
#expect(!harness.clipboard.canPasteFiles(into: harness.store))
#expect(!harness.clipboard.pasteFiles(into: harness.store))
harness.store.clearSelection()
#expect(!harness.clipboard.canPasteFiles(into: harness.store))
}
@Test("A file URL offers the file branch even though it silences the picture branch")
func fileURLOffersItsOwnBranch() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let shot = try sources.file("shot.png")
harness.pasteboard.seedFileURLs([shot], also: [(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
harness.store.select([clipboardCard1], in: .board)
#expect(harness.clipboard.fileURLPayload)
#expect(harness.clipboard.imagePayload == nil)
#expect(harness.clipboard.canPasteFiles(into: harness.store))
#expect(!harness.clipboard.canPasteImage(into: harness.store))
#expect(!harness.clipboard.canPasteBoardBackground(into: harness.store), "a file URL suppresses this too")
}
@Test("The card-window branch refuses a card that is not on the board side")
func theCardWindowBranchsClauses() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let sources = try PasteFileSources()
defer { sources.tearDown() }
let shot = try sources.file("shot.png")
harness.pasteboard.seedFileURLs([shot])
harness.clipboard.refresh()
#expect(harness.clipboard.canPasteFiles(intoCard: clipboardCard1, in: harness.store))
#expect(!harness.clipboard.canPasteFiles(intoCard: clipboardCard3, in: harness.store), "trashed")
#expect(!harness.clipboard.canPasteFiles(intoCard: clipboardLane1, in: harness.store), "a lane")
}
/// Clause 1 of the precedence, proven rather than assumed `PastedImageClassificationTests
/// .boardItemsWin`'s own claim, one surface over. Synthetic (`layerForeign`'s doc), because a real
/// copy's `clearContents()` would never leave a file URL beside the app's own type in the first
/// place the guard exists so that if it ever did, the board payload would still win.
@Test("A board payload on the pasteboard outranks a file URL riding beside it")
func boardPayloadOutranksFileURL() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
harness.pasteboard.layerForeign([(UTType.fileURL.identifier, Data("file:///tmp/shot.png".utf8))])
harness.clipboard.refresh()
#expect(!harness.clipboard.fileURLPayload)
#expect(harness.clipboard.canPaste(into: harness.store))
#expect(!harness.clipboard.canPasteFiles(into: harness.store))
}
@Test("A non-file URL (a browser's Copy Link) offers nothing to either attachment branch")
func nonFileURLsOfferNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.url.identifier, Data("https://example.com".utf8))])
harness.clipboard.refresh()
harness.store.select([clipboardCard1], in: .board)
#expect(!harness.clipboard.fileURLPayload)
#expect(!harness.clipboard.canPasteFiles(into: harness.store))
#expect(!harness.clipboard.canPasteImage(into: harness.store))
}
}