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