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
+32 -32
View File
@@ -15,11 +15,11 @@ import Testing
@Suite("DragPayload")
struct DragPayloadTests {
private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload {
private static func payload(kind: DragKind = .cards, container: ItemContainer = .board) -> DragPayload {
DragPayload(
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
kind: kind,
side: side,
container: container,
items: [
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
@@ -30,8 +30,8 @@ struct DragPayloadTests {
@Test("A payload round-trips through its JSON representation unchanged")
func roundTrip() throws {
for kind in [DragKind.cards, .lanes] {
for side in [Liveness.live, .trashed] {
let original = Self.payload(kind: kind, side: side)
for container in [ItemContainer.board, .trash] {
let original = Self.payload(kind: kind, container: container)
let data = try #require(original.encoded())
#expect(DragPayload(data: data) == original)
}
@@ -62,10 +62,10 @@ struct DragPayloadTests {
#expect(Self.payload().plainText == "First\nUntitled")
}
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag")
func sideSurvives() throws {
let data = try #require(Self.payload(side: .trashed).encoded())
#expect(DragPayload(data: data)?.side.liveness == .trashed)
@Test("The container survives the round trip, because it is what makes a trash drag a trash drag")
func containerSurvives() throws {
let data = try #require(Self.payload(container: .trash).encoded())
#expect(DragPayload(data: data)?.container == .trash)
}
}
@@ -95,25 +95,25 @@ struct DragLocalityTests {
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
@Test("Locality picks the default — within is a move, across is a copy")
func theDefault() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: none) == .copy)
}
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
func modifiersOverride() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: command) == .move)
// The no-ops.
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: option) == .copy)
}
@Test("⌘ wins over ⌥ when both are held")
func commandWinsOverOption() {
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
#expect(DragLocality.operation(
kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move)
kind: .cards, container: .board, isWithinBoard: false, modifiers: [.option, .command]) == .move)
}
/// The first carve-out: "Lane drags never copy *within their board*. is simply ignored there:
@@ -122,13 +122,13 @@ struct DragLocalityTests {
func laneDragsNeverCopyWithinTheirBoard() {
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
#expect(
DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move,
DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: true, modifiers: modifiers) == .move,
"a within-board lane drag is a reorder whatever is held"
)
}
// Across boards the lane obeys the ordinary grammar again.
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: option) == .copy)
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: command) == .move)
}
/// The second: a trash row's drag is copy-out grammar (04-interactions.md The trash). Within its
@@ -137,11 +137,11 @@ struct DragLocalityTests {
/// live copy either way.
@Test("A trash row drags as a restore at home and as a copy-out abroad")
func trashDragDefaults() {
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy)
kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: option) == .copy)
}
}
@@ -158,7 +158,7 @@ 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,
container: ItemContainer = .board,
isWithinBoard: Bool = true,
operation: TransferOperation = .move,
isTrashShown: Bool = true,
@@ -166,7 +166,7 @@ struct TrashDropTests {
) -> Bool {
TrashDrop.accepts(
kind: kind,
side: side,
container: container,
isWithinBoard: isWithinBoard,
operation: operation,
isTrashShown: isTrashShown,
@@ -196,12 +196,12 @@ struct TrashDropTests {
#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))
/// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it
/// never proposes.
@Test("A trash card dropped back on the trash is refused")
func theTrashContainerIsRefused() {
#expect(!accepts(container: .trash))
#expect(!accepts(container: .trash, isWithinBoard: false))
}
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
@@ -340,7 +340,7 @@ struct DropSettleTests {
.appendingPathComponent($0.id.rawValue, isDirectory: true)
},
heights: members.map { _ in 44 },
side: .live,
container: .board,
source: store
)
}