Build the drop-slot model and the drop commits — drag & drop, first half

The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md
travels with it, rewritten for lanes, the interior masonry, multi-drag,
cross-board sessions, the re-grounding trio, and the committed-overlay hold):

- DropSlotMath — resting-layout zones from analytic lane arithmetic and the
  pure masonry placement (MasonryLayout now lays out through the same
  MasonryPlacement the drag reads, so geometry cannot drift), span-capped
  triggers sized to the dragged run's future footprint, hysteresis holds with
  the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold.
- DragAutoScrollMath — the activation bands and velocity ramp, pure.
- The drop commits, one performWrite bracket each: moveCards/copyCards within
  a board (insertion ranks touch only the dragged cards; renumber fallback);
  receiveCards/receiveLanes/receiveRestoredCards on the destination store for
  cross-board copy and ⌘-move with the import-boundary remint, lane copies
  stripping tombstoned cards while moves carry them; restoreByDrag is now
  positional, writing order only when the drop names a new one.

Gestures, sessions, previews, and delegates are the second half.

773 unit tests (87 new since the keyboard grammar).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 20:10:24 -04:00
parent 4035ba7986
commit 21a5a6dbfd
14 changed files with 2641 additions and 55 deletions
+54
View File
@@ -776,6 +776,60 @@ public enum BoardWriter: Sendable {
}
}
/// Physically removes every tombstoned card from a **just-copied** lane, and reports which
/// the tail of a lane copy (04-interactions.md Drag and drop: "A lane copy **strips
/// tombstoned cards**: the copy transfers content, and trash isn't content"; the same rule
/// governs a pasted lane copy).
///
/// **Removed, not tombstoned.** These folders were minted seconds ago by `copyItem` and were
/// never content in this board, so there is nothing here for a Put Back to recover and no
/// tombstone to leave standing the tombstoned *originals* stay recoverable in the source
/// board, which is where the recovery story lives. A lane **move** carries them whole and never
/// calls this: the folder travels as-is and its tombstones land in the destination's trash by
/// rendering.
///
/// **Only ever pointed at a fresh copy.** `copyItem` has no filter hook it copies the tree
/// verbatim by design, which is what makes attachments and strays arrive byte-identical so
/// the strip is a second step rather than a parameter, and a caller that aimed it at a lane the
/// user actually owns would be destroying their trash. Every call site in the app is the line
/// after a `copyItem` that materialized the folder.
///
/// A child whose `index.md` is missing or unreadable is **left alone**: the liveness question
/// cannot be answered for it, and the conservative direction is to keep the folder the same
/// leniency `copyItem` extends below its root. Liveness is read exactly as the loader reads it
/// (a present `deleted` key, malformed or not).
///
/// The operation vocabulary is `.copy`, not `.purge`: the user pressed nothing called "delete",
/// and a failure here must say the app could not copy the lane (02-architecture.md §
/// Write-failure surfacing).
@discardableResult
public static func stripTombstonedChildren(of laneFolder: URL) throws(BoardWriteError) -> [ItemID] {
let operation = WriteOperation.copy(title: nil)
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
try checkIsUUIDShaped(laneFolder, operation: operation)
var removed: [ItemID] = []
for child in childCandidates(of: laneFolder) {
let indexURL = child.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path),
let document = try? readDocument(at: indexURL, operation: operation),
!document.deleted.isMissing
else { continue }
do {
try FileManager.default.removeItem(at: child)
} catch {
throw BoardWriteError(
operation: operation,
path: child.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
removed.append(ItemID(rawValue: child.lastPathComponent))
}
return removed
}
// MARK: - Tombstone
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md`