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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user