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