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
+113
View File
@@ -19,6 +19,38 @@ struct DropTarget: Equatable, Sendable {
var index: Int
}
// MARK: - Where an external file drag would land
/// Where an external **Finder file** session would land (04-interactions.md Drag and drop, "Files
/// from Finder").
///
/// A separate type from `DropTarget` because a file session is a separate *mode*: nothing of ours is
/// in flight, so there is no dragged run to lift out of the resting layout, no operation to resolve
/// against modifiers, and no source board to compare roots with only a destination and what the
/// files would become there.
struct FileDropTarget: Equatable, Sendable {
/// The two landings 04 gives a file drop, and the whole of its behavioural split.
enum Landing: Equatable, Sendable {
/// **Onto a card**: the files copy into that card's `attachments/`. A card under the cursor
/// always wins over the lane behind it attach beats create, anywhere on the card's bounds.
case attach(cardID: ItemID)
/// **Onto lane empty space**: one card per file, landing at this position in the lane's
/// logical card order.
case create(laneID: ItemID, index: Int)
}
/// The board under the cursor only that board's delegates may commit, and only its lanes draw
/// the shadows, exactly as `DropTarget.boardRoot` works for our own sessions.
var boardRoot: URL
var landing: Landing
/// 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.
var fileCount: Int
}
// MARK: - The committed-overlay hold
/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
@@ -192,7 +224,21 @@ final class DragSession {
/// `CommittedHold`.
private(set) var hold: CommittedHold?
// MARK: The external file mode
/// Where an external Finder file drag would land, or `nil` when there is none in flight or it is
/// over nothing that accepts it (`FileDropTarget`).
///
/// **A distinct mode, deliberately kept out of everything above.** A file session arms none of
/// this object's own state `kind` stays `nil`, so `isActive` stays false, no member is hidden
/// from any resting layout, and the marquee and card/lane machinery carry on as if no drag
/// existed. It lives here rather than in a drop delegate for the reason the rest does: the
/// highlight and the shadows render off it, so it has to be observable and it has to be one
/// value the whole window agrees on.
private(set) var fileTarget: FileDropTarget?
@ObservationIgnored private var watchdog: Task<Void, Never>?
@ObservationIgnored private var fileWatchdog: Task<Void, Never>?
@ObservationIgnored private var holdTimeout: Task<Void, Never>?
init() {}
@@ -258,6 +304,49 @@ final class DragSession {
return members.indices.filter { live.contains(members[$0]) }
}
// MARK: The file mode's queries and lifecycle
/// The card an external file drag is hovering **on this board**, or `nil` the attach
/// highlight's one input (`CardFaceView`).
func fileAttachTarget(onBoardRooted root: URL) -> ItemID? {
guard let fileTarget,
DragLocality.isSameBoard(fileTarget.boardRoot, root),
case let .attach(cardID) = fileTarget.landing
else { return nil }
return cardID
}
/// The shadow run a file drop would open in `laneID` on this board where the created cards
/// land and how many there are or `nil` when the proposal is elsewhere.
func fileLaneProposal(onBoardRooted root: URL, laneID: ItemID) -> (index: Int, count: Int)? {
guard let fileTarget,
DragLocality.isSameBoard(fileTarget.boardRoot, root),
case let .create(lane, index) = fileTarget.landing,
lane == laneID
else { return nil }
return (index: index, count: max(1, fileTarget.fileCount))
}
/// Records where the files would land. `nil` withdraws the proposal over a gap, the outer
/// margin, the trash column, or a board that refuses the drop outright.
///
/// **No hysteresis, unlike our own sessions.** A card or lane proposal deliberately *holds* while
/// the cursor crosses ambiguous territory, because the drop must land where the shadows show even
/// if the cursor drifted off a live zone. A file drop has no such contract: it is a plain "what is
/// under the cursor right now", so leaving every target simply clears the highlight and the drop
/// is refused (the pathfinder's `retargetFile` precedent).
func proposeFile(_ target: FileDropTarget?) {
guard fileTarget != target else { return }
let wasHovering = fileTarget != nil
fileTarget = target
if target == nil {
fileWatchdog?.cancel()
fileWatchdog = nil
} else if !wasHovering {
armFileWatchdog()
}
}
// MARK: Lifecycle
/// Begins a card session live faces or trash rows.
@@ -415,4 +504,28 @@ final class DragSession {
}
}
}
/// The file mode's own watchdog, and its only guaranteed termination path.
///
/// An external session is not ours to end: no `performDrop` runs when the user drops the files
/// somewhere else entirely, and `onDragSessionUpdated` reports only sessions this app started. A
/// drag is a button held down, so the same poll the internal watchdog uses answers here when
/// the button has been up for a grace period and a target is still standing, the highlight is
/// stale and goes.
private func armFileWatchdog() {
fileWatchdog?.cancel()
fileWatchdog = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(120))
guard let self, self.fileTarget != nil else { return }
guard NSEvent.pressedMouseButtons == 0 else { continue }
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled, self.fileTarget != nil else { return }
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) {
self.proposeFile(nil)
}
return
}
}
}
}