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
+5 -5
View File
@@ -481,12 +481,12 @@ struct CardWindowHost: View {
.onChange(of: placement.card.hero.value, initial: true) { _, hero in .onChange(of: placement.card.hero.value, initial: true) { _, hero in
attachments.hero = hero attachments.hero = hero
} }
// **V in this window pastes a picture onto this card** (04-interactions.md Clipboard's // **V in this window pastes onto this card** (04-interactions.md Clipboard's
// image-data branch). Here rather than inside `CardWindowView` because the availability // image-data and file-URL branches). Here rather than inside `CardWindowView` because the
// is the clipboard's observable reading and this is where a store, a card id and the // availability is the clipboard's observable reading and this is where a store, a card id
// app-wide clipboard are all in scope at once the same join `configureAttachments` // and the app-wide clipboard are all in scope at once the same join `configureAttachments`
// makes for the other two writes. // makes for the other two writes.
.cardWindowImagePaste( .cardWindowPaste(
store: store, store: store,
cardID: placement.card.id, cardID: placement.card.id,
clipboard: appModel.clipboard clipboard: appModel.clipboard
+19
View File
@@ -215,6 +215,15 @@ public protocol ClipboardPasteboard: AnyObject {
/// specific one, added for the image branch (which knows its type only at runtime, off the /// specific one, added for the image branch (which knows its type only at runtime, off the
/// classification above). /// classification above).
func data(forType type: String) -> Data? func data(forType type: String) -> Data?
/// Every file URL the pasteboard's items carry, in item order the file-URL branch's own read
/// (04-interactions.md Clipboard, ruled 2026-08-09; `ClipboardStore.pasteFiles`).
///
/// A method of its own rather than another `data(forType:)` call, because `availableTypes()`'s
/// first-item carve-out is wrong here: "is a file being offered at all" only needs the first
/// item, but "which files" does not a Finder copy of several files is several pasteboard
/// items, each carrying `public.file-url` on its own, and this reads across all of them.
func fileURLs() -> [URL]
} }
/// The real pasteboard. /// The real pasteboard.
@@ -253,6 +262,16 @@ public final class SystemPasteboard: ClipboardPasteboard {
pasteboard.data(forType: NSPasteboard.PasteboardType(type)) pasteboard.data(forType: NSPasteboard.PasteboardType(type))
} }
/// `readObjects(forClasses:options:)` rather than a per-item `data(forType:)` walk: it is the
/// framework's own multi-item reconstruction of `public.file-url`, `.urlReadingFileURLsOnly`
/// keeping a web URL (`public.url`, which does not conform) from ever surfacing here.
public func fileURLs() -> [URL] {
(pasteboard.readObjects(
forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]
) as? [URL]) ?? []
}
@discardableResult @discardableResult
public func write(manifest: Data, text: String) -> Int { public func write(manifest: Data, text: String) -> Int {
pasteboard.clearContents() pasteboard.clearContents()
+85 -4
View File
@@ -91,6 +91,18 @@ public final class ClipboardStore {
/// call sites have to remember. /// call sites have to remember.
public private(set) var imagePayload: PastedImage.Flavor? public private(set) var imagePayload: PastedImage.Flavor?
/// **The file-URL branch's reading of the same pasteboard**, as of the same `refresh()` `true`
/// when there are file URLs to paste as attachments and no board payload outranks them
/// (04-interactions.md Clipboard, ruled 2026-08-09; `imagePayload`'s clause 2, finally with an
/// answer instead of a decline `PastedImage.carriesFileURL` still decides the presence).
///
/// A bare `Bool` rather than a value carrying the URLs themselves: unlike a picture's `Flavor`,
/// there is no format or name to decide ahead of the paste, so there is nothing worth caching
/// beyond "is the branch live". The URLs themselves are read fresh at paste time
/// (`ClipboardPasteboard.fileURLs()`) real work across every pasteboard item, not just the
/// first, worth doing once at the gesture rather than on every menu revalidation.
public private(set) var fileURLPayload = false
/// The staging directory public because the tests assert on what it holds after a copy, a /// The staging directory public because the tests assert on what it holds after a copy, a
/// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests. /// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests.
@ObservationIgnored public let stagingRoot: URL @ObservationIgnored public let stagingRoot: URL
@@ -439,6 +451,74 @@ public final class ClipboardStore {
return url return url
} }
// MARK: - Paste the file-URL branch
// **The image branch's reserved clause 2, finally with an answer** (04-interactions.md
// Clipboard, ruled 2026-08-09). A pasteboard carrying one or more file URLs a Finder copy,
// foremost pastes those *files themselves* into a card's `attachments/`, the same way a Finder
// drop does:
//
// - **Outranks the image branch, never the board's own payload.** `refresh()` fills `imagePayload`
// and `fileURLPayload` from the one reading that already orders them (`PastedImage.flavor`'s own
// clause order): a board payload wins outright over either, and a file URL wins over raw image
// data riding beside it a Finder-copied image file carries both, and the actual file is the
// more honest answer than a re-encoded copy of its bytes landing under a different name.
// - **The write is `BoardStore.importAttachments(_:toCard:)`, through `FinderDrop.partition`**
// the very split a Finder drag makes at the drop, applied to the paste the same way: files land,
// folders are named in a loss row and nothing about them is attempted. One collision ladder, one
// set of banners, no second folder-refusal rule to keep in step with the drop's.
// - **Multiple files, in pasteboard order.** Unlike the image branch's single flavor, a paste here
// can be a whole multi-select Finder copy `ClipboardPasteboard.fileURLs()` reads every item,
// not just the first (`availableTypes()`'s own first-item carve-out does not apply to this read).
// - **It registers no undo step and announces exactly as a drop does** the image branch's own
// reasons, unchanged: an import is not on the undo stack (13-native-undo.md Out of scope), and
// the write is app-mediated so the arrival is the row appearing and the chip counting one higher.
/// Whether V would paste one or more files into `store`'s **anchor card** the board window's
/// branch, `canPasteImage(into:)`'s own shape one clause over.
public func canPasteFiles(into store: BoardStore) -> Bool {
guard store.acceptsBoardMutations, fileURLPayload else { return false }
return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil
}
/// V's file-URL branch on the board resolves the anchor card and pastes into it.
@discardableResult
public func pasteFiles(into store: BoardStore) -> Bool {
refresh()
guard canPasteFiles(into: store),
let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot)
else { return false }
return pasteFiles(intoCard: cardID, in: store)
}
/// Whether V would paste files into this **named** card the card window's branch,
/// `canPasteImage(intoCard:in:)`'s own reasoning verbatim (the lock clause is `!store.isReadOnly`
/// rather than `acceptsBoardMutations`; the card must be on the board side, since
/// `BoardStore.importAttachments` refuses a trashed one outright).
public func canPasteFiles(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
guard !store.isReadOnly, fileURLPayload else { return false }
return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil
}
/// Pastes the pasteboard's file URLs into `cardID`'s `attachments/` `FinderDrop.land`'s write
/// half, reached from the pasteboard instead of a drag.
///
/// - Returns: whether at least one file was handed to the import path. `false` covers every way
/// this declines no file URLs, a card that is not there, or a pasteboard offering only
/// folders and every one of them writes nothing; a folders-only pasteboard still posts the
/// loss row naming what was skipped, exactly as a folders-only Finder drop does.
@discardableResult
public func pasteFiles(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
refresh()
guard canPasteFiles(intoCard: cardID, in: store) else { return false }
let (files, folders) = FinderDrop.partition(pasteboard.fileURLs())
if !files.isEmpty {
store.importAttachments(files, toCard: cardID)
}
store.banners.postSkippedFolders(count: folders.count)
return !files.isEmpty
}
// MARK: - Paste the board backdrop // MARK: - Paste the board backdrop
/// Whether Edit Paste as Board Background applies to `store` (03-board-ui.md § Styling /// Whether Edit Paste as Board Background applies to `store` (03-board-ui.md § Styling
@@ -669,10 +749,11 @@ public final class ClipboardStore {
payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:)) payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:))
// The image branch's whole precedence, applied here so it is applied once: a board payload // The image branch's whole precedence, applied here so it is applied once: a board payload
// outranks a picture, and a file URL means this is not the image branch's pasteboard at all. // outranks a picture, and a file URL means this is not the image branch's pasteboard at all.
imagePayload = PastedImage.flavor( let types = pasteboard.availableTypes()
hasBoardItems: payload != nil, imagePayload = PastedImage.flavor(hasBoardItems: payload != nil, types: types)
types: pasteboard.availableTypes() // The file-URL branch's own answer, same reading: a board payload still wins outright, and a
) // file URL is what the image branch's clause 2 defers to rather than nothing.
fileURLPayload = payload == nil && PastedImage.carriesFileURL(types)
if let cut = armedCut, payload?.copyID != cut.copyID { if let cut = armedCut, payload?.copyID != cut.copyID {
voidCut() voidCut()
} }
+5 -2
View File
@@ -26,8 +26,11 @@ import os
/// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes /// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes
/// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is /// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is
/// the ruling's own parenthesis. A file URL is a *reference* to something the user already has /// the ruling's own parenthesis. A file URL is a *reference* to something the user already has
/// filed; taking a second copy of it into `attachments/` behind their back is a different gesture /// filed; taking a second copy of it into `attachments/` behind their back would be a different
/// with a different answer, and this branch declines rather than guessing at it. /// gesture with a different answer, so this branch declines outright and `ClipboardStore`'s own
/// file-URL branch (`pasteFiles`, ruled 2026-08-09) takes the reference at its word instead,
/// importing the file itself rather than a re-encoded copy of its bytes. A Finder-copied image
/// file therefore lands as the file it is, never as a second "Pasted Image.png" beside it.
/// 3. **Raw image data is the fallback**, which is the screenshot (4), the browser's Copy Image, /// 3. **Raw image data is the fallback**, which is the screenshot (4), the browser's Copy Image,
/// and Preview's C. /// and Preview's C.
/// ///
+40 -17
View File
@@ -48,22 +48,26 @@ extension View {
.onCommand(#selector(NSText.paste(_:)), perform: Self.pasteAction(store: store, clipboard: clipboard)) .onCommand(#selector(NSText.paste(_:)), perform: Self.pasteAction(store: store, clipboard: clipboard))
} }
/// **V's two branches as one optional handler** (04-interactions.md Clipboard, the image-data /// **V's three branches as one optional handler** (04-interactions.md Clipboard, the
/// branch ruled 2026-08-09). /// image-data branch ruled 2026-08-09; the file-URL branch ruled the same day).
/// ///
/// The precedence is expressed as the order of these two `if`s and nowhere else, which is the /// The precedence is expressed as the order of these three `if`s and nowhere else, which is the
/// same discipline the rest of this file states: availability *is* the handler's presence, so a /// same discipline the rest of this file states: availability *is* the handler's presence, so a
/// board payload winning over a picture is one expression rather than a condition on one item and /// board payload winning over a file, and a file winning over a picture, is three expressions in
/// a matching negation on another. `ClipboardStore.refresh` has already made the two readings /// order rather than a condition on one item and matching negations on the other two.
/// mutually exclusive at the source (`imagePayload` is `nil` whenever a board payload is /// `ClipboardStore.refresh` has already made the three readings mutually exclusive at the source
/// readable), so this ordering is belt over braces but it is the ordering a reader will look /// (`imagePayload` and `fileURLPayload` are never both live, and neither is while a board payload
/// is readable), so this ordering is belt over braces but it is the ordering a reader will look
/// for, and stating it here costs one line. /// for, and stating it here costs one line.
/// ///
/// `nil` neither branch applies greys the standard Paste row out exactly as before. /// `nil` no branch applies greys the standard Paste row out exactly as before.
private static func pasteAction(store: BoardStore, clipboard: ClipboardStore) -> (() -> Void)? { private static func pasteAction(store: BoardStore, clipboard: ClipboardStore) -> (() -> Void)? {
if clipboard.canPaste(into: store) { if clipboard.canPaste(into: store) {
return { clipboard.paste(into: store) } return { clipboard.paste(into: store) }
} }
if clipboard.canPasteFiles(into: store) {
return { clipboard.pasteFiles(into: store) }
}
if clipboard.canPasteImage(into: store) { if clipboard.canPasteImage(into: store) {
return { clipboard.pasteImage(into: store) } return { clipboard.pasteImage(into: store) }
} }
@@ -75,12 +79,17 @@ extension View {
extension View { extension View {
/// **V in a card window pastes a picture into that card** (04-interactions.md Clipboard, the /// **V in a card window pastes onto that card** (04-interactions.md Clipboard, the image-data
/// image-data branch; 05-card-window.md Attachments). /// and file-URL branches; 05-card-window.md Attachments) the file branch first, the picture
/// branch behind it, `pasteAction`'s own precedence one window over.
/// ///
/// The board's own responder shape, one window over and with one branch instead of two: there is /// The board's own responder shape, one window over and with two branches instead of three: there
/// no board payload a card window could paste cards and lanes land on a *board* so the card /// is no board payload a card window could paste cards and lanes land on a *board* so the card
/// window answers `paste:` only for the image branch, and only while there is a picture to take. /// window answers `paste:` only for the two attachment branches, and only while one of them has
/// something to take. **One combined handler, not two `.onCommand`s for the same selector**: the
/// precedence has to be one expression for the same reason `pasteAction` is, and layering a second
/// responder over the same selector would leave the ordering to however SwiftUI happened to chain
/// them rather than to this file.
/// ///
/// **A focused text field still wins, with nothing here doing the arithmetic.** `NSTextView` /// **A focused text field still wins, with nothing here doing the arithmetic.** `NSTextView`
/// consumes `paste:` natively, so V in the body editor, the comment composer or an inline /// consumes `paste:` natively, so V in the body editor, the comment composer or an inline
@@ -89,14 +98,28 @@ extension View {
/// It is also why this hangs on the window's whole content rather than on the attachments /// It is also why this hangs on the window's whole content rather than on the attachments
/// section: 05 makes the *window* the drop surface for files, and the paste is that sentence's /// section: 05 makes the *window* the drop surface for files, and the paste is that sentence's
/// keyboard twin. /// keyboard twin.
func cardWindowImagePaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View { func cardWindowPaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
onCommand( onCommand(
#selector(NSText.paste(_:)), #selector(NSText.paste(_:)),
perform: clipboard.canPasteImage(intoCard: cardID, in: store) ? { perform: Self.cardWindowPasteAction(store: store, cardID: cardID, clipboard: clipboard)
clipboard.pasteImage(intoCard: cardID, in: store)
} : nil
) )
} }
/// The card window's own `pasteAction` the file branch outranking the picture branch, exactly
/// as `refresh()` orders them.
private static func cardWindowPasteAction(
store: BoardStore,
cardID: ItemID,
clipboard: ClipboardStore
) -> (() -> Void)? {
if clipboard.canPasteFiles(intoCard: cardID, in: store) {
return { clipboard.pasteFiles(intoCard: cardID, in: store) }
}
if clipboard.canPasteImage(intoCard: cardID, in: store) {
return { clipboard.pasteImage(intoCard: cardID, in: store) }
}
return nil
}
} }
// MARK: - Edit Paste as Board Background // MARK: - Edit Paste as Board Background
+25 -9
View File
@@ -409,8 +409,8 @@ final class CardBodyTextView: NSTextView {
// MARK: The paste yield // MARK: The paste yield
/// **A pasteboard this editor cannot read is never the editor's either** the drop rule above, /// **A pasteboard this editor cannot read is never the editor's either** the drop rule above,
/// arrived at the keyboard (04-interactions.md Clipboard, the image-data branch; 05 /// arrived at the keyboard (04-interactions.md Clipboard, the image-data and file-URL branches;
/// Attachments makes the *window* answer V with an attachment import). /// 05 Attachments makes the *window* answer V with an attachment import).
/// ///
/// The focused-editor rule says a focused text surface wins V, and it still does: any pasteboard /// The focused-editor rule says a focused text surface wins V, and it still does: any pasteboard
/// carrying something this view can take text, foremost pastes into the text exactly as /// carrying something this view can take text, foremost pastes into the text exactly as
@@ -421,10 +421,22 @@ final class CardBodyTextView: NSTextView {
/// must not mean "blocks what it cannot take" so a paste this editor has no reading of is /// must not mean "blocks what it cannot take" so a paste this editor has no reading of is
/// passed to the responder behind it, and the standard validation walks the same path. /// passed to the responder behind it, and the standard validation walks the same path.
/// ///
/// The yield is by *capability*, not by content kind: `readablePasteboardTypes` is AppKit's own /// The yield is by *capability*, not by content kind, for the image branch `readablePasteboardTypes`
/// statement of what this view would accept, so the expression cannot drift from the paste it /// is AppKit's own statement of what this view would accept, so the expression cannot drift from
/// guards. In Preview the view is not editable and takes no paste, so there the window's branch /// the paste it guards. In Preview the view is not editable and takes no paste, so there the
/// simply owns V outright. /// window's branch simply owns V outright.
///
/// **The file-URL branch needs one content check, and here is why.** `acceptableDragTypes` above
/// excludes `.fileURL` outright, but `readablePasteboardTypes`' own answer already omits it too
/// `importsGraphics` is `false` on this view, so AppKit does not offer it. The leak is one level
/// up: `public.file-url` *conforms to* `public.url`, which this view legitimately does read (so a
/// dragged or pasted web link still lands as text), and `NSPasteboard.availableType(from:)`
/// matches by conformance, not exact type so a Finder copy that also declares a generic URL
/// representation beside its file URL would still read as "text this editor takes" through sheer
/// ancestry, and the paste would never reach the window at all. `PastedImage.carriesFileURL` is
/// the one predicate this whole feature already classifies file-URL pasteboards with
/// (`ClipboardStore.fileURLPayload`); reusing it here rather than a second UTI walk is what keeps
/// this check in step with that one.
/// The pasteboard the yield reads the general one in the app; tests hand in their own so the /// The pasteboard the yield reads the general one in the app; tests hand in their own so the
/// suite never touches the machine's (`FakePasteboard`'s reason, one seam over). /// suite never touches the machine's (`FakePasteboard`'s reason, one seam over).
@@ -432,11 +444,15 @@ final class CardBodyTextView: NSTextView {
/// Whether this editor itself would take the current pasteboard. /// Whether this editor itself would take the current pasteboard.
private var takesPasteboardAsText: Bool { private var takesPasteboardAsText: Bool {
isEditable && yieldPasteboard.availableType(from: readablePasteboardTypes) != nil guard isEditable else { return false }
// A file URL is never this editor's, whatever generic ancestor type rides beside it on the
// pasteboard (the doc block above) the file-URL branch owns this paste instead.
guard !PastedImage.carriesFileURL((yieldPasteboard.types ?? []).map(\.rawValue)) else { return false }
return yieldPasteboard.availableType(from: readablePasteboardTypes) != nil
} }
/// The responder behind this view that answers `paste:` the card window's image branch when it /// The responder behind this view that answers `paste:` the card window's file or image branch
/// is armed (`cardWindowImagePaste`; SwiftUI's bridge responds only while the handler is /// when either is armed (`cardWindowPaste`; SwiftUI's bridge responds only while the handler is
/// attached), and `nil` when nothing behind would take the paste either. /// attached), and `nil` when nothing behind would take the paste either.
private var pasteYieldTarget: NSResponder? { private var pasteYieldTarget: NSResponder? {
var responder = nextResponder var responder = nextResponder
+30
View File
@@ -32,6 +32,12 @@ final class FakePasteboard: ClipboardPasteboard {
/// test can actually make. /// test can actually make.
private var foreign: [(type: String, data: Data)] = [] private var foreign: [(type: String, data: Data)] = []
/// **A multi-item read, unlike `foreign`** the file-URL branch's own `fileURLs()`, which reads
/// across every pasteboard item rather than the first item's types (`availableTypes()`'s
/// carve-out). `foreign`'s flat `(type, data)` list cannot represent "the same type on several
/// items", which is exactly what a multi-file Finder copy needs seeded.
private var seededFileURLs: [URL] = []
func manifestData() -> Data? { data } func manifestData() -> Data? { data }
@discardableResult @discardableResult
@@ -53,12 +59,15 @@ final class FakePasteboard: ClipboardPasteboard {
return foreign.first { $0.type == type }?.data return foreign.first { $0.type == type }?.data
} }
func fileURLs() -> [URL] { seededFileURLs }
/// Another app copied: ownership moves, our type is gone, the counter advanced. /// Another app copied: ownership moves, our type is gone, the counter advanced.
func takeOver() { func takeOver() {
changeCount += 1 changeCount += 1
data = nil data = nil
text = nil text = nil
foreign = [] foreign = []
seededFileURLs = []
} }
/// Another app put *these* flavors down a screenshot, a browser's Copy Image, a Finder copy. /// Another app put *these* flavors down a screenshot, a browser's Copy Image, a Finder copy.
@@ -68,6 +77,27 @@ final class FakePasteboard: ClipboardPasteboard {
takeOver() takeOver()
foreign = payloads foreign = payloads
} }
/// A Finder copy of one or more **files** every URL as its own pasteboard item, exactly as a
/// real multi-select copy is, with `public.file-url` reported through `availableTypes()` (the
/// first-item read the image branch's precedence classifies) so `carriesFileURL` sees it. `also`
/// seeds types riding beside the file URL on that same first item an image flavor, for the
/// precedence tests where a Finder-copied image file carries both.
func seedFileURLs(_ urls: [URL], also: [(type: String, data: Data)] = []) {
takeOver()
seededFileURLs = urls
foreign = urls.isEmpty ? also : [(UTType.fileURL.identifier, Data())] + also
}
/// A foreign type layered over whatever this pasteboard already holds, counter bumped but nothing
/// cleared a combination `write()` alone can never produce (a real copy's `clearContents()`
/// takes everything with it), but exactly what "the app's own type wins outright" needs to
/// construct to prove the guard actually fires rather than merely never being exercised
/// (`PastedImageClassificationTests.boardItemsWin`'s same synthetic shape, one layer up).
func layerForeign(_ payloads: [(type: String, data: Data)]) {
changeCount += 1
foreign = payloads
}
} }
// MARK: - Fixtures // MARK: - Fixtures
+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))
}
}
+21 -1
View File
@@ -775,7 +775,7 @@ struct PasteBoardBackgroundTests {
// MARK: - The body editor's paste yield // MARK: - The body editor's paste yield
/// The responder behind the editor in these tests stands where the card window's /// The responder behind the editor in these tests stands where the card window's
/// `cardWindowImagePaste` bridge stands in the app, and only counts. /// `cardWindowPaste` bridge stands in the app, and only counts.
@MainActor @MainActor
private final class PasteCatcher: NSView { private final class PasteCatcher: NSView {
var pastes = 0 var pastes = 0
@@ -820,6 +820,26 @@ struct PasteYieldTests {
#expect(catcher.pastes == 1) #expect(catcher.pastes == 1)
} }
/// **The file-URL branch's own yield** a Finder copy reaches the window's attachment branch
/// exactly as a screenshot does, `readablePasteboardTypes`'s explicit exclusion of `.fileURL`
/// (`CardBodyTextView`'s override) proven rather than assumed: `importsGraphics` being `false` is
/// an AppKit default this pins down as a contract.
@Test("A file-URL-only pasteboard validates through the editor and forwards to the responder behind")
func fileURLOnlyYields() throws {
let catcher = PasteCatcher(frame: .zero)
let (editor, pasteboard) = makeEditor(behind: catcher)
defer { pasteboard.releaseGlobally() }
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("paste-yield-\(UUID().uuidString).png")
try Data([0x01]).write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
pasteboard.writeObjects([fileURL as NSURL])
#expect(editor.validateUserInterfaceItem(pasteItem))
editor.paste(nil)
#expect(catcher.pastes == 1)
}
/// No expectation on `validateUserInterfaceItem` here: a readable pasteboard routes validation /// No expectation on `validateUserInterfaceItem` here: a readable pasteboard routes validation
/// to `super`, and `NSTextView`'s own answer reads the *machine's* general pasteboard asserting /// to `super`, and `NSTextView`'s own answer reads the *machine's* general pasteboard asserting
/// it would tie the test to whatever the host's clipboard happens to hold. The claim under test /// it would tie the test to whatever the host's clipboard happens to hold. The claim under test