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
attachments.hero = hero
}
// **V in this window pastes a picture onto this card** (04-interactions.md Clipboard's
// image-data branch). Here rather than inside `CardWindowView` because the availability
// is the clipboard's observable reading and this is where a store, a card id and the
// app-wide clipboard are all in scope at once the same join `configureAttachments`
// **V in this window pastes onto this card** (04-interactions.md Clipboard's
// image-data and file-URL branches). Here rather than inside `CardWindowView` because the
// availability is the clipboard's observable reading and this is where a store, a card id
// and the app-wide clipboard are all in scope at once the same join `configureAttachments`
// makes for the other two writes.
.cardWindowImagePaste(
.cardWindowPaste(
store: store,
cardID: placement.card.id,
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
/// classification above).
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.
@@ -253,6 +262,16 @@ public final class SystemPasteboard: ClipboardPasteboard {
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
public func write(manifest: Data, text: String) -> Int {
pasteboard.clearContents()
+85 -4
View File
@@ -91,6 +91,18 @@ public final class ClipboardStore {
/// call sites have to remember.
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
/// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests.
@ObservationIgnored public let stagingRoot: URL
@@ -439,6 +451,74 @@ public final class ClipboardStore {
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
/// 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:))
// 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.
imagePayload = PastedImage.flavor(
hasBoardItems: payload != nil,
types: pasteboard.availableTypes()
)
let types = pasteboard.availableTypes()
imagePayload = PastedImage.flavor(hasBoardItems: payload != nil, types: types)
// 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 {
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
/// 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
/// filed; taking a second copy of it into `attachments/` behind their back is a different gesture
/// with a different answer, and this branch declines rather than guessing at it.
/// filed; taking a second copy of it into `attachments/` behind their back would be a different
/// 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,
/// 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))
}
/// **V's two branches as one optional handler** (04-interactions.md Clipboard, the image-data
/// branch ruled 2026-08-09).
/// **V's three branches as one optional handler** (04-interactions.md Clipboard, the
/// 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
/// board payload winning over a picture is one expression rather than a condition on one item and
/// a matching negation on another. `ClipboardStore.refresh` has already made the two readings
/// mutually exclusive at the source (`imagePayload` is `nil` whenever a board payload is
/// readable), so this ordering is belt over braces but it is the ordering a reader will look
/// board payload winning over a file, and a file winning over a picture, is three expressions in
/// order rather than a condition on one item and matching negations on the other two.
/// `ClipboardStore.refresh` has already made the three readings mutually exclusive at the source
/// (`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.
///
/// `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)? {
if clipboard.canPaste(into: store) {
return { clipboard.paste(into: store) }
}
if clipboard.canPasteFiles(into: store) {
return { clipboard.pasteFiles(into: store) }
}
if clipboard.canPasteImage(into: store) {
return { clipboard.pasteImage(into: store) }
}
@@ -75,12 +79,17 @@ extension View {
extension View {
/// **V in a card window pastes a picture into that card** (04-interactions.md Clipboard, the
/// image-data branch; 05-card-window.md Attachments).
/// **V in a card window pastes onto that card** (04-interactions.md Clipboard, the image-data
/// 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
/// 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.
/// The board's own responder shape, one window over and with two branches instead of three: there
/// 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 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`
/// 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
/// section: 05 makes the *window* the drop surface for files, and the paste is that sentence's
/// keyboard twin.
func cardWindowImagePaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
func cardWindowPaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
onCommand(
#selector(NSText.paste(_:)),
perform: clipboard.canPasteImage(intoCard: cardID, in: store) ? {
clipboard.pasteImage(intoCard: cardID, in: store)
} : nil
perform: Self.cardWindowPasteAction(store: store, cardID: cardID, clipboard: clipboard)
)
}
/// 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
+25 -9
View File
@@ -409,8 +409,8 @@ final class CardBodyTextView: NSTextView {
// MARK: The paste yield
/// **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
/// Attachments makes the *window* answer V with an attachment import).
/// arrived at the keyboard (04-interactions.md Clipboard, the image-data and file-URL branches;
/// 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
/// 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
/// 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
/// statement of what this view would accept, so the expression cannot drift from the paste it
/// guards. In Preview the view is not editable and takes no paste, so there the window's branch
/// simply owns V outright.
/// The yield is by *capability*, not by content kind, for the image branch `readablePasteboardTypes`
/// is AppKit's own statement of what this view would accept, so the expression cannot drift from
/// the paste it guards. In Preview the view is not editable and takes no paste, so there the
/// 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
/// 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.
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
/// is armed (`cardWindowImagePaste`; SwiftUI's bridge responds only while the handler is
/// The responder behind this view that answers `paste:` the card window's file or image branch
/// 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.
private var pasteYieldTarget: NSResponder? {
var responder = nextResponder