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