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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user