diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 5eae7e6..40fa0d7 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -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 diff --git a/Kanban/App/ClipboardManifest.swift b/Kanban/App/ClipboardManifest.swift index e76be1c..0f7ff05 100644 --- a/Kanban/App/ClipboardManifest.swift +++ b/Kanban/App/ClipboardManifest.swift @@ -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() diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index a394225..fc49ef2 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -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() } diff --git a/Kanban/App/PastedImage.swift b/Kanban/App/PastedImage.swift index 3113ce8..906c92e 100644 --- a/Kanban/App/PastedImage.swift +++ b/Kanban/App/PastedImage.swift @@ -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. /// diff --git a/Kanban/UI/Board/ClipboardCommands.swift b/Kanban/UI/Board/ClipboardCommands.swift index c777220..e5aeb45 100644 --- a/Kanban/UI/Board/ClipboardCommands.swift +++ b/Kanban/UI/Board/ClipboardCommands.swift @@ -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 diff --git a/Kanban/UI/Card/CardBodySurface.swift b/Kanban/UI/Card/CardBodySurface.swift index 8a311a8..c8bbb17 100644 --- a/Kanban/UI/Card/CardBodySurface.swift +++ b/Kanban/UI/Card/CardBodySurface.swift @@ -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 diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index 7c388a9..a64768c 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -32,6 +32,12 @@ final class FakePasteboard: ClipboardPasteboard { /// test can actually make. 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 } @discardableResult @@ -53,12 +59,15 @@ final class FakePasteboard: ClipboardPasteboard { return foreign.first { $0.type == type }?.data } + func fileURLs() -> [URL] { seededFileURLs } + /// Another app copied: ownership moves, our type is gone, the counter advanced. func takeOver() { changeCount += 1 data = nil text = nil foreign = [] + seededFileURLs = [] } /// 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() 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 diff --git a/KanbanTests/PasteFileTests.swift b/KanbanTests/PasteFileTests.swift new file mode 100644 index 0000000..da24388 --- /dev/null +++ b/KanbanTests/PasteFileTests.swift @@ -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)) + } +} diff --git a/KanbanTests/PasteImageTests.swift b/KanbanTests/PasteImageTests.swift index af3428d..0b39204 100644 --- a/KanbanTests/PasteImageTests.swift +++ b/KanbanTests/PasteImageTests.swift @@ -775,7 +775,7 @@ struct PasteBoardBackgroundTests { // MARK: - The body editor's paste yield /// 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 private final class PasteCatcher: NSView { var pastes = 0 @@ -820,6 +820,26 @@ struct PasteYieldTests { #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 /// 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