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
+31 -31
View File
@@ -126,7 +126,7 @@ struct LaneView: View {
// the drag off until the pointer actually moves, so a click is never a drag.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current,
togglesOnRepeat: true
)
@@ -232,17 +232,17 @@ struct LaneView: View {
/// alone standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
/// Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
return .items([lane.id])
}
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
return [lane.id]
}
return store.selection.ids
@@ -352,20 +352,20 @@ struct LaneView: View {
private func startLaneDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(lane.id)
&& selection.ids.count > 1
? selection.ids
: [lane.id]
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
let members = store.snapshot.lanes.filter { ids.contains($0.id) }
guard !members.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .lanes,
side: .live,
container: .board,
items: members.map {
DragPayload.Item(
id: $0.id.rawValue,
@@ -405,7 +405,7 @@ struct LaneView: View {
private var draggedLaneCount: Int {
let selection = store.selection
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 }
return selection.ids.count
}
@@ -544,7 +544,7 @@ struct LaneView: View {
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current,
togglesOnRepeat: true
)
@@ -552,7 +552,7 @@ struct LaneView: View {
// The rubber band's first surface "click-drag rubber-bands across lanes". Simultaneous
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
// started on a card face out of it (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(side: .live))
.simultaneousGesture(marquee.gesture(in: .board))
// The same menu the header carries "one menu, invoked on the header or lane empty
// space alike" (03-board-ui.md § Lane, settled).
.contextMenu { laneMenu }
@@ -688,7 +688,7 @@ struct LaneView: View {
renaming: ItemID?
) -> [Card] {
cards.filter { card in
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
guard !hidden.contains(card.id) else { return false }
return filter.matches(card) || card.id == renaming
}
}
@@ -696,7 +696,7 @@ struct LaneView: View {
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
store.selection.container == .board && store.selection.ids.contains(lane.id)
}
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet
@@ -922,7 +922,7 @@ private struct CardFaceView: View {
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current)
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
}
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
@@ -948,7 +948,7 @@ private struct CardFaceView: View {
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
@@ -960,7 +960,7 @@ private struct CardFaceView: View {
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
@@ -969,7 +969,7 @@ private struct CardFaceView: View {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
@@ -978,21 +978,21 @@ private struct CardFaceView: View {
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String?] = [:]
for lane in snapshot.lanes where !lane.isDeleted {
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
var titles: [ItemID: String] = [:]
for lane in snapshot.lanes {
for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
side: .live,
container: .board,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
@@ -1001,7 +1001,7 @@ private struct CardFaceView: View {
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id] ?? nil
title: titles[id]
)
}
)
@@ -1012,7 +1012,7 @@ private struct CardFaceView: View {
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
side: .live,
container: .board,
source: store
)
return payload.itemProvider()
@@ -1021,7 +1021,7 @@ private struct CardFaceView: View {
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
@@ -1098,17 +1098,17 @@ private struct CardFaceView: View {
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return .items([card.id])
}
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
@@ -1205,7 +1205,7 @@ private struct CardFaceView: View {
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(card.id)
store.selection.container == .board && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
@@ -1330,14 +1330,14 @@ private struct NewCardStubView: View {
///
/// The lane is read before the commit, because every discard path clears the overlay that holds
/// it and re-checked after, because one of those paths is *the lane vanished*, and selecting
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
/// something that renders nowhere would break the one-container invariant until the
/// next reload swept it away.
@discardableResult
private func commit() -> ItemID? {
let lane = store.transient.newCardPlaceholder?.laneID
let id = store.commitPlaceholder()
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
store.select([lane], liveness: .live)
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
store.select([lane], in: .board)
}
return id
}