Refuse folder drops at hover, skip them in mixes

04's settled ruling: the attachment model is flat top-level files, so a
drag containing only folders never engages — no highlight, no proposal,
the standard incompatible-payload cursor — and a mixed drag proposes for
its files only, importing them at the drop while a loss row names the
skipped folders. Hover reads the providers' registered types (anything
conforming to public.directory refuses, packages included); commit
re-partitions authoritatively from the filesystem, so a synthetic
payload that hides its type still can't land a folder. The create path
now only ever fires with at least one importable file — the
mint-fail-remove dance is gone from the folder case and stays reserved
for genuine mid-batch failures.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 07:29:43 -04:00
parent 7be4eec9fd
commit 1487b391ad
4 changed files with 379 additions and 33 deletions
+213 -17
View File
@@ -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])
}
}