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
+59 -13
View File
@@ -98,13 +98,11 @@ struct LaneView: View {
.background(selectionBackground)
.overlay(selectionStroke)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
// **This lane's drop target**, on the whole body. It accepts *both* board types and routes
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes
// internally card sessions against this lane's masonry zones, lane sessions forwarded to
// the strip's logic because single-target dispatch has no fall-through (DRAG-REORDER.md).
//
// m5-finder-drops: external Finder file sessions join this same target and this same
// routing; the type list grows by `.fileURL` and the delegate by one branch.
.onDrop(of: boardDragTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
// the strip's logic, external Finder file sessions against those same zones because
// single-target dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
}
// MARK: - Header
@@ -452,11 +450,11 @@ struct LaneView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's drop proposal**
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
.animation(Motion.dragReflow(reduced: reduceMotion), value: cardProposal)
.animation(Motion.dragReflow(reduced: reduceMotion), value: shadowRun)
// Where this lane's card grid is drawn, in the window's global space the analytic
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
// re-derived, so the zones and the drawn grid cannot disagree.
@@ -511,12 +509,34 @@ struct LaneView: View {
}
}
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere the
/// shadow run's position, and the reflow's narrow animation key.
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
private var cardProposal: Int? {
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
}
/// The shadow run this lane opens, or `nil` when no proposal names it the masonry's one
/// make-room mechanism, and the reflow's narrow animation key.
///
/// Two sessions feed it and they are mutually exclusive by construction (a file session never
/// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight):
///
/// - **a card drag**, at the dragged cards' frozen heights the run's real footprint, so the
/// drop lands exactly where the shadows are;
/// - **a Finder file drag**, at the nominal height, one shadow per file the cards being
/// proposed do not exist yet, so there is no measured height to be faithful to.
private var shadowRun: ShadowRun? {
if let position = cardProposal {
return ShadowRun(position: position, heights: drops.session.cardHeights)
}
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
return ShadowRun(
position: proposal.index,
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
)
}
return nil
}
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
/// proposal, and the new-card placeholder when this lane is the one being created into.
///
@@ -529,14 +549,15 @@ struct LaneView: View {
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
let shadowPosition = cardProposal.map { min(max(0, $0), result.count) }
let run = shadowRun
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
var placeholderPosition: Int?
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
?? result.count
}
let heights = drops.session.cardHeights
let heights = run?.heights ?? []
if let shadowPosition {
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
result.insert(contentsOf: shadows, at: shadowPosition)
@@ -604,6 +625,16 @@ struct LaneView: View {
// MARK: - Lane slots
/// The run of shadows a lane opens for whichever session is proposing into it where it starts and
/// what each shadow is worth in height.
///
/// `Equatable` because it is the reflow's animation key: within a session the heights never change,
/// so the value moves exactly when the proposal does.
private struct ShadowRun: Equatable {
var position: Int
var heights: [CGFloat]
}
/// What a lane's masonry lays out its cards, plus at most one pseudo-card.
///
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
@@ -704,9 +735,16 @@ private struct CardFaceView: View {
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
// highlights while hovered" (04-interactions.md Drag and drop), and the accent stroke is
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
// hovered card that is *also* selected still reads as the target.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
@@ -961,6 +999,14 @@ private struct CardFaceView: View {
store.selection.liveness == .live && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
/// to one implementation (`BoardDrops`).
private var isFileHovered: Bool {
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
}
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == card.id
}