Implement drag & drop with the locality model

The second half: system drag sessions over phase 1's model, per
DRAG-REORDER.md and 04-interactions.md § Drag & drop.

- Card faces, lane headers, and trash rows drag as NSItemProvider sessions
  (two exported UTTypes, JSON payload in flatten order, plain-text titles as
  the secondary representation) — replacing m4's custom lane-reorder gesture
  and trash drag-out wholesale; the app-wide DragSession carries the members,
  the frozen dragged sizes, the live proposal, and the effective operation.
- Three drop delegates (lane masonry, strip, window fallback), each accepting
  both types and routing internally per the single-target-dispatch rule; the
  cursor is the physical mouse converted to strip space; proposals come from
  DropSlotMath with hysteresis threaded through, and the lane-strip proposal
  clamps in front of the shown trash.
- Locality picks the default — move within a board, copy across, the badge
  tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘
  forces move; trash rows restore within their board (positional), copy out
  across boards by default, ⌘ forcing the true restore-move.
- N contiguous shadows with reflow keyed on the proposal; the
  committed-overlay hold renders the dropped arrangement until the reload
  echo lands (1.5 s dissolution deadline for refused writes); the
  re-grounding trio: geometry re-derives per render, proposals re-validate
  by liveness at release, an emptied drag cancels itself.
- Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per
  step), the mouse-up-gated late-event cleanup, and the polling watchdog —
  the pathfinder's lifecycle traps, ported.
- Store: moveLanes and multi-card restoreByDrag join the one-bracket drop
  commits.

784 unit tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 20:58:26 -04:00
parent f2d9f3ad07
commit 90cf82d740
24 changed files with 2307 additions and 739 deletions
+120 -34
View File
@@ -133,6 +133,15 @@ public final class BoardStore {
/// path see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel
/// How many snapshots this store has applied, ever a counter, not a version.
///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
/// is the event, not change. A reload that produced an identical model still ends the round trip
/// the overlay was covering comparing `snapshot` values would leave the overlay standing
/// exactly when the write turned out to be a no-op.
public private(set) var snapshotGeneration: Int = 0
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
/// describe the tree currently on screen.
@@ -449,6 +458,7 @@ public final class BoardStore {
reduced: Motion.prefersReducedMotion
)) {
snapshot = result.model
snapshotGeneration += 1
loadWarnings = result.warnings
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
@@ -1133,7 +1143,7 @@ public final class BoardStore {
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
/// counted **with the dragged lane itself removed** which is the index
/// `LaneReorderMath.proposedIndex` produces.
/// `DropSlotMath.laneSlot` produces.
///
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
@@ -1177,6 +1187,57 @@ public final class BoardStore {
}
}
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
/// position `index` among the board's live lanes, counted with the dragged run removed the
/// index `DropSlotMath.laneSlot` produces.
///
/// `moveLane`'s plural, and it exists rather than a loop over it because "one `performWrite`
/// bracket per gesture whatever the set's size" is load-bearing (DRAG-REORDER.md § The drop
/// commits): one app-mediated reload, and on git boards one commit rather than N.
///
/// The run keeps **board order**, which is the lane level's flatten order a multi-lane drag has
/// no other relative order to preserve.
///
/// A drag that changes nothing writes nothing, stated as the arrangement rather than as a special
/// case: if the strip would render exactly what it renders now, no rank is rewritten and no
/// commit is minted.
public func moveLanes(_ ids: Set<ItemID>, toIndex index: Int) {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
let members = lanes.filter { ids.contains($0.id) }
guard !members.isEmpty else { return }
let remaining = lanes.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
guard DropSlotMath.applied(lanes.map(\.id), moving: members.map(\.id), to: target) != lanes.map(\.id)
else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The dragged lanes *are* among the renumbered children
// they are real folders on disk so their fresh rungs are dropped from the ladder
// before the neighbours are consulted, exactly as `moveLane` drops its one.
try BoardWriter.renumberVisibleChildren(of: root)
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
.filter { !ids.contains($0.0.id) }
.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
_ = try BoardWriter.moveItem(
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}
// MARK: - Drag & drop commits
// The writes a released drag performs (04-interactions.md Drag and drop; the geometry that
@@ -1758,52 +1819,77 @@ public final class BoardStore {
/// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag
/// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing.
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
let source = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted }
}),
let card = source.cards.first(where: { $0.id == cardID })
else { return }
restoreByDrag(cardIDs: [cardID], intoLane: laneID, at: index)
}
/// The multi-drag face of the same gesture: N trash rows dropped over one live lane land
/// contiguously at `index`, **in drop order** the order the payload carries, which is the
/// trash's own sorted order (03-board-ui.md § Trash Contents).
///
/// It is the plural rather than a loop over the singular for `moveLanes`' reason: one
/// `performWrite` bracket per gesture whatever the set's size, so one reload and one commit
/// (DRAG-REORDER.md § The drop commits). Every rule above holds per member the same-lane
/// single write, the cross-lane restore-then-move pair, and the recorded-`order` preservation,
/// which is what makes a row dropped back where it already belonged come back exactly there.
public func restoreByDrag(cardIDs: [ItemID], intoLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
// A row exists only for a card whose *own* flag is set under a *live* lane the trash's
// absolute ancestor walk (03-board-ui.md § Trash Contents). Anything else in the list names
// nothing draggable and is silently skipped, which is this method's standing posture.
let rows: [(laneID: ItemID, card: Card)] = cardIDs.compactMap { id in
guard let source = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == id && $0.isDeleted }
}),
let card = source.cards.first(where: { $0.id == id })
else { return nil }
return (source.id, card)
}
guard !rows.isEmpty else { return }
let root = rootURL
let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root)
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
let crossesLanes = source.id != laneID
let rendered = destination.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
let recordedOrder = card.order
try? performWrite { () throws(BoardWriteError) -> Void in
var rank = Ranks.insertionRank(amongVisible: rendered.map(\.order), at: target)
if rank == nil {
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: rendered.count), at: target)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: rows.count
)
}
guard let rank else { return }
guard let ranks else { return }
guard crossesLanes else {
try BoardWriter.updateIndex(
inItemFolder: cardFolder,
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
operation: .restore(title: nil)
) { document in
document.remove(FrontmatterKeys.deleted)
if rank != recordedOrder {
document.set(FrontmatterKeys.order, to: .double(rank))
for (row, rank) in zip(rows, ranks) {
let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root)
guard row.laneID != laneID else {
try BoardWriter.updateIndex(
inItemFolder: cardFolder,
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
operation: .restore(title: nil)
) { document in
document.remove(FrontmatterKeys.deleted)
if rank != row.card.order {
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
continue
}
return
}
try BoardWriter.restoreItem(at: cardFolder)
_ = try BoardWriter.moveItem(
at: cardFolder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
try BoardWriter.restoreItem(at: cardFolder)
_ = try BoardWriter.moveItem(
at: cardFolder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
}