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
+88
View File
@@ -1571,6 +1571,94 @@ struct BoardWriterCopyTests {
}
}
// MARK: - Stripping a copied lane's tombstones
/// `BoardWriter.stripTombstonedChildren` the tail of a lane copy (04-interactions.md Drag and
/// drop: "A lane copy **strips tombstoned cards**"). `copyItem` copies the tree verbatim by
/// design, so the strip is the line after it rather than a filter inside it.
struct BoardWriterStripTombstonesTests {
/// A tombstoned card, as an agent or a delete leaves it.
private static func tombstone(order: String, title: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\ndeleted: 2026-03-03T09:00:00Z\n---\n\(title) body.\n"
}
@Test func onlyTheTombstonedChildrenAreRemoved() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)",
Self.tombstone(order: "2048", title: "Trashed"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Also live"))
let live = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed == [ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card2)"))
// Removed, never tombstoned, and the survivors are not rewritten on the way past.
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card3)"))
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") == live)
#expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty)
}
@Test func aWholeTombstonedFolderGoesWithItsAttachments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)",
Self.tombstone(order: "1024", title: "Trashed"))
try fixture.file("A.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x89, 0x50]))
_ = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
}
@Test func nonUUIDStraysAndUnreadableChildrenAreLeftAlone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
// A stray is not a level at all; a UUID-shaped folder with no `index.md` cannot be asked
// the liveness question, and the conservative direction is to keep it.
try fixture.file("A.kanban/\(Ident.lane1)/notes/scratch.txt", Data("hand-written\n".utf8))
try FileManager.default.createDirectory(at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"),
withIntermediateDirectories: true)
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed.isEmpty)
#expect(fixture.exists("A.kanban/\(Ident.lane1)/notes/scratch.txt"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.indexless)"))
}
@Test func aLaneWithNothingTombstonedIsUntouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
#expect(try BoardWriter.stripTombstonedChildren(of: lane).isEmpty)
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").sorted() == [Ident.card1, "index.md"].sorted())
}
@Test func aMissingFolderIsALoudErrorNamingTheCopy() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let error = writeFailure { _ = try BoardWriter.stripTombstonedChildren(of: fixture.url("A.kanban/\(Ident.lane1)")) }
// The user pressed nothing called "delete": a failure here must say the copy failed.
#expect(error?.operation == .copy(title: nil))
}
}
// MARK: - Delete / Restore
/// `BoardWriter.deleteItem`/`restoreItem` the tombstone half of 01-storage-format.md §