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
+166 -2
View File
@@ -145,6 +145,93 @@ struct DragLocalityTests {
}
}
// MARK: - Dropping on the trash
/// **The delete gesture's gate** (04-interactions.md The trash, settled 2026-07-28: "dropping a
/// live card on the shown trash deletes it").
///
/// One pure function decides it, and it is asked twice once at hover for the shadow and once at
/// release for the write so every clause below is a claim about both.
@Suite("TrashDrop")
struct TrashDropTests {
/// The accepted session, with one clause at a time knocked out by the cases.
private func accepts(
kind: DragKind? = .cards,
side: Liveness = .live,
isWithinBoard: Bool = true,
operation: TransferOperation = .move,
isTrashShown: Bool = true,
acceptsMutations: Bool = true
) -> Bool {
TrashDrop.accepts(
kind: kind,
side: side,
isWithinBoard: isWithinBoard,
operation: operation,
isTrashShown: isTrashShown,
acceptsMutations: acceptsMutations
)
}
/// "The shadow always takes the topmost position which the sort makes honest, not arbitrary:
/// the trash orders by `deleted` newest-first, so a fresh tombstone genuinely lands on top."
@Test("The landing is the topmost row, always")
func theTopmostRow() {
#expect(TrashDrop.landingIndex == 0)
}
@Test("A live, same-board, unmodified card drag is the one session the trash takes")
func theOneItTakes() {
#expect(accepts())
// forces move, which is already the default here, so it changes nothing.
#expect(accepts(operation: .move))
}
/// "Lanes are not deliverable this way (a lane drag proposes only lane slots)."
@Test("A lane drag never proposes into the trash")
func lanesAreNotDeliverable() {
#expect(!accepts(kind: .lanes))
// And no session at all is no proposal either the column is inert between drags.
#expect(!accepts(kind: nil))
}
/// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes
/// nothing, so it never proposes.
@Test("A trash row dropped back on the trash is refused")
func theTrashedSideIsRefused() {
#expect(!accepts(side: .trashed))
#expect(!accepts(side: .trashed, isWithinBoard: false))
}
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
/// would be a transfer-and-delete compound, which the design names nowhere.
@Test("A foreign board's card is refused")
func crossBoardIsRefused() {
#expect(!accepts(isWithinBoard: false))
// Not even with , which forces the move a cross-board drag would otherwise only copy.
#expect(!accepts(isWithinBoard: false, operation: .move))
}
/// Copying into the trash is not a thing and the alternative would be tombstoning an original
/// the copy grammar had just promised to leave exactly where it was.
@Test("⌥ is refused rather than reinterpreted")
func optionCopyIsRefused() {
#expect(!accepts(operation: .copy))
}
/// "The trash stays undroppable-into while hidden, like every gesture."
@Test("Hidden, the trash is invisible to the gesture")
func hiddenIsInert() {
#expect(!accepts(isTrashShown: false))
}
@Test("The mutating-gesture rule applies, like every other write the pointer can start")
func theLockAndTheEditorRefuse() {
#expect(!accepts(acceptsMutations: false))
}
}
// MARK: - The committed-overlay hold
@Suite("CommittedHold")
@@ -244,7 +331,7 @@ struct DropSettleTests {
) -> DragSession {
let session = DragSession()
pickUp(session, from: store, members: members)
session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: index))
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: index))
return session
}
@@ -362,11 +449,88 @@ struct DropSettleTests {
session.commit(into: store, survivors: [0], operation: .move)
session.propose(nil)
session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: 0))
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0))
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2)
}
// MARK: The trash's landing
/// A session proposing into the trash column rather than into a lane the delete gesture
/// (04-interactions.md The trash).
private func proposingIntoTheTrash(_ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")]) -> DragSession {
let session = DragSession()
pickUp(session, from: store, members: members)
session.propose(DropTarget(
boardRoot: store.rootURL,
container: .trash,
index: TrashDrop.landingIndex
))
return session
}
@Test("The trash's shadow opens at the topmost row, and no lane draws one")
func trashInFlightDrawsTheTopRow() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposingIntoTheTrash(store)
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
#expect(landing.index == 0)
#expect(landing.run == .shadows)
// The proposal names one container and one only: the lane the cards came out of draws
// nothing, and neither does the strip.
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
#expect(session.stripProposal(onBoardRooted: store.rootURL) == nil)
// And they are still lifted out of the lane while the drag is in flight, as ever.
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
}
/// The settle, at the one landing whose cards would otherwise wink out of existence: the write
/// takes them off the live side, so the trash has to draw them from the instant of release.
@Test("A settled trash drop draws the tombstoned rows on top and keeps the originals lifted")
func trashSettleDrawsTheRows() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")])
// A delete is committed as the move it is the write really did take the originals away.
session.commit(into: store, survivors: [0, 1], operation: .move)
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
#expect(landing.index == 0, "the slot does not move at the settle — only what it contains")
let drop = try #require(landing.dropped)
#expect(drop.items == [
DroppedItem(id: Self.card1, title: "First"),
DroppedItem(id: Self.card2, title: "Second")
])
// A tombstone remints nothing, so the settled rows may wear the cards' own identities and the
// echo reload swaps content inside one element rather than removing and inserting.
#expect(drop.keepsIdentity)
#expect(drop.isLocal)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
}
@Test("A lane session never draws a trash landing")
func laneSessionsHaveNoTrashLanding() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
session.beginLanes(
[Self.lane1],
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
titles: ["Todo"],
units: [1],
source: store
)
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
#expect(session.trashLanding(onBoardRooted: store.rootURL) == nil)
}
// MARK: The hand-off
@Test("The hand-off clears the hold and the overlay with it — the snapshot is the authority again")
+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