diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 5b05719..07041ef 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1669,6 +1669,12 @@ public final class BoardStore { // ordinary store writes, with the drop commits' own rules above (one `performWrite` bracket per // gesture; a vanished or tombstoned destination is a silent no-op, the reload being the // authority; failures are the banner's). + // + // **Folders never arrive here from a drop.** "Folders are refused at hover" (04-interactions.md): + // the gesture refuses a folders-only drag outright and `FinderDrop.land` partitions a mixed one + // before it calls either of these, naming the skipped folders in a loss row. Both functions stay + // honest about a directory anyway — `BoardWriter.importAttachments` refuses one by design — since + // nothing about their contract says a drop is the only caller. /// Copies `urls` into `cardID`'s `attachments/` — the drop-on-a-card half. /// @@ -1711,9 +1717,9 @@ public final class BoardStore { /// (01-storage-format.md § Frontmatter). /// /// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file - /// that cannot be imported — a folder rather than a file, an unreadable source — which banners - /// naming it; the cards already made keep their files, matching `importAttachments`' own - /// "everything already imported stays landed". The card whose import failed is removed again + /// that cannot be imported — an unreadable source, a vanished one, a disk with no room left — + /// which banners naming it; the cards already made keep their files, matching `importAttachments`' + /// own "everything already imported stays landed". The card whose import failed is removed again /// before the throw: it was minted moments earlier in this same bracket and holds nothing but /// what this call put there, and "creating-then-abandoning never leaves an empty card behind" /// (04-interactions.md ▸ Grammar) is the rule it would otherwise break. diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 2743434..b5f5bca 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -248,14 +248,28 @@ struct BoardDropContext { !store.isReadOnly && !store.isEditingInline } - /// How many files the session carries — the shadow run's length on the create path. + /// Whether this board accepts *this* drag — the board's own state **and the payload's** + /// (04-interactions.md ▸ Drag and drop: "Folders are refused at hover"). + /// + /// A drag carrying nothing but folders is refused here, at every delegate's `validateDrop` and + /// again before any retarget, which is the whole of "a drag containing only folders never + /// engages — no highlight, no drop proposal, the standard incompatible-payload read". A mixed + /// drag engages for its files alone: it has something to import, and the folders are named at + /// the drop (`FinderDrop.land`). + func acceptsFileDrop(_ info: DropInfo) -> Bool { + acceptsFileDrops && FinderDrop.importableCount(info.itemProviders(for: [.fileURL])) > 0 + } + + /// How many **files** the session carries — the shadow run's length on the create path, and the + /// count the create path is sized by: folders are not imported, so they draw no shadow and mint + /// no card (the refusal rule above). /// /// Read from `info` on every sample rather than captured at `dropEntered`: a delegate can be /// entered without this window ever having seen the enter callback (single-target dispatch hands /// the session to whichever region is deepest), and a count that was never set would draw the /// wrong number of shadows. private func fileCount(_ info: DropInfo) -> Int { - max(1, info.itemProviders(for: [.fileURL]).count) + max(1, FinderDrop.importableCount(info.itemProviders(for: [.fileURL]))) } /// Where a **file** session would land in `laneID` — the file mode's twin of `retargetCards`, @@ -281,7 +295,7 @@ struct BoardDropContext { /// the lane's bottom is this milestone's reading of the pathfinder's `retargetFile` precedent, /// implemented here and awaiting the design's word. func retargetFile(inLane laneID: ItemID, info: DropInfo) { - guard acceptsFileDrops, let cursor = globalCursor(), + guard acceptsFileDrop(info), let cursor = globalCursor(), let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }), let grid = registry.grids[laneID] else { @@ -341,7 +355,7 @@ struct BoardDropContext { /// tombstoned cards are inert" and the trash column is never a target (04-interactions.md ▸ The /// trash). func retargetFileFromStrip(_ info: DropInfo) { - guard acceptsFileDrops, let cursor = stripCursor() else { + guard acceptsFileDrop(info), let cursor = stripCursor() else { session.proposeFile(nil) return } @@ -373,6 +387,11 @@ struct BoardDropContext { /// drag arriving in the meantime must not find a stale target sitting there. The store call then /// happens back on the main actor, one `performWrite` bracket per gesture, whatever the file /// count (`BoardStore.importAttachments` / `createCards`). + /// + /// **The resolved URLs are the authority on what is a folder** (04-interactions.md ▸ Drag and + /// drop): the hover read is a declared-type guess and the drop is a filesystem fact, so + /// `FinderDrop.land` re-partitions here and writes only the files — see its own note on why the + /// two reads cannot be one. func commitFileDrop(_ info: DropInfo) -> Bool { guard acceptsFileDrops, let target = session.fileTarget, @@ -400,12 +419,9 @@ struct BoardDropContext { let scoped = urls.filter { $0.startAccessingSecurityScopedResource() } defer { for url in scoped { url.stopAccessingSecurityScopedResource() } } - switch target.landing { - case let .attach(cardID): - store.importAttachments(urls, toCard: cardID) - case let .create(laneID, index): - store.createCards(fromFiles: urls, inLane: laneID, at: index) - } + // Inside the scope: the directory read is itself a read of the dropped item, and the + // extension the drag handed over is what makes it answer honestly. + FinderDrop.land(urls, landing: target.landing, into: store) } return true } @@ -547,6 +563,123 @@ enum FileDropLoading { } } +// MARK: - Files in, folders out + +/// What a Finder drag is carrying and where it lands — **the folder refusal, both halves** +/// (04-interactions.md ▸ Drag and drop: "Folders are refused at hover … the attachment model is flat +/// top-level files, and the importer refuses directories by design"). +/// +/// ### Why the payload is read twice +/// +/// The rule is a *hover* rule, and hover has only the providers' declared types to go on: a file URL +/// is not resolved until the drop, and resolving one during hover is neither offered nor affordable. +/// So the drag is read twice, and the two reads have different jobs: +/// +/// - **At hover, from `registeredTypeIdentifiers`** — Finder registers the concrete UTI beside +/// `public.file-url`, so a folder announces itself as `public.folder` before anything is loaded. +/// That is what makes a folders-only drag refuse *at the cursor*: no highlight, no shadows, the +/// incompatible-payload read (`BoardDropContext.acceptsFileDrop`). +/// - **At the drop, from the URLs themselves** — `FinderDrop.partition`, which is a filesystem fact +/// rather than a declaration and therefore the authority. It is what actually decides what gets +/// written. +/// +/// **Unknown at hover is treated as a file**, deliberately: a provider that registers only +/// `public.file-url` and no concrete type — a synthetic drag, or an unusual source — cannot be +/// classified until its URL resolves, and the optimistic read means such a drag still engages, still +/// shows its shadows, and is sorted out authoritatively at the drop. The pessimistic read would make +/// an ordinary file drag silently dead, which is the far worse failure. +/// +/// **A package is a directory.** Conformance to `public.directory` — not equality with +/// `public.folder` — is the test, so a `.app`, an `.rtfd`, or any other bundle is refused exactly as +/// a plain folder is: the flat top-level attachment model has no more room for one than for the +/// other, and `isDirectory(at:)` says the same thing at the drop. +enum FinderDrop { + + // MARK: The hover read — declared types + + /// Whether a provider's registered types describe a directory (folders and packages alike). + /// + /// An identifier the system does not know, and an empty list, are *not* directories: this is the + /// optimistic side of the unknown-at-hover rule above. + nonisolated static func isDirectory(typeIdentifiers: [String]) -> Bool { + typeIdentifiers.contains { identifier in + UTType(identifier)?.conforms(to: .directory) ?? false + } + } + + /// How many of these providers are importable — the file count every hover-time proposal is + /// sized by, and `0` is the refusal that keeps a folders-only drag from ever engaging. + /// + /// `@MainActor` for the same reason `FileDropLoading.url(from:)` is: an `NSItemProvider` off a + /// `DropInfo` is not `Sendable`, so it stays on the actor it arrived on. + @MainActor + static func importableCount(_ providers: [NSItemProvider]) -> Int { + providers.filter { !isDirectory(typeIdentifiers: $0.registeredTypeIdentifiers) }.count + } + + // MARK: The drop read — the filesystem + + /// Whether `url` is a directory, as the filesystem answers it — the authoritative read. + /// + /// `resourceValues` first (the real answer, packages included), `fileExists` as the fallback for + /// a URL whose resource values cannot be read, and the purely lexical `hasDirectoryPath` last, + /// for a source that has already vanished between the drag and the drop. + nonisolated static func isDirectory(at url: URL) -> Bool { + if let flag = try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory { return flag } + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) { + return isDirectory.boolValue + } + return url.hasDirectoryPath + } + + /// Splits a dropped set into what will be written and what will be named as skipped, preserving + /// input order in both halves — the create path mints its cards in drop order, and the order the + /// user dropped in is the only order there is. + nonisolated static func partition(_ urls: [URL]) -> (files: [URL], folders: [URL]) { + var files: [URL] = [] + var folders: [URL] = [] + for url in urls { + if isDirectory(at: url) { folders.append(url) } else { files.append(url) } + } + return (files, folders) + } + + // MARK: The write + + /// The drop's **write half**: the files land where the highlight or the shadows showed, and the + /// folders are named in a loss row rather than attempted. + /// + /// **No card is ever minted for an import that cannot succeed** (04-interactions.md): the folders + /// are gone before `createCards` sees the list, so the create path only ever fires with files — + /// "the mint-fail-remove dance is gone" for this reason, not because the store stopped doing it. + /// `BoardStore.createCards` still removes a card whose import failed for a *genuine* reason (an + /// unreadable source, a full disk), and `BoardWriter.importAttachments` still refuses a directory + /// outright: that throw stays as the model layer's backstop for every other caller, and folders + /// simply never reach it from here. + /// + /// **Zero files is a valid arrival, and writes nothing.** The hover refusal means a folders-only + /// drag normally never gets here at all; a payload whose types were unknown at hover can, and the + /// honest answer is the loss row alone — no write, no empty card, nothing to undo. + /// + /// A **loss row, not a failure one-shot** (02-architecture.md § the banner vocabulary): nothing + /// failed here. The files the user dropped arrived; the folders were never things this app could + /// take, and `postSkippedFolders` is silent at zero, so an all-files drop says nothing at all. + @MainActor + static func land(_ urls: [URL], landing: FileDropTarget.Landing, into store: BoardStore) { + let (files, folders) = partition(urls) + if !files.isEmpty { + switch landing { + case let .attach(cardID): + store.importAttachments(files, toCard: cardID) + case let .create(laneID, index): + store.createCards(fromFiles: files, inLane: laneID, at: index) + } + } + store.banners.postSkippedFolders(count: folders.count) + } +} + // MARK: - Drop delegates // **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS @@ -564,6 +697,13 @@ enum FileDropLoading { // hit-testing the *analytic* masonry frames the card zones are already built from // (`BoardDropContext.retargetFile`), which adds no drop region at all and cannot drift from the // geometry the shadows use. +// +// **A file session is validated by its payload, not only by its type** (04-interactions.md ▸ Drag +// and drop: "Folders are refused at hover"). Every `validateDrop` below asks +// `BoardDropContext.acceptsFileDrop`, which answers false for a drag carrying nothing but folders — +// so the system reads the board as an incompatible target for it: no `dropEntered`, no highlight, no +// shadows, and a refusal cursor at the drop. A *mixed* drag validates, proposes for its files alone, +// and names the folders it left behind when it lands (`FinderDrop`). /// The board's own drag types. Spelled once so no target can accidentally accept fewer. let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes] @@ -586,7 +726,7 @@ struct LaneDropDelegate: DropDelegate { let laneID: ItemID func validateDrop(info: DropInfo) -> Bool { - if context.isFileSession(info) { return context.acceptsFileDrops } + if context.isFileSession(info) { return context.acceptsFileDrop(info) } return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) } @@ -639,7 +779,7 @@ struct StripDropDelegate: DropDelegate { let context: BoardDropContext func validateDrop(info: DropInfo) -> Bool { - if context.isFileSession(info) { return context.acceptsFileDrops } + if context.isFileSession(info) { return context.acceptsFileDrop(info) } return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) } @@ -681,7 +821,7 @@ struct BoardFallbackDropDelegate: DropDelegate { let context: BoardDropContext func validateDrop(info: DropInfo) -> Bool { - if context.isFileSession(info) { return context.acceptsFileDrops } + if context.isFileSession(info) { return context.acceptsFileDrop(info) } return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) } diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index 6e686d3..39d41b8 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -48,6 +48,10 @@ struct FileDropTarget: Equatable, Sendable { /// How many files ride along — the create path's shadow run length, one shadow per card that /// will land. Read off the session's item providers at hover time, floored at one. + /// + /// **Folders are not counted** (04-interactions.md ▸ Drag and drop: "a mixed drag proposes for + /// its files only"): `FinderDrop.importableCount` reads the providers' declared types, so a + /// mixed drag draws shadows for its files alone and a folders-only drag never proposes at all. var fileCount: Int } diff --git a/KanbanTests/FileDropWriteTests.swift b/KanbanTests/FileDropWriteTests.swift index fc92c52..ac627c3 100644 --- a/KanbanTests/FileDropWriteTests.swift +++ b/KanbanTests/FileDropWriteTests.swift @@ -1,16 +1,17 @@ import Foundation import Testing +import UniformTypeIdentifiers @testable import Kanban /// `BoardStore`'s Finder file drops — the writes an external file drag performs /// (04-interactions.md ▸ Drag and drop, "Files from Finder"): onto a card the files join its -/// `attachments/`, onto lane empty space they become one card each. +/// `attachments/`, onto lane empty space they become one card each, and **folders are refused**. /// /// Like every other write suite here these 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: the /// interesting claims are about the files — which name a colliding attachment landed under, which -/// rank a created card took, and which folder was removed again when a batch failed. `WriterFixture`, -/// `Ident` and `Item` come from `WriterTestSupport.swift`. +/// rank a created card took, which card was removed again when a batch failed, and which folders were +/// never attempted at all. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. /// /// The gesture's half — which card is under the cursor, which slot the shadows show — is /// `BoardDropContext`'s and is not unit-testable without a live window; here the target and the index @@ -72,13 +73,19 @@ private struct DropSources { return url } - /// A *folder* — what a Finder drag of a directory hands over, which the import machinery refuses. + /// A *folder* — what a Finder drag of a directory hands over, which the drop refuses. @discardableResult func folder(_ relativePath: String) throws -> URL { let url = root.appendingPathComponent(relativePath, isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } + + /// A path with nothing at it — a **genuine** import failure, the kind the folder refusal is + /// deliberately not: the source is a file as far as anyone can tell and the copy simply fails. + func missing(_ relativePath: String) -> URL { + root.appendingPathComponent(relativePath) + } } private let lane1 = ItemID(rawValue: Ident.lane1) @@ -186,6 +193,9 @@ struct ImportAttachmentsToCardTests { #expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure") } + /// The *genuine* failure path, which the folder ruling deliberately leaves alone: a source that + /// cannot be read is a write that did not happen, so it banners as a one-shot and stops the batch + /// — 04's folder refusal happens a layer above this and never reaches it (`FinderDrop`). @Test("A source that cannot be imported banners and leaves nothing half-copied") func aFailingSourceBanners() throws { let fixture = try makeBoard() @@ -194,14 +204,15 @@ struct ImportAttachmentsToCardTests { defer { sources.tearDown() } let store = try BoardStore(rootURL: fixture.root) let shot = try sources.file("shot.png") - let directory = try sources.folder("Project") + let gone = sources.missing("gone.png") - store.importAttachments([shot, directory], toCard: card1) + store.importAttachments([shot, gone], toCard: card1) - // The batch stops at the folder, and what landed before it stays landed. + // The batch stops at the unreadable source, and what landed before it stays landed. #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"]) #expect(store.banners.oneShots.count == 1) - #expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "Project")) + #expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png")) + #expect(store.banners.losses.isEmpty, "a failure is a one-shot; a loss row says nothing failed") } } @@ -341,8 +352,71 @@ struct CreateCardsFromFilesTests { #expect(store.banners.oneShots.isEmpty) } + /// The mint-fail-remove dance, on the one path that still needs it: a *genuine* mid-batch failure. + /// Folders never get this far any more (the drop partitions them out — `FinderDrop`), but a source + /// that vanished between the drag and the drop still can, and the card minted for it must not + /// survive its own import. @Test("A file that fails mid-batch banners, keeps the cards already made, and leaves no half-made one") func partialFailureLeavesNoHalfMadeCard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let sources = try DropSources() + defer { sources.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let shot = try sources.file("shot.png") + let gone = sources.missing("gone.png") + let after = try sources.file("after.txt") + + store.createCards(fromFiles: [shot, gone, after], inLane: lane1, at: 3) + + // The first file's card stands, the failed one's card was removed again, and the batch + // stopped before the third — so the lane grew by exactly one. + #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot"]) + let landed = try cards(lane1, in: fixture) + #expect(landed.count == 4) + #expect(try fixture.entryNames(Ident.lane1).count == 5, "index.md, three cards, one new one") + #expect(landed.last?.attachments == ["shot.png"]) + #expect(store.banners.oneShots.count == 1) + #expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png")) + } +} + +// MARK: - Folders, refused + +/// **"Folders are refused at hover"** (04-interactions.md ▸ Drag and drop, settled 2026-07-28): the +/// attachment model is flat top-level files, so a drag of folders is an incompatible payload rather +/// than a failed import. The hover half — a folders-only drag never engaging — is +/// `BoardDropContext.acceptsFileDrop`'s and needs a live window; what is testable here is the drop +/// itself: `FinderDrop.land`, the write half `commitFileDrop` runs once the URLs have resolved. +/// +/// The claim every case below shares: **the files land, the folders are named, and nothing failed** — +/// a loss row (warning tone), never an error one-shot. +@MainActor +@Suite("Finder drop ▸ folders are refused") +struct FinderDropFolderTests { + + @Test("A mixed drop on a card imports its file and names the folder it skipped") + func mixedAttachDrop() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let sources = try DropSources() + defer { sources.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let shot = try sources.file("shot.png", Data([0x89, 0x50])) + let directory = try sources.folder("Project") + + FinderDrop.land([shot, directory], landing: .attach(cardID: card1), into: store) + + // The file imported whole — the folder did not abort the batch, it was never in it. + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"]) + #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50])) + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments/Project")) + #expect(store.banners.oneShots.isEmpty, "nothing failed: the folder was never attempted") + #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"]) + } + + @Test("A mixed drop on lane empty space makes one card per file and none for the folder") + func mixedCreateDrop() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let sources = try DropSources() @@ -352,17 +426,139 @@ struct CreateCardsFromFilesTests { let directory = try sources.folder("Project") let after = try sources.file("after.txt") - store.createCards(fromFiles: [shot, directory, after], inLane: lane1, at: 3) + FinderDrop.land([shot, directory, after], landing: .create(laneID: lane1, index: 3), into: store) - // The first file's card stands, the folder's card was removed again, and the batch stopped - // before the third — so the lane grew by exactly one. - #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot"]) + // Two cards, in drop order, and no half-made third: the file *after* the folder imports, + // which is the whole difference from the old abort-at-the-folder behaviour. + #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot", "after"]) let landed = try cards(lane1, in: fixture) - #expect(landed.count == 4) - #expect(try fixture.entryNames(Ident.lane1).count == 5, "index.md, three cards, one new one") - #expect(landed.last?.attachments == ["shot.png"]) - #expect(store.banners.oneShots.count == 1) - #expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "Project")) + #expect(landed.count == 5) + #expect(try fixture.entryNames(Ident.lane1).count == 6, "index.md, three cards, two new ones") + #expect(landed[3].attachments == ["shot.png"]) + #expect(landed[4].attachments == ["after.txt"]) + #expect(store.banners.oneShots.isEmpty) + #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"]) + } + + /// Unreachable through the gesture — a folders-only drag never engages — but reachable when the + /// hover read could not classify the payload (a provider carrying only `public.file-url`), so it + /// has a defined answer: no write at all, just the loss row. + @Test("A folders-only drop writes nothing at all and only names the loss") + func foldersOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let sources = try DropSources() + defer { sources.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let first = try sources.folder("Project") + let second = try sources.folder("Archive") + + FinderDrop.land([first, second], landing: .create(laneID: lane1, index: 0), into: store) + + #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"], "no card was minted") + #expect(try fixture.entryNames(Ident.lane1).count == 4, "index.md and the three cards") + #expect(store.banners.oneShots.isEmpty) + #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 2 skipped"]) + } + + @Test("A folders-only drop on a card leaves it without an attachments folder at all") + func foldersOnlyOnACard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let sources = try DropSources() + defer { sources.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + FinderDrop.land([try sources.folder("Project")], landing: .attach(cardID: card1), into: store) + + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"), "no write, not an empty one") + #expect(store.banners.oneShots.isEmpty) + #expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"]) + } + + @Test("An all-files drop says nothing — a drop that lost nothing has nothing to report") + func allFilesIsSilent() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let sources = try DropSources() + defer { sources.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + FinderDrop.land([try sources.file("shot.png")], landing: .attach(cardID: card1), into: store) + + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"]) + #expect(store.banners.losses.isEmpty) + #expect(store.banners.oneShots.isEmpty) + } +} + +// MARK: - What the drag is carrying + +/// The two reads `FinderDrop` makes of a Finder drag: the **declared types** at hover, which decide +/// whether the drag engages at all, and the **filesystem** at the drop, which decides what is +/// written. 04's refusal rule is a hover rule, so the first one is what gives it its cursor. +@Suite("Finder drop ▸ what the drag is carrying") +struct FinderDropPayloadTests { + + @Test("Conformance to public.directory is the test — folders and packages alike") + func directoryTypes() { + #expect(FinderDrop.isDirectory(typeIdentifiers: ["public.folder", "public.file-url"])) + // A package is a directory, and the flat attachment model has no more room for one than for + // a plain folder: a .app, a bundle, an .rtfd. + #expect(FinderDrop.isDirectory(typeIdentifiers: ["com.apple.application-bundle", "public.file-url"])) + #expect(FinderDrop.isDirectory(typeIdentifiers: ["com.apple.package"])) + #expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.png", "public.file-url"])) + #expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.data", "public.item"])) + } + + @Test("An unclassifiable payload reads as a file — optimistic at hover, sorted out at the drop") + func unknownTypesAreOptimistic() { + // The synthetic shape: a provider registering the URL type and nothing concrete. + #expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.file-url", "public.url"])) + #expect(!FinderDrop.isDirectory(typeIdentifiers: [])) + #expect(!FinderDrop.isDirectory(typeIdentifiers: ["not.a.real.type"])) + // `NSItemProvider(contentsOf:)` gives a folder a *dynamic* type rather than public.folder, + // which is exactly the case the optimistic read exists for: such a drag still engages, and + // the drop's own read is what refuses it. + #expect(!FinderDrop.isDirectory(typeIdentifiers: ["dyn.age8u"])) + } + + @MainActor + @Test("The importable count is the file count — folders are not counted, so they draw no shadow") + func importableCountCountsFilesOnly() { + func provider(_ type: UTType) -> NSItemProvider { + let provider = NSItemProvider() + provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { + completion in + completion(Data(), nil) + return nil + } + return provider + } + + #expect(FinderDrop.importableCount([provider(.png), provider(.folder)]) == 1) + #expect(FinderDrop.importableCount([provider(.png), provider(.plainText)]) == 2) + // Zero is the refusal itself: `acceptsFileDrop` is false, so the drag never engages. + #expect(FinderDrop.importableCount([provider(.folder), provider(.folder)]) == 0) + #expect(FinderDrop.importableCount([]) == 0) + } + + @Test("The drop reads the filesystem, which is the authority a declared type is not") + func filesystemIsAuthoritative() throws { + let sources = try DropSources() + defer { sources.tearDown() } + let shot = try sources.file("shot.png") + let notes = try sources.file("notes.txt") + let project = try sources.folder("Project") + let bundle = try sources.folder("Thing.app") // a package: a directory like any other + + #expect(FinderDrop.isDirectory(at: project)) + #expect(FinderDrop.isDirectory(at: bundle)) + #expect(!FinderDrop.isDirectory(at: shot)) + + let (files, folders) = FinderDrop.partition([shot, project, notes, bundle]) + #expect(files == [shot, notes], "input order survives — the create path mints in drop order") + #expect(folders == [project, bundle]) } }