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
+9 -3
View File
@@ -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.
+153 -13
View File
@@ -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)
}
+4
View File
@@ -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
}