Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+57 -54
View File
@@ -52,21 +52,21 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4)
func tombstonedItem(order: String, title: String) -> String {
/// An ordinary card body, for the trash's resident.
func trashResidentItem(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Two lanes: `lane1` holds three live cards and one tombstoned one, `lane2` holds a single card.
/// Two lanes: `lane1` holds two cards, `lane2` holds a single card, and one card sits in the trash.
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
/// fallback lost exactly two files" both assertable.
@MainActor
@@ -78,9 +78,9 @@ func makeClipboardBoard() throws -> WriterFixture {
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstonedItem(order: "3072", title: "Trashed"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
try fixture.item(".trash/\(Ident.card3)", trashResidentItem(order: "1024", title: "Trashed"))
return fixture
}
@@ -140,7 +140,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: [entry(Ident.card1)]
)
let data = try #require(manifest.encoded())
@@ -153,7 +153,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: [entry(Ident.card1)]
)
manifest.version = ClipboardManifest.currentVersion + 1
@@ -167,7 +167,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: []
)
let data = try #require(manifest.encoded())
@@ -200,7 +200,7 @@ struct ClipboardManifestTests {
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card,
side: .live,
container: .board,
entries: [titled, untitled]
)
#expect(manifest.plainText == "First\nUntitled")
@@ -218,7 +218,7 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
@@ -238,12 +238,12 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .card)
#expect(manifest.side == .live)
#expect(manifest.container == .board)
#expect(manifest.rootURL.path == harness.fixture.root.path)
// Flatten order lane `order`, then card `order`.
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
@@ -258,7 +258,7 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
@@ -266,12 +266,12 @@ struct ClipboardCopyTests {
#expect(manifest.entries[0].index == onDisk)
}
@Test("A lane copy embeds its live cards and leaves the tombstoned one out")
func laneEntryEmbedsLiveCards() async throws {
@Test("A lane copy embeds exactly its cards — the trash is board-level, so none is nested")
func laneEntryEmbedsItsCards() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live)
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
@@ -281,17 +281,17 @@ struct ClipboardCopyTests {
#expect(manifest.entries[0].lostAttachmentCount == 2)
}
@Test("A trashed selection copies out, side recorded")
func trashedSide() async throws {
@Test("A trash selection copies out, container recorded")
func trashContainerRecorded() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.side == .trashed)
#expect(manifest.container == .trash)
#expect(manifest.entries.map(\.id) == [Ident.card3])
}
@@ -310,12 +310,12 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let first = try #require(harness.clipboard.payload?.copyID)
harness.store.select([clipboardCard2], liveness: .live)
harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let second = try #require(harness.clipboard.payload?.copyID)
@@ -365,7 +365,7 @@ struct ClipboardSweepTests {
withIntermediateDirectories: true
)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
@@ -378,7 +378,7 @@ struct ClipboardSweepTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
#expect(try harness.stagedCopyIDs().count == 1)
@@ -402,7 +402,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.payload != nil)
@@ -417,7 +417,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
let before = harness.pasteboard.changeCount
harness.clipboard.refresh()
@@ -432,7 +432,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
@@ -453,11 +453,11 @@ struct ClipboardCutTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard4], liveness: .live)
harness.store.select([clipboardCard1, clipboardCard4], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
#expect(harness.store.transient.pendingCut.liveness == .live)
#expect(harness.store.transient.pendingCut.container == .board)
}
@Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
@@ -465,21 +465,21 @@ struct ClipboardCutTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
#expect(!harness.store.transient.pendingCut.isEmpty)
harness.store.select([clipboardCard2], liveness: .live)
harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.store.transient.pendingCut.isEmpty)
}
@Test("Deletion voids per item: a tombstoned cut member leaves the pending set on reload")
@Test("Deletion voids per item: a deleted cut member leaves the pending set on reload")
func deletionVoidsPerItem() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1])
@@ -502,19 +502,22 @@ struct ClipboardAvailabilityTests {
defer { harness.tearDown() }
#expect(harness.clipboard.canCopy(from: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
#expect(harness.clipboard.canCopy(from: harness.store))
}
@Test("Copy works on a trashed selection; cut does not")
func trashIsCopyOutOnly() throws {
/// 04-interactions.md The trash, resettled 2026-07-28: "X **works** (it was disabled under
/// the tombstone model): cut in the trash, paste into a lane is the keyboard-native restore, an
/// ordinary folder move."
@Test("Both copy and cut work on a trash selection — cut is the keyboard restore")
func trashTakesCopyAndCut() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed)
harness.store.select([clipboardCard3], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
#expect(harness.clipboard.canCut(from: harness.store))
}
@Test("The read-only lock blocks cut but never copy")
@@ -522,7 +525,7 @@ struct ClipboardAvailabilityTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.store.enterVanishedRootLock()
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false)
@@ -534,7 +537,7 @@ struct ClipboardAvailabilityTests {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
@@ -549,7 +552,7 @@ struct ClipboardAvailabilityTests {
defer { harness.tearDown() }
#expect(harness.clipboard.canPaste(into: harness.store) == false)
harness.store.select([clipboardCard1], liveness: .live)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.canPaste(into: harness.store))
}
@@ -573,11 +576,11 @@ struct ClipboardAvailabilityTests {
let sourceStore = try BoardStore(rootURL: source.root)
let emptyStore = try BoardStore(rootURL: empty.root)
sourceStore.select([clipboardCard1], liveness: .live)
sourceStore.select([clipboardCard1], in: .board)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore) == false)
sourceStore.select([clipboardLane1], liveness: .live)
sourceStore.select([clipboardLane1], in: .board)
clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore))
}
@@ -600,7 +603,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
lastActiveLaneID: nil,
snapshot: model
)
@@ -614,11 +617,11 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
lastActiveLaneID: nil,
snapshot: model
)
// Two rendered cards the tombstoned third is not in the layout.
// Two rendered cards.
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
}
@@ -629,7 +632,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], container: .board),
lastActiveLaneID: nil,
snapshot: model
)
@@ -637,18 +640,18 @@ struct PasteTargetTests {
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@Test("A tombstoned selection never anchors: it behaves as nothing selected")
func tombstonedSelectionNeverAnchors() throws {
@Test("A trash selection never anchors: it behaves as nothing selected")
func trashSelectionNeverAnchors() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let model = try snapshot(fixture)
let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
lastActiveLaneID: clipboardLane2,
snapshot: model
)
// The last-active lane, appended never `card3`'s live disk-lane.
// The last-active lane, appended the trash is never the destination.
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
}
@@ -679,7 +682,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
snapshot: model
) == 1)
}
@@ -691,12 +694,12 @@ struct PasteTargetTests {
let model = try snapshot(fixture)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
snapshot: model
) == 1)
}
@Test("Nothing (or something tombstoned) selected lands a lane at the board's right end")
@Test("Nothing (or a trash selection) lands a lane at the board's right end")
func rightEnd() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
@@ -704,7 +707,7 @@ struct PasteTargetTests {
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
#expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
snapshot: model
) == 2)
}