Implement Finder file drops

Files from Finder land on the board per 04-interactions.md § Drag & drop:

- Dropped on a card, they copy into its attachments/ (any type, multi-file),
  the face highlighting while hovered; the hovered card is resolved by
  hit-testing the same analytic masonry frames the card zones are built
  from, so attach-beats-create adds no drop region and cannot drift from
  the dispatch.
- Dropped on lane empty space, one card per file — filename minus extension
  as the title (a blank stem omits the key), fresh GUID, rank at the drop
  position through the ordinary insertion machinery, the file attached —
  all in one bracket; a failed import removes the just-minted card, so
  creating-then-abandoning never leaves an empty card behind.
- Every board drop surface now declares .fileURL beside the two board
  types (the single-target-dispatch rule); tombstoned surfaces are inert;
  file sessions ride a distinct session mode with their own watchdog and
  no hysteresis, leaving the board-drag machinery untouched.
- Also: four empty fixture directories pinned with .keep files so git
  preserves them, and a test-only visibility fix in DragSessionTests.

811 unit tests (12 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 21:20:46 -04:00
parent 90cf82d740
commit 8f116934b4
13 changed files with 992 additions and 59 deletions
+297 -35
View File
@@ -227,6 +227,189 @@ struct BoardDropContext {
}
}
// 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
}
/// How many files the session carries the shadow run's length on the create path.
///
/// 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)
}
/// 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 acceptsFileDrops, 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 acceptsFileDrops, 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`).
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() } }
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)
}
}
return true
}
// MARK: The drop proposal the badge tracks
/// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
@@ -326,56 +509,112 @@ struct BoardDropContext {
}
}
// 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: - 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 **both** board types and routes internally; a region whose topmost target
// understood only one of them would be a dead zone for the other no hover callbacks, and a release
// there would snap back instead of committing.
// 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.
//
// m5-finder-drops: the next card adds external Finder file sessions (`.fileURL`) to this same
// dispatch files onto a card become attachments, files onto lane empty space become cards
// (04-interactions.md Drag and drop) and the same dead-region rule applies to them, so the type
// list and the routing switch in each delegate below grow by one case rather than gaining a delegate
// of their own.
// **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.
/// The board types every delegate declares. 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]
/// 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.
/// 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 {
// m5-finder-drops: a file session resolves against these same masonry zones onto a card it
// becomes attachments, onto empty space a card per file (04-interactions.md Drag and drop).
let context: BoardDropContext
let laneID: ItemID
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
if context.isFileSession(info) { return context.acceptsFileDrops }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { retarget() }
func dropEntered(info: DropInfo) { retarget(info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
retarget()
return context.dropProposal()
retarget(info)
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
// No `dropExited`, deliberately: the proposal is meant to **hold** while the cursor leaves for
// ambiguous territory that is the hysteresis contract (DRAG-REORDER.md § Hysteresis).
/// **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.commitDrop()
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
private func retarget() {
private func retarget(_ info: DropInfo) {
if context.isFileSession(info) {
context.retargetFile(inLane: laneID, info: info)
return
}
context.revalidateProposal()
if context.session.isDraggingLanes {
context.retargetLanes()
@@ -392,26 +631,41 @@ struct LaneDropDelegate: DropDelegate {
/// 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 {
// m5-finder-drops: the strip is a file session's safety net too the same dead-region
// hit-testing bug can strand one, and without file support here it would have nowhere to land.
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
if context.isFileSession(info) { return context.acceptsFileDrops }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { context.retargetFromStrip() }
func dropEntered(info: DropInfo) { retarget(info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
context.retargetFromStrip()
return context.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.commitDrop()
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
private func retarget(_ info: DropInfo) {
if context.isFileSession(info) {
context.retargetFileFromStrip(info)
} else {
context.retargetFromStrip()
}
}
}
@@ -419,22 +673,30 @@ struct StripDropDelegate: DropDelegate {
/// 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 {
// m5-finder-drops: a file session released over uncovered window chrome commits whatever the
// file target currently names, exactly as this commits the current card/lane proposal.
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
if context.isFileSession(info) { return context.acceptsFileDrops }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
context.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.commitDrop()
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
}