Drop a card on the shown trash to delete it

04's ruling makes the drag the pointer's delete gesture: the shown
trash column accepts live same-board card drags, the shadow pinned
topmost — honest, since the trash sorts by deleted newest-first — and
release tombstones through the same write path as Backspace, extracted
so the two gestures cannot drift. DropTarget grew a container case for
the quasi-lane (it has no lane id by construction); lane drags,
cross-board arrivals, option-copies (re-checked at release, the one
input that can flip without a callback), trashed-side payloads, hidden
trash, and the read-only lock all refuse — and a refusal falls through
to the strip retarget, never cancelling the drag. The settle draws the
tombstoned rows in the trash under the cards' own GUIDs, so the echo is
an invisible content swap and nothing winks out for a round trip.
Selection needs no surgery: the reload's resolve rule ejects tombstoned
members as the vanish it is, pinned by a test contrasting both gestures.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:12:09 -04:00
parent 1020d9fca4
commit 33bf425f25
6 changed files with 819 additions and 72 deletions
+112
View File
@@ -188,6 +188,118 @@ struct TrashDeleteTests {
}
}
// MARK: - Delete by drop
/// **Drop-on-trash deletes** (04-interactions.md The trash, settled 2026-07-28): "release
/// tombstones the dragged card(s), exactly the tombstone".
///
/// *Exactly* is the claim under test, and it is a claim about the disk so these run the two
/// gestures over two identical fixtures and compare the bytes. Where the drop *may* land and what it
/// draws on the way are `TrashDropTests`' and `DropSettleTests`'; here it has already landed.
@MainActor
@Suite("BoardStore ▸ delete by drop")
struct TrashDropWriteTests {
@Test("A drop-delete is byte-for-byte the ⌫ tombstone")
func indistinguishableFromTheKeystroke() throws {
let byKey = try makeBoard()
defer { byKey.tearDown() }
let byDrop = try makeBoard()
defer { byDrop.tearDown() }
try BoardStore(rootURL: byKey.root).delete([card1])
try BoardStore(rootURL: byDrop.root).deleteByDrag(cardIDs: [card1])
let keyed = try byKey.indexText("\(Ident.lane1)/\(Ident.card1)")
let dropped = try byDrop.indexText("\(Ident.lane1)/\(Ident.card1)")
// Everything but the two stamps that are clocks rather than content, which differ between any
// two writes at all including two presses.
#expect(untouchedLines(dropped) == untouchedLines(keyed))
#expect(try FrontmatterDocument.parse(dropped).deleted.value != nil)
#expect(!dropped.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
}
/// A multi-selection drag carries its whole run across lanes, and the tombstone is the card's own
/// `index.md` and nothing else the parents are not rewritten to record a child's departure,
/// because nothing departed.
@Test("A cross-lane run lands whole and touches nothing it did not carry")
func theWholeRunLands() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lane = try stat(fixture, Ident.lane1)
store.deleteByDrag(cardIDs: [card1, card3])
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
.deleted.value != nil)
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane2)/\(Ident.card3)"))
.deleted.value != nil)
let laneAfter = try stat(fixture, Ident.lane1)
#expect(laneAfter.data == lane.data)
#expect(laneAfter.modified == lane.modified)
#expect(store.banners.oneShots.isEmpty)
}
/// The one thing the drop deliberately does *not* share with . The keystroke picks a successor
/// because the selection lost its cards and "repeated walks down a lane"; a drag's run is not
/// necessarily the selection at all, so re-pointing one that lost nothing would be a bug. The
/// reload's resolve rule ejects tombstoned members from a live-side set on its own.
@Test("A drop-delete never touches the selection, where ⌫ moves it to the successor")
func theSelectionIsLeftAlone() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Something else entirely is selected dragging a card outside the selection drags it alone
// and leaves the selection standing (`LaneView.startCardDrag`).
store.select([card3], liveness: .live, anchor: card3, head: card3)
store.deleteByDrag(cardIDs: [card1])
#expect(store.selection.ids == [card3])
#expect(store.selection.liveness == .live)
// The keystroke's contrasting half, over an identical board: re-points the selection
// whatever was in it, because it is the gesture that promises to walk down a lane.
let keyed = try makeBoard()
defer { keyed.tearDown() }
let keyedStore = try BoardStore(rootURL: keyed.root)
keyedStore.select([card3], liveness: .live, anchor: card3, head: card3)
keyedStore.delete([card1])
#expect(keyedStore.selection.ids != [card3])
}
@Test("Already-tombstoned ids are skipped, and an empty run writes nothing")
func liveOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)")
store.deleteByDrag(cardIDs: [card2])
store.deleteByDrag(cardIDs: [])
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified)
#expect(store.banners.oneShots.isEmpty)
}
@Test("A read-only board refuses the drop without a second banner")
func readOnlyRefusesQuietly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
store.deleteByDrag(cardIDs: [card1])
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
}
}
// MARK: - Put Back
@MainActor