Files
lanework/Kanban/UI/Board/BoardDrops.swift
T
rzen 1487b391ad 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
2026-07-28 07:29:43 -04:00

843 lines
42 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import UniformTypeIdentifiers
// MARK: - What each lane draws, as the drag reads it
/// Where each lane's card grid is drawn and how tall its cards are — the measured half of the card
/// masonry's drop geometry, one registry per board window.
///
/// **Deliberately not `@Observable`.** Nothing renders off it: it exists so a drop delegate and the
/// autoscroll driver can ask, at *event* time, where the grid is and what the resting row extents
/// are. Observing it would invalidate the strip on every layout pass, which is the animation
/// feedback loop this whole model exists to avoid.
///
/// ### What is measured here, and why that is animation-proof
///
/// 03-board-ui.md § Motion forbids reading *mid-flight* measurements. Two things are read here and
/// neither is one:
///
/// - **The grid's frame.** A lane's card area does not move while a card session is in flight: the
/// masonry reflows *inside* it, and the strip only reflows for a lane session, which reads none of
/// this.
/// - **Each card's height.** A card's height is content-driven — the column width is fixed by the
/// lane's unit count — so it does not animate under the reflow; only positions do. The positions
/// are never measured: they are replayed analytically from these heights through
/// `MasonryPlacement.frames(heights:)`, which is the very function `MasonryLayout` places with
/// (DRAG-REORDER.md § The card masonry).
///
/// The one input that *is* frozen at drag start is the **dragged** cards' own heights, which live on
/// `DragSession`: the pickup transition scales the replica and corrupts its last measured frame.
@MainActor
final class LaneDropRegistry {
/// One lane's masonry, as drawn.
struct Grid: Equatable, Sendable {
/// The card area's frame in the window's SwiftUI global space — the space the physical
/// cursor is converted into (`BoardDropContext.globalCursor`).
var frame: CGRect
/// Interior masonry columns — the lane's width units.
var columns: Int
/// Spacing between columns and between stacked cards.
var spacing: CGFloat
}
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
static let nominalCardHeight: CGFloat = 44
/// The board strip's own frame in the window's SwiftUI global space — the origin the strip
/// coordinates `DropSlotMath.laneExtents` is written in are measured from. It lives here rather
/// than in the view's `@State` so a delegate reads the *current* rectangle at event time.
var stripFrame: CGRect = .zero
private(set) var grids: [ItemID: Grid] = [:]
private(set) var heights: [ItemID: CGFloat] = [:]
func update(_ grid: Grid, for laneID: ItemID) { grids[laneID] = grid }
func removeGrid(_ laneID: ItemID) { grids.removeValue(forKey: laneID) }
func update(height: CGFloat, for cardID: ItemID) { heights[cardID] = height }
func removeHeight(_ cardID: ItemID) { heights.removeValue(forKey: cardID) }
}
// MARK: - The board window's half of a drop
/// Everything a drop delegate — and the autoscroll driver — needs to answer "where would this land",
/// read at **event** time rather than captured at body-evaluation time.
///
/// The closures are the point: a captured snapshot of the strip's frame or its standard width goes
/// stale the moment the layout animates, and two delegates holding different snapshots would flap
/// the proposal between them.
@MainActor
struct BoardDropContext {
let store: BoardStore
let session: DragSession
let registry: LaneDropRegistry
/// The strip's inter-lane gap, which is also its outer margin.
let gap: CGFloat
/// The window hosting this board — the physical cursor is converted through it.
let window: @MainActor () -> NSWindow?
/// The strip's frame in the window's SwiftUI global space.
let stripFrame: @MainActor () -> CGRect
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
let standard: @MainActor () -> CGFloat
// MARK: The cursor
/// The physical cursor in the window's SwiftUI global space.
///
/// **`NSEvent.mouseLocation`, never `DropInfo.location`** (DRAG-REORDER.md § Animation-proof
/// inputs): the drop callback's location is expressed in the target view's space, and that view
/// may itself be mid-reflow. This is also what `LaneResizeSession` and `MarqueeSession` already
/// read.
func globalCursor() -> CGPoint? {
guard let window = window(), let content = window.contentView else { return nil }
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
let inContent = content.convert(inWindow, from: nil)
// SwiftUI's global space is top-left-origin; an unflipped `NSView` is bottom-left.
let y = content.isFlipped ? inContent.y : content.bounds.height - inContent.y
return CGPoint(x: inContent.x, y: y)
}
/// The cursor in strip coordinates — 0 at the strip's leading edge, outer margin included, which
/// is the origin `DropSlotMath.laneExtents` assumes.
func stripCursor() -> CGPoint? {
guard let cursor = globalCursor() else { return nil }
let frame = stripFrame()
return CGPoint(x: cursor.x - frame.minX, y: cursor.y - frame.minY)
}
// MARK: Re-grounding
/// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md ▸ Drag and drop): a proposal
/// whose target lane was tombstoned or vanished in a reload is invalidated — tombstoned lanes are
/// never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a
/// live target.
///
/// Run at the top of every callback *and* again at release, against the snapshot as it is then;
/// the store's commits enforce the same rule independently, so the gesture and the write cannot
/// disagree.
func revalidateProposal() {
guard let proposal = session.proposal,
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
let laneID = proposal.laneID
else { return }
guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return }
session.propose(nil)
}
// MARK: Retargeting — the one shared answer
/// Where a **lane** session would land on this board's strip.
///
/// The zones are analytic — `DropSlotMath.laneExtents` over the remaining lanes' unit counts and
/// this strip's standard width — and the cursor is the physical mouse, so neither input is a
/// measured frame (03-board-ui.md § Motion).
///
/// **The terminal slot is clamped before the trash.** The quasi-lane consumes one unit while
/// shown and is never a landing spot for anything (04-interactions.md ▸ The trash: "no move or
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
/// slot's uncapped reach past the last real lane lands *before* it.
func retargetLanes() {
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) }
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
let slot = DropSlotMath.laneSlot(
cursorX: cursor.x,
restingUnits: restingUnits,
draggedUnits: session.laneUnits,
standard: standard(),
gap: gap,
current: session.stripProposal(onBoardRooted: store.rootURL)
)
guard let slot else { return } // a dead region: hold the current proposal
let index = min(max(0, slot), restingUnits.count)
session.propose(DropTarget(boardRoot: store.rootURL, laneID: nil, index: index))
}
/// Where a **card** session would land in `laneID`'s masonry.
///
/// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
/// driver all go through — "the lane's drop delegate and the autoscroll driver must go through
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
func retargetCards(inLane laneID: ItemID) {
guard session.isDraggingCards, let cursor = globalCursor() else { return }
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else {
revalidateProposal()
return
}
guard let grid = registry.grids[laneID] else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement(
columnCount: grid.columns,
columnWidth: MasonryPlacement.columnWidth(
totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing),
spacing: grid.spacing,
origin: grid.frame.origin
)
let slot = DropSlotMath.cardSlot(
cursor: cursor,
placement: placement,
heights: heights,
// The run's footprint at the landing spot: the first dragged card's frozen height, which
// is the trigger rect the cursor is over (the rest stack below it).
draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight,
current: session.laneProposal(onBoardRooted: store.rootURL, laneID: laneID)
)
guard let slot else { return } // a dead region: hold
session.propose(DropTarget(boardRoot: store.rootURL, laneID: laneID, index: slot))
}
/// The strip's fall-through for card sessions: which lane is under the cursor, analytically.
///
/// This is both the safety net for a lane whose own drop region goes dead (DRAG-REORDER.md §
/// Single-target dispatch) and the live handler for the strip's own regions. A cursor over a gap,
/// the outer margin, or the trash column is over no lane at all — `LaneLayoutMath.laneIndex`
/// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract.
func retargetCardsFromStrip() {
guard session.isDraggingCards, let cursor = stripCursor() else { return }
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
let index = LaneLayoutMath.laneIndex(
atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
standard: standard(),
gap: gap
)
guard let index, lanes.indices.contains(index) else { return }
retargetCards(inLane: lanes[index].id)
}
/// The retarget for whichever session type is in flight, from the strip's own surfaces.
func retargetFromStrip() {
revalidateProposal()
if session.isDraggingLanes {
retargetLanes()
} else {
retargetCardsFromStrip()
}
}
// MARK: Retargeting — external Finder file sessions
/// Whether `info` is an **external Finder file** session rather than one of ours.
///
/// Never ambiguous: our own drags arm `DragSession` synchronously at `.onDrag` time, before any
/// drop callback can arrive, so a session that is not active but carries `.fileURL` items came
/// from outside the app. (A board drag also carries a plain-text representation and no file URL,
/// so the two type sets never overlap.)
func isFileSession(_ info: DropInfo) -> Bool {
!session.isActive && info.hasItemsConforming(to: [.fileURL])
}
/// Whether this board accepts a file drop at all — **the mutating-gesture rule, applied to the
/// one gesture that arrives from outside the app**: under the read-only lock (02-architecture.md
/// § The lock's scope) or with an inline title editor focused (04-interactions.md ▸ Grammar's
/// focused-editor rule) a file drop refuses at the board, with no highlight and no proposal —
/// the same refusal `.onDrag` makes by handing back an empty item provider.
var acceptsFileDrops: Bool {
!store.isReadOnly && !store.isEditingInline
}
/// 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, FinderDrop.importableCount(info.itemProviders(for: [.fileURL])))
}
/// Where a **file** session would land in `laneID` — the file mode's twin of `retargetCards`,
/// resolved against the very same analytic masonry geometry.
///
/// Two answers, in this order:
///
/// 1. **A card under the cursor always wins** (04-interactions.md ▸ Drag and drop): attach beats
/// create, anywhere on the card's bounds. The bounds are the resting frames
/// `MasonryPlacement.frames(heights:)` replays — the same reconstruction the card-slot zones
/// are built from, never a measured frame (03-board-ui.md § Motion). While a create shadow is
/// open the *drawn* cards sit lower than their resting frames, which is exactly the tradeoff
/// every proposal in this app makes: the answer stays a pure function of the cursor and the
/// snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
/// 2. **Otherwise the create slot**, from `DropSlotMath.cardSlot` — "new cards land at the drop
/// position using the same card-grid zone math ordinary card drags use". The footprint the
/// span cap is measured against is the nominal card height, since the cards being proposed do
/// not exist yet to have one; the cap only ever truncates a zone that lies *over* an existing
/// card, and that region is case 1's.
///
/// **Positional landing is filed for design ratification.** 04's bullet says only "dropped on
/// lane empty space → creates a card"; that the card lands at the *drop position* rather than at
/// 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 acceptsFileDrop(info), let cursor = globalCursor(),
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
let grid = registry.grids[laneID]
else {
session.proposeFile(nil)
return
}
let rendered = lane.cards.filter { !$0.isDeleted }
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement(
columnCount: grid.columns,
columnWidth: MasonryPlacement.columnWidth(
totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing),
spacing: grid.spacing,
origin: grid.frame.origin
)
let count = fileCount(info)
// Closed containment — a cursor sitting exactly on a shared edge still counts, and the first
// match wins, so the answer is deterministic however the frames abut.
let frames = placement.frames(heights: heights)
if let index = frames.firstIndex(where: { frame in
cursor.x >= frame.minX && cursor.x <= frame.maxX
&& cursor.y >= frame.minY && cursor.y <= frame.maxY
}), index < rendered.count {
session.proposeFile(FileDropTarget(
boardRoot: store.rootURL,
landing: .attach(cardID: rendered[index].id),
fileCount: count
))
return
}
let slot = DropSlotMath.cardSlot(
cursor: cursor,
placement: placement,
heights: heights,
draggedHeight: LaneDropRegistry.nominalCardHeight,
current: session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: laneID)?.index
)
guard let slot else { return } // a dead region: hold whatever the create slot already was
session.proposeFile(FileDropTarget(
boardRoot: store.rootURL,
landing: .create(laneID: laneID, index: slot),
fileCount: count
))
}
/// The strip's fall-through for file sessions: which lane is under the cursor, analytically.
///
/// Both a safety net for a lane whose own drop region goes dead (DRAG-REORDER.md § Single-target
/// dispatch) and the live handler for the strip's own surfaces — and unlike a card session, those
/// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
/// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
/// `nil` there, since the quasi-lane is absent from the live lane list by construction), so the
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md ▸ The
/// trash).
func retargetFileFromStrip(_ info: DropInfo) {
guard acceptsFileDrop(info), let cursor = stripCursor() else {
session.proposeFile(nil)
return
}
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
let index = LaneLayoutMath.laneIndex(
atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
standard: standard(),
gap: gap
)
guard let index, lanes.indices.contains(index) else {
session.proposeFile(nil)
return
}
retargetFile(inLane: lanes[index].id, info: info)
}
/// What `dropUpdated` answers for a file session: always `.copy` — importing a file leaves the
/// original where it was, which is what the badge should say — and `.cancel` when nothing under
/// the cursor will take it.
func fileDropProposal() -> DropProposal {
session.fileTarget != nil ? DropProposal(operation: .copy) : DropProposal(operation: .cancel)
}
/// Commits a file drop: the files land where the highlight or the shadows showed.
///
/// **The target is captured and the hover state cleared before the load starts.** A provider's
/// file URL loads asynchronously — it is never synchronous for a Finder drag — and a fast second
/// 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,
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
else {
session.proposeFile(nil)
return false
}
let providers = info.itemProviders(for: [.fileURL])
session.proposeFile(nil)
guard !providers.isEmpty else { return false }
let store = self.store
Task { @MainActor in
var urls: [URL] = []
for provider in providers {
if let url = await FileDropLoading.url(from: provider) { urls.append(url) }
}
guard !urls.isEmpty else { return }
// The sandbox's half: a Finder drag hands the app an extension for what it dropped, and
// the copy is the read that needs it. `start…` answers false for a URL that carries no
// scope of its own — an ordinary in-container path — so only the ones that opened are
// closed again.
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
// 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
}
// MARK: The drop proposal the badge tracks
/// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
/// `.cancel` otherwise.
///
/// The operation is re-resolved here rather than at pickup, which is what makes the badge track
/// live as the cursor crosses a board boundary (04-interactions.md ▸ Drag and drop).
func dropProposal() -> DropProposal {
guard let proposal = session.proposal,
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL)
else { return DropProposal(operation: .cancel) }
let operation = session.resolveOperation(destinationRoot: store.rootURL)
return DropProposal(operation: operation == .copy ? .copy : .move)
}
// MARK: The commit
/// Commits the current proposal — **the drop always lands exactly where the shadows show**
/// (DRAG-REORDER.md § The pieces).
///
/// The three re-grounding rules are applied here, against the snapshot as it is *now* rather than
/// against whatever the last render believed:
///
/// 1. the geometry was re-derived on every sample and the proposal is what it produced;
/// 2. a proposal naming a vanished or tombstoned lane is invalidated, and **release with no valid
/// proposal cancels** — items return, nothing is written;
/// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
///
/// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the
/// set's size (DRAG-REORDER.md § The drop commits).
func commitDrop() -> Bool {
guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
return false
}
revalidateProposal()
guard let target = session.proposal,
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
else {
cancelDrop()
return false
}
let survivors = session.survivors
guard !survivors.isEmpty else {
cancelDrop()
return false
}
let ids = survivors.map { session.members[$0] }
let folders = survivors.map { session.folders[$0] }
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
let operation = session.resolveOperation(destinationRoot: store.rootURL)
switch kind {
case .lanes:
if within {
store.moveLanes(Set(ids), toIndex: target.index)
} else {
store.receiveLanes(folders, operation: operation, at: target.index)
}
case .cards:
guard let laneID = target.laneID else {
cancelDrop()
return false
}
switch (session.side, within) {
case (.live, true):
if operation == .copy {
store.copyCards(Set(ids), toLane: laneID, at: target.index)
} else {
store.moveCards(Set(ids), toLane: laneID, at: target.index)
}
case (.live, false):
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
case (.trashed, true):
// Drag-to-restore, and its ⌥ twin. "Dropping a tombstoned card into one of its own
// board's lanes restores it at the drop position"; ⌥ is the copy-out instead — a
// live copy lands and the tombstoned original stays (04-interactions.md ▸ The trash,
// "⌘C, ⌥-drag … always yield live copies").
if operation == .copy {
store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index)
} else {
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index)
}
case (.trashed, false):
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
}
}
// The committed-overlay hold: keep drawing the arrangement until this store's next snapshot.
session.commit(into: store)
return true
}
/// Release with nothing valid to write: the items return and nothing is written.
func cancelDrop() {
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { session.end() }
}
}
// MARK: - Loading what Finder dropped
/// The one place an `NSItemProvider` from an external drag is unwrapped into a file URL.
enum FileDropLoading {
/// The file URL a dropped provider carries, or `nil` when it carries none.
///
/// **`loadItem` on `.fileURL`, not `loadInPlaceFileRepresentation`.** A `public.file-url` item is
/// what Finder actually puts on the dragging pasteboard, and the drag itself is what grants the
/// sandbox the extension to read it — the caller opens the scope and copies. The in-place
/// representation would hand back a URL valid only for the duration of its own completion block,
/// forcing the copy to happen off the main actor inside a callback, for no benefit here.
///
/// The completion fires on an arbitrary queue — never assume the main actor — so this is a plain
/// continuation wrapper; a `CheckedContinuation` resumes from any queue whatever isolation the
/// call site started from. Both shapes a `.fileURL` item arrives in are accepted: the `Data`
/// encoding it normally takes, and a bare `URL`.
///
/// `@MainActor` for a concurrency reason rather than a behavioural one: the provider comes off a
/// `DropInfo` on the main actor and is not `Sendable`, so a nonisolated entry point would be
/// *sending* it across domains. Staying on the actor it came from keeps the hand-off to the
/// completion handler — which fires wherever AppKit likes — the only crossing there is.
@MainActor
static func url(from provider: NSItemProvider) async -> URL? {
await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) {
continuation.resume(returning: url)
} else if let url = item as? URL {
continuation.resume(returning: url)
} else {
continuation.resume(returning: nil)
}
}
}
}
}
// 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
// delivers a drag session to the *deepest* drop region under the cursor with no fall-through, not
// even when that target's declared content types don't match the session's payload. So every
// delegate below accepts **all three** session types — cards, lanes, and external Finder file drags
// — and routes internally; a region whose topmost target understood only some of them would be a
// dead zone for the rest — no hover callbacks, and a release there would snap back instead of
// committing.
//
// **The file routing is resolved inside these delegates, not by a drop target of the card's own.**
// A per-face `onDrop` would be the deepest region under the cursor and would therefore have to
// re-implement card and lane routing too, just to avoid becoming a dead zone for them — a second
// copy of the dispatch, able to disagree with this one. Instead a card under the cursor is found by
// 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]
/// Every type a board surface's `onDrop` declares: our own two, plus the external Finder file drag
/// (04-interactions.md ▸ Drag and drop). **A target that left `.fileURL` off would be a dead zone
/// that strands a file session** — no hover callbacks, and a refused release with nothing to explain
/// why (DRAG-REORDER.md § Single-target dispatch).
let boardDropTypes: [UTType] = boardDragTypes + [.fileURL]
/// One lane's drop target, attached to the whole lane body.
///
/// **Card sessions** resolve against this lane's masonry zones. **Lane sessions** are forwarded to
/// the strip's logic (cursor converted to strip space by the shared context), so lane reordering
/// keeps working while the cursor crosses lane bodies. **File sessions** resolve against those same
/// masonry zones — onto a card they become attachments, onto empty space one card per file.
struct LaneDropDelegate: DropDelegate {
let context: BoardDropContext
let laneID: ItemID
func validateDrop(info: DropInfo) -> Bool {
if context.isFileSession(info) { return context.acceptsFileDrop(info) }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { retarget(info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
retarget(info)
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
/// **Only file sessions have an exit hook.** A card or lane proposal is meant to *hold* while the
/// cursor leaves for ambiguous territory — that is the hysteresis contract (DRAG-REORDER.md §
/// Hysteresis) — while a file proposal is only ever "what is under the cursor now", so leaving
/// the lane body clears the highlight.
func dropExited(info: DropInfo) {
guard context.isFileSession(info) else { return }
context.session.proposeFile(nil)
}
func performDrop(info: DropInfo) -> Bool {
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
private func retarget(_ info: DropInfo) {
if context.isFileSession(info) {
context.retargetFile(inLane: laneID, info: info)
return
}
context.revalidateProposal()
if context.session.isDraggingLanes {
context.retargetLanes()
} else {
context.retargetCards(inLane: laneID)
}
}
}
/// The strip's drop target — the backdrop, the gaps, the outer margin, and the trash column's
/// footprint, which is never a landing spot of its own (04-interactions.md ▸ The trash) and so
/// simply falls through to here.
///
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
/// same shared function the lane delegates use, resolving the lane under the cursor analytically —
/// the safety net for a lane whose own drop region goes dead.
/// File sessions are the strip's twin safety net, and here its own surfaces are a *live* handler for
/// them rather than only a fallback: a file drag has no proposal to hold, so hovering a gap, the
/// outer margin, or the trash column clears the target outright.
struct StripDropDelegate: DropDelegate {
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
if context.isFileSession(info) { return context.acceptsFileDrop(info) }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { retarget(info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
retarget(info)
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
/// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions.
func dropExited(info: DropInfo) {
guard context.isFileSession(info) else { return }
context.session.proposeFile(nil)
}
func performDrop(info: DropInfo) -> Bool {
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
private func retarget(_ info: DropInfo) {
if context.isFileSession(info) {
context.retargetFileFromStrip(info)
} else {
context.retargetFromStrip()
}
}
}
/// The window-level fallback, behind every specific target: a release over any in-window region they
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
/// leaking the session into a cancel-snapback. It retargets nothing — the drop lands where the
/// shadows already show, which is what the shadows promise.
/// A file session released over uncovered window chrome commits whatever the file target currently
/// names, exactly as this commits the current card/lane proposal — and with no target standing it
/// simply refuses, which is the honest answer for a release over nothing.
struct BoardFallbackDropDelegate: DropDelegate {
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
if context.isFileSession(info) { return context.acceptsFileDrop(info) }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
/// The drag left the window's last covering region — with nothing below this one to take over,
/// a standing file highlight would be stale.
func dropExited(info: DropInfo) {
guard context.isFileSession(info) else { return }
context.session.proposeFile(nil)
}
func performDrop(info: DropInfo) -> Bool {
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
}