Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
881 lines
45 KiB
Swift
881 lines
45 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
// MARK: - What each lane draws, as the drag reads it
|
||
|
||
/// Where each lane's card grid and title bar are 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).
|
||
/// - **Each lane's header frame.** The title bar sits outside the card scroll view and above it, so
|
||
/// it neither scrolls nor reflows for anything a drop can do; it is read for one rule only — "a
|
||
/// release on the lane header resolves to the topmost position" (04-interactions.md ▸ Drag and
|
||
/// drop, settled 2026-07-28), which needs an edge the scrolling masonry cannot supply.
|
||
///
|
||
/// 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.
|
||
///
|
||
/// **Font-derived** (`BoardMetrics.nominalCardHeight`, 10-accessibility.md's full-relative-scaling
|
||
/// rule), and it matters more here than at most sites: this is the stand-in the drop model tiles
|
||
/// rows with before anything has measured itself, so a figure fixed at 13pt's 44 points would put
|
||
/// every un-measured slot boundary in the wrong place at a large system text size — and the drop
|
||
/// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this
|
||
/// guess is all it has until the lane lays out.
|
||
@MainActor
|
||
static var nominalCardHeight: CGFloat {
|
||
BoardMetrics.nominalCardHeight(bodyPointSize: BoardMetrics.bodyPointSize)
|
||
}
|
||
|
||
/// 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] = [:]
|
||
|
||
/// Each lane's title bar, in the same global space `Grid.frame` is written in — the topmost-rule
|
||
/// stripe (`FileDropZones.landing`). Absent for a lane that has not laid its header out yet, and
|
||
/// the file zones then let the masonry answer alone.
|
||
private(set) var headers: [ItemID: CGRect] = [:]
|
||
|
||
func update(_ grid: Grid, for laneID: ItemID) { grids[laneID] = grid }
|
||
func removeGrid(_ laneID: ItemID) { grids.removeValue(forKey: laneID) }
|
||
|
||
func update(header frame: CGRect, for laneID: ItemID) { headers[laneID] = frame }
|
||
func removeHeader(_ laneID: ItemID) { headers.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 vanished in a reload is invalidated — deleted 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 }) 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 column consumes one unit while
|
||
/// shown and is never a *position* on the strip (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. The delete a lane drag can
|
||
/// propose over the column is not a slot at all — it is the column's own target answering
|
||
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
|
||
func retargetLanes() {
|
||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||
let resting = store.snapshot.lanes.filter { !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, container: .strip, 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 }) else {
|
||
revalidateProposal()
|
||
return
|
||
}
|
||
guard let grid = registry.grids[laneID] else { return }
|
||
|
||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||
let rendered = lane.cards.filter { !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, container: .lane(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
|
||
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 — the trash column
|
||
|
||
/// Whether the trash column would take the session in flight right now — `TrashDrop.accepts`
|
||
/// with this board's state read in (04-interactions.md ▸ The trash, settled 2026-07-28).
|
||
///
|
||
/// A method rather than a property because it is not free of consequence: the operation is
|
||
/// re-resolved against the modifiers *at this instant*, which is also what keeps the badge honest
|
||
/// while the cursor sits over the column (`dropProposal`).
|
||
func acceptsTrashDrop() -> Bool {
|
||
guard let sourceRoot = session.sourceRoot else { return false }
|
||
return TrashDrop.accepts(
|
||
kind: session.kind,
|
||
container: session.container,
|
||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
|
||
operation: session.resolveOperation(destinationRoot: store.rootURL),
|
||
isTrashShown: store.transient.isTrashVisible,
|
||
acceptsMutations: store.acceptsBoardMutations
|
||
)
|
||
}
|
||
|
||
/// Where a session over the **trash column** would land: the topmost row, or nowhere.
|
||
///
|
||
/// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**,
|
||
/// which is precisely what this column did before it had a drop target of its own: a cursor over
|
||
/// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show
|
||
/// and `retargetLanes` clamps the terminal slot in front of the trash column. That is the
|
||
/// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the
|
||
/// trash proposes nothing for you": the column declines to be a target, it does not cancel the
|
||
/// drag the user is still holding. So a lane drag reorders across the column exactly as it always
|
||
/// did, and an ⌥-copy released over it still lands where its shadows are.
|
||
func retargetTrash() {
|
||
revalidateProposal()
|
||
guard acceptsTrashDrop() else {
|
||
retargetFromStrip()
|
||
return
|
||
}
|
||
session.propose(DropTarget(
|
||
boardRoot: store.rootURL,
|
||
container: .trash,
|
||
index: TrashDrop.landingIndex
|
||
))
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
/// Where a **file** session would land in `laneID` — the file mode's twin of `retargetCards`,
|
||
/// resolved against the very same analytic masonry geometry.
|
||
///
|
||
/// The three answers and the order they are asked in are `FileDropZones.landing`'s, kept there so
|
||
/// the ruling is checkable without a window; this is the adapter that feeds it the snapshot and
|
||
/// the registry and turns its answer into a proposal. In short: the **header** is the topmost
|
||
/// position, a **card under the cursor** attaches, and everything else is the **create slot** the
|
||
/// ordinary card zones produce.
|
||
///
|
||
/// **Created cards land at the drop position** (04-interactions.md ▸ Drag and drop, settled
|
||
/// 2026-07-28): "resolved through the same card-grid zones an ordinary card drag uses, shadow
|
||
/// included — drops are positional everywhere, and append-at-bottom stays the creation *trio*'s
|
||
/// rule, not the drop's."
|
||
///
|
||
/// **The landing shadow is the create path's whole feedback** (settled, same bullet): no lane-level
|
||
/// highlight is proposed here or drawn anywhere, because "each target gets one clear signal, and
|
||
/// the card-attach highlight exists precisely because that target has no shadow".
|
||
///
|
||
/// 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.
|
||
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
||
let grid = registry.grids[laneID]
|
||
else {
|
||
session.proposeFile(nil)
|
||
return
|
||
}
|
||
|
||
let rendered = lane.cards
|
||
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 = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
|
||
|
||
let landing = FileDropZones.landing(
|
||
cursor: cursor,
|
||
headerBottom: registry.headers[laneID]?.maxY,
|
||
placement: placement,
|
||
heights: heights,
|
||
nominalHeight: LaneDropRegistry.nominalCardHeight,
|
||
current: session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: laneID)?.index
|
||
)
|
||
|
||
switch landing {
|
||
case .hold:
|
||
return // a dead region: hold whatever the create slot already was
|
||
case let .attach(index):
|
||
guard rendered.indices.contains(index) else { return }
|
||
session.proposeFile(FileDropTarget(
|
||
boardRoot: store.rootURL,
|
||
landing: .attach(cardID: rendered[index].id),
|
||
fileCount: count
|
||
))
|
||
case let .create(index):
|
||
session.proposeFile(FileDropTarget(
|
||
boardRoot: store.rootURL,
|
||
landing: .create(laneID: laneID, index: index),
|
||
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 trash column is absent from the lane list by construction), so the
|
||
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
|
||
/// trash cards are inert" and the trash column is never a file-drop 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
|
||
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 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).
|
||
///
|
||
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
|
||
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` /
|
||
/// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an
|
||
/// item deleted by drop is indistinguishable on disk from one deleted by keystroke
|
||
/// (04-interactions.md ▸ The trash, settled 2026-07-28; lanes extended 2026-07-29).
|
||
///
|
||
/// The write is the first half; the second is the **committed-overlay hold**
|
||
/// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
|
||
/// from proposing to committed and keeps drawing the arrangement it was showing — the shadows at
|
||
/// their landing slots, the originals lifted out — until this store's echo reload lands.
|
||
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 target.isTrash {
|
||
// **The pointer's delete gesture, at the lane level** (04-interactions.md ▸ The
|
||
// trash, lanes extended 2026-07-29: "a lane drag over the shown trash proposes the
|
||
// delete alongside its strip slots"): release moves the lane's folder — subtree
|
||
// intact — into `.trash/`, exactly the ⌫ delete (`BoardStore.deleteLanesByDrag`).
|
||
//
|
||
// The gate is re-asked here rather than trusted from the hover, the card branch's
|
||
// rule for its reason: the modifiers can change after the proposal stood, and no
|
||
// callback reports it. A refusal cancels — the lane returns, nothing is written.
|
||
guard TrashDrop.accepts(
|
||
kind: kind,
|
||
container: session.container,
|
||
isWithinBoard: within,
|
||
operation: operation,
|
||
isTrashShown: store.transient.isTrashVisible,
|
||
acceptsMutations: store.acceptsBoardMutations
|
||
) else {
|
||
cancelDrop()
|
||
return false
|
||
}
|
||
store.deleteLanesByDrag(laneIDs: ids)
|
||
break
|
||
}
|
||
// **A lane drag never targets a masonry** — a lane session proposes lane slots and the
|
||
// trash column, and nothing else (04-interactions.md ▸ The trash). True by construction,
|
||
// since `retargetLanes` and `retargetTrash` are the only things that propose for one;
|
||
// written down because a commit that trusted the container implicitly would be the one
|
||
// place the invariant could break silently.
|
||
guard target.container == .strip else {
|
||
cancelDrop()
|
||
return false
|
||
}
|
||
if within {
|
||
// **The trash side is the restore, and it is a different write** (04 ▸ The trash:
|
||
// "a trashed lane row [dropped] onto its own board's strip — is an ordinary move to
|
||
// the drop position"): the folder comes *out* of `.trash/`, where `moveLanes`'
|
||
// rank arithmetic — a permutation of the strip's own lanes — has nothing to say
|
||
// about it. `restoreLanes` is that move, with the same one-bracket, one-step shape.
|
||
if session.container == .trash {
|
||
store.restoreLanes(Set(ids), toIndex: target.index)
|
||
} else {
|
||
store.moveLanes(Set(ids), toIndex: target.index)
|
||
}
|
||
} else {
|
||
// Cross-board, from either container: the ordinary arrival, copy by default and
|
||
// move under ⌘ — "Dropped on *another* board it follows the copy default … ⌘-drag
|
||
// forces the true cross-board restore-move" (04 ▸ The trash).
|
||
store.receiveLanes(folders, operation: operation, at: target.index)
|
||
}
|
||
|
||
case .cards:
|
||
if target.isTrash {
|
||
// **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled
|
||
// 2026-07-28): "release moves the dragged card(s) into `.trash/`" — exactly the ⌫
|
||
// delete.
|
||
//
|
||
// The gate is re-asked here rather than trusted from the hover, because the one input
|
||
// that can change between them arrives through no callback at all: ⌥ pressed after
|
||
// the proposal stood would otherwise delete an original the copy grammar had just
|
||
// promised to leave alone. A refusal cancels — items return, nothing is written.
|
||
guard TrashDrop.accepts(
|
||
kind: kind,
|
||
container: session.container,
|
||
isWithinBoard: within,
|
||
operation: operation,
|
||
isTrashShown: store.transient.isTrashVisible,
|
||
acceptsMutations: store.acceptsBoardMutations
|
||
) else {
|
||
cancelDrop()
|
||
return false
|
||
}
|
||
store.deleteByDrag(cardIDs: ids)
|
||
break
|
||
}
|
||
guard let laneID = target.laneID else {
|
||
cancelDrop()
|
||
return false
|
||
}
|
||
// **The trash side needs no branch of its own any more** (04-interactions.md ▸ The
|
||
// trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a
|
||
// trash card into one of its own board's lanes is an ordinary move to the drop
|
||
// position"). `moveCards`/`copyCards` resolve their members in either container, so a
|
||
// restore *is* the within-board move and a cross-board restore *is* the ordinary
|
||
// arrival — which is exactly what retiring the restore-specific machinery bought.
|
||
if within {
|
||
if operation == .copy {
|
||
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
||
} else {
|
||
store.moveCards(Set(ids), toLane: laneID, at: target.index)
|
||
}
|
||
} else {
|
||
store.receiveCards(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() }
|
||
}
|
||
}
|
||
|
||
// The Finder-drag payload rules this file used to hold — `FileDropLoading` and `FinderDrop` — moved
|
||
// to `Kanban/UI/FinderDrop.swift` verbatim when the card window's whole-window attachment drop
|
||
// (05-card-window.md ▸ Attachments) became their second caller. Everything below still calls them by
|
||
// the same names; only their file changed.
|
||
|
||
// 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 the grid one card per file at the drop
|
||
/// position, and onto the **header** the topmost position, since the target is the whole lane body
|
||
/// and a dead stripe across its top would be the one place a file drop refused for no reason
|
||
/// (04-interactions.md ▸ Drag and drop, settled 2026-07-28).
|
||
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 and the outer margin.
|
||
///
|
||
/// The trash column used to fall through to here too, being no landing spot of its own; it has its
|
||
/// own target now that a live card drag can *delete* into it (`TrashDropDelegate`), and that target
|
||
/// hands every session it declines straight back to this logic, so the behaviour over the column is
|
||
/// unchanged for everything but the one gesture that is new.
|
||
///
|
||
/// 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 **trash column's** drop target — the pointer's delete gesture (04-interactions.md ▸ The
|
||
/// trash, settled 2026-07-28: "dropping a live card on the shown trash deletes it").
|
||
///
|
||
/// It exists only while the column does. Hidden, the trash renders nothing, so there is no region
|
||
/// here to enter and "the trash stays undroppable-into while hidden, like every gesture" needs no
|
||
/// code — `TrashDrop.accepts` restates it anyway, because an invariant that is only true by
|
||
/// construction is worth being able to point at.
|
||
///
|
||
/// Like every other delegate it accepts **all three** session types, because single-target dispatch
|
||
/// gives the deepest region the session whether it wants it or not (see the note above), and it
|
||
/// routes them three ways:
|
||
///
|
||
/// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the
|
||
/// ones the trash takes — a live item from this board, unmodified, either kind (lanes extended
|
||
/// 2026-07-29) — and falls through to the strip's own answer for the rest, so a *trashed* row being
|
||
/// dragged out keeps reordering against the strip across the column exactly as it did when the
|
||
/// column was a hole in the strip's target;
|
||
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
|
||
/// import) on trash cards are inert" (▸ The trash), and the column has nothing else to offer
|
||
/// them — no lane, no card, nothing to attach to.
|
||
struct TrashDropDelegate: 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,
|
||
/// whose proposals are meant to hold across ambiguous territory.
|
||
func dropExited(info: DropInfo) {
|
||
guard context.isFileSession(info) else { return }
|
||
context.session.proposeFile(nil)
|
||
}
|
||
|
||
/// A release on the column commits whatever stands — the delete when the trash is the
|
||
/// proposal, and otherwise the proposal the column declined to displace, which is the same
|
||
/// "the drop lands where the shadows show" promise as anywhere else.
|
||
func performDrop(info: DropInfo) -> Bool {
|
||
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
|
||
}
|
||
|
||
private func retarget(_ info: DropInfo) {
|
||
if context.isFileSession(info) {
|
||
context.session.proposeFile(nil)
|
||
return
|
||
}
|
||
context.retargetTrash()
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
}
|