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
+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