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