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:
@@ -1669,6 +1669,12 @@ public final class BoardStore {
|
|||||||
// ordinary store writes, with the drop commits' own rules above (one `performWrite` bracket per
|
// 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
|
// gesture; a vanished or tombstoned destination is a silent no-op, the reload being the
|
||||||
// authority; failures are the banner's).
|
// 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.
|
/// 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).
|
/// (01-storage-format.md § Frontmatter).
|
||||||
///
|
///
|
||||||
/// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file
|
/// **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
|
/// that cannot be imported — an unreadable source, a vanished one, a disk with no room left —
|
||||||
/// naming it; the cards already made keep their files, matching `importAttachments`' own
|
/// which banners naming it; the cards already made keep their files, matching `importAttachments`'
|
||||||
/// "everything already imported stays landed". The card whose import failed is removed again
|
/// 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
|
/// 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"
|
/// 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.
|
/// (04-interactions.md ▸ Grammar) is the rule it would otherwise break.
|
||||||
|
|||||||
@@ -248,14 +248,28 @@ struct BoardDropContext {
|
|||||||
!store.isReadOnly && !store.isEditingInline
|
!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
|
/// 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
|
/// 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
|
/// the session to whichever region is deepest), and a count that was never set would draw the
|
||||||
/// wrong number of shadows.
|
/// wrong number of shadows.
|
||||||
private func fileCount(_ info: DropInfo) -> Int {
|
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`,
|
/// 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,
|
/// the lane's bottom is this milestone's reading of the pathfinder's `retargetFile` precedent,
|
||||||
/// implemented here and awaiting the design's word.
|
/// implemented here and awaiting the design's word.
|
||||||
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
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 lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||||
let grid = registry.grids[laneID]
|
let grid = registry.grids[laneID]
|
||||||
else {
|
else {
|
||||||
@@ -341,7 +355,7 @@ struct BoardDropContext {
|
|||||||
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md ▸ The
|
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md ▸ The
|
||||||
/// trash).
|
/// trash).
|
||||||
func retargetFileFromStrip(_ info: DropInfo) {
|
func retargetFileFromStrip(_ info: DropInfo) {
|
||||||
guard acceptsFileDrops, let cursor = stripCursor() else {
|
guard acceptsFileDrop(info), let cursor = stripCursor() else {
|
||||||
session.proposeFile(nil)
|
session.proposeFile(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -373,6 +387,11 @@ struct BoardDropContext {
|
|||||||
/// drag arriving in the meantime must not find a stale target sitting there. The store call then
|
/// 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
|
/// happens back on the main actor, one `performWrite` bracket per gesture, whatever the file
|
||||||
/// count (`BoardStore.importAttachments` / `createCards`).
|
/// 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 {
|
func commitFileDrop(_ info: DropInfo) -> Bool {
|
||||||
guard acceptsFileDrops,
|
guard acceptsFileDrops,
|
||||||
let target = session.fileTarget,
|
let target = session.fileTarget,
|
||||||
@@ -400,12 +419,9 @@ struct BoardDropContext {
|
|||||||
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
|
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
|
||||||
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
|
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
switch target.landing {
|
// Inside the scope: the directory read is itself a read of the dropped item, and the
|
||||||
case let .attach(cardID):
|
// extension the drag handed over is what makes it answer honestly.
|
||||||
store.importAttachments(urls, toCard: cardID)
|
FinderDrop.land(urls, landing: target.landing, into: store)
|
||||||
case let .create(laneID, index):
|
|
||||||
store.createCards(fromFiles: urls, inLane: laneID, at: index)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true
|
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
|
// MARK: - Drop delegates
|
||||||
|
|
||||||
// **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS
|
// **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
|
// 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
|
// (`BoardDropContext.retargetFile`), which adds no drop region at all and cannot drift from the
|
||||||
// geometry the shadows use.
|
// 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.
|
/// The board's own drag types. Spelled once so no target can accidentally accept fewer.
|
||||||
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]
|
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]
|
||||||
@@ -586,7 +726,7 @@ struct LaneDropDelegate: DropDelegate {
|
|||||||
let laneID: ItemID
|
let laneID: ItemID
|
||||||
|
|
||||||
func validateDrop(info: DropInfo) -> Bool {
|
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)
|
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +779,7 @@ struct StripDropDelegate: DropDelegate {
|
|||||||
let context: BoardDropContext
|
let context: BoardDropContext
|
||||||
|
|
||||||
func validateDrop(info: DropInfo) -> Bool {
|
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)
|
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +821,7 @@ struct BoardFallbackDropDelegate: DropDelegate {
|
|||||||
let context: BoardDropContext
|
let context: BoardDropContext
|
||||||
|
|
||||||
func validateDrop(info: DropInfo) -> Bool {
|
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)
|
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// 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
|
var fileCount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
import UniformTypeIdentifiers
|
||||||
@testable import Kanban
|
@testable import Kanban
|
||||||
|
|
||||||
/// `BoardStore`'s Finder file drops — the writes an external file drag performs
|
/// `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
|
/// (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
|
/// 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
|
/// 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
|
/// 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`,
|
/// rank a created card took, which card was removed again when a batch failed, and which folders were
|
||||||
/// `Ident` and `Item` come from `WriterTestSupport.swift`.
|
/// 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
|
/// 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
|
/// `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
|
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
|
@discardableResult
|
||||||
func folder(_ relativePath: String) throws -> URL {
|
func folder(_ relativePath: String) throws -> URL {
|
||||||
let url = root.appendingPathComponent(relativePath, isDirectory: true)
|
let url = root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
return url
|
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)
|
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")
|
#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")
|
@Test("A source that cannot be imported banners and leaves nothing half-copied")
|
||||||
func aFailingSourceBanners() throws {
|
func aFailingSourceBanners() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
@@ -194,14 +204,15 @@ struct ImportAttachmentsToCardTests {
|
|||||||
defer { sources.tearDown() }
|
defer { sources.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let shot = try sources.file("shot.png")
|
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(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
|
||||||
#expect(store.banners.oneShots.count == 1)
|
#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)
|
#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")
|
@Test("A file that fails mid-batch banners, keeps the cards already made, and leaves no half-made one")
|
||||||
func partialFailureLeavesNoHalfMadeCard() throws {
|
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()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let sources = try DropSources()
|
let sources = try DropSources()
|
||||||
@@ -352,17 +426,139 @@ struct CreateCardsFromFilesTests {
|
|||||||
let directory = try sources.folder("Project")
|
let directory = try sources.folder("Project")
|
||||||
let after = try sources.file("after.txt")
|
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
|
// Two cards, in drop order, and no half-made third: the file *after* the folder imports,
|
||||||
// before the third — so the lane grew by exactly one.
|
// which is the whole difference from the old abort-at-the-folder behaviour.
|
||||||
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot"])
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot", "after"])
|
||||||
let landed = try cards(lane1, in: fixture)
|
let landed = try cards(lane1, in: fixture)
|
||||||
#expect(landed.count == 4)
|
#expect(landed.count == 5)
|
||||||
#expect(try fixture.entryNames(Ident.lane1).count == 5, "index.md, three cards, one new one")
|
#expect(try fixture.entryNames(Ident.lane1).count == 6, "index.md, three cards, two new ones")
|
||||||
#expect(landed.last?.attachments == ["shot.png"])
|
#expect(landed[3].attachments == ["shot.png"])
|
||||||
#expect(store.banners.oneShots.count == 1)
|
#expect(landed[4].attachments == ["after.txt"])
|
||||||
#expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "Project"))
|
#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