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
+1 -1
View File
@@ -347,7 +347,7 @@ struct RevealInFinderCommand: View {
if let store {
let ids = store.selection.ids
guard !ids.isEmpty else { return [store.rootURL] }
return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot)
return ItemPath.resolve(ids, in: store.selection.container, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
if let attachments {
+2 -4
View File
@@ -645,11 +645,9 @@ public final class AppModel {
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
var lanes = 0
var cards = 0
for lane in snapshot.lanes where !lane.isDeleted {
for lane in snapshot.lanes {
lanes += 1
for card in lane.cards where !card.isDeleted {
cards += 1
}
cards += lane.cards.count
}
return (lanes, cards)
}
+2 -4
View File
@@ -175,9 +175,7 @@ struct CardWindowHost: View {
let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
return lane.isDeleted || card.isDeleted
? .dismisses
: .shows(CardPlacement(card: card, lane: lane))
return .shows(CardPlacement(card: card, lane: lane))
}
return .dismisses
}
@@ -319,7 +317,7 @@ struct CardWindowHost: View {
/// links resolve against (05-card-window.md Preview).
///
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for
/// `BoardStore.liveItem`'s reason: a mid-session folder rename moves the board, and a preview
/// `BoardStore.boardItem`'s reason: a mid-session folder rename moves the board, and a preview
/// resolving images against where the board used to be would quietly stop showing them.
static func cardFolder(root: URL, placement: CardPlacement) -> URL {
root
+4 -4
View File
@@ -27,7 +27,7 @@ extension UTType {
/// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments
/// absent", announced by a banner rather than discovered later.
///
/// `kind` and `side` are the selection's own vocabulary (`SelectionKind`, `Liveness`) rather than
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
/// spellings are pasteboard API a manifest written before a quit is decoded after the relaunch.
@@ -53,7 +53,7 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
public var boardRoot: String
public var kind: SelectionKind
public var side: Liveness
public var container: ItemContainer
public var entries: [Entry]
/// One copied item: where its snapshot is staged, what it is called, and its bytes.
@@ -132,14 +132,14 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
copyID: String,
boardRoot: URL,
kind: SelectionKind,
side: Liveness,
container: ItemContainer,
entries: [Entry]
) {
self.version = version
self.copyID = copyID
self.boardRoot = boardRoot.path
self.kind = kind
self.side = side
self.container = container
self.entries = entries
}
+77 -66
View File
@@ -192,7 +192,7 @@ public final class ClipboardStore {
copyID: copyID,
boardRoot: store.rootURL,
kind: capture.kind,
side: capture.side,
container: capture.container,
entries: capture.subjects.map(\.entry)
)
guard let data = manifest.encoded() else { return }
@@ -209,7 +209,7 @@ public final class ClipboardStore {
armedCut = ArmedCut(copyID: copyID, source: store)
store.transient.pendingCut = ItemReferenceSet(
ids: Set(capture.subjects.map(\.id)),
liveness: capture.side
container: capture.container
)
}
// "A sweep at launch and on each copy purges entries the pasteboard no longer references."
@@ -219,8 +219,9 @@ public final class ClipboardStore {
// MARK: - Availability
/// Whether Edit Copy applies a non-empty selection that still names something the board
/// renders, on either side of the live/tombstoned boundary.
/// Whether Edit Copy applies a non-empty selection that still names something, in either
/// container ("C copies a trash card a live copy lands wherever pasted, like copying out of
/// Finder's Trash" 04-interactions.md The trash).
///
/// **The read-only lock deliberately does not close it**: "reading, selecting, searching and
/// copying out all stay live" (02-architecture.md § The lock's scope) a copy is a read. The
@@ -233,12 +234,15 @@ public final class ClipboardStore {
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
}
/// Whether Edit Cut applies. Copy's conditions, plus the two a *move* adds: the board must
/// accept writes (a cut mutates its source), and the selection must be **live** "X is
/// disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else" (04 The
/// trash).
/// Whether Edit Cut applies. Copy's conditions plus the one a *move* adds: the board must
/// accept writes, since a cut mutates its source.
///
/// **The trash no longer disqualifies it** (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". So there is no
/// container clause here at all, which is the pivot showing up as a deleted line.
public func canCut(from store: BoardStore) -> Bool {
canCopy(from: store) && !store.isReadOnly && store.selection.liveness == .live
canCopy(from: store) && !store.isReadOnly
}
/// Whether Edit Paste applies to `store`.
@@ -347,7 +351,6 @@ public final class ClipboardStore {
operation: .move,
toLane: target.laneID,
at: target.index,
clearingTombstones: false,
normalizingLooseFiles: true
)
case let .lanes(index):
@@ -355,7 +358,6 @@ public final class ClipboardStore {
sources,
operation: .move,
at: index,
clearingTombstones: false,
normalizingLooseFiles: true
)
}
@@ -389,9 +391,9 @@ public final class ClipboardStore {
}
}
// "C strips `deleted:` at materialization" (04 The trash) the trash's copy-out-only rule,
// and the one axis a paste varies that a within-board drop never does.
let clearingTombstones = manifest.side == .trashed
// A card copied out of the trash needs nothing done to it on arrival: it carries no
// `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled
// 2026-07-28). The tombstone era's strip-at-materialization axis is gone with it.
switch plan {
case let .cards(target):
store.receiveCards(
@@ -399,7 +401,6 @@ public final class ClipboardStore {
operation: .copy,
toLane: target.laneID,
at: target.index,
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true
)
case let .lanes(index):
@@ -407,7 +408,6 @@ public final class ClipboardStore {
sources,
operation: .copy,
at: index,
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true
)
}
@@ -427,10 +427,11 @@ public final class ClipboardStore {
let survivors = source.transient.pendingCut
guard !survivors.isEmpty else { return nil }
// `TrashModel.paths` walks lanes in board order and each lane's cards in card order, which is
// the flatten order the drop commits insert in and the pending cut is homogeneous by kind,
// so only one of its two branches ever contributes.
let folders = TrashModel.paths(of: survivors.ids, on: .live, in: source.snapshot)
// `ItemPath.resolve` walks the container in display order, which is the flatten order the
// drop commits insert in and the pending cut is homogeneous by container, so it is asked
// for exactly the side the cut was made on. A cut made in the trash therefore hands the
// paste the trash folders it must move out, which is the keyboard restore (04 The trash).
let folders = ItemPath.resolve(survivors.ids, in: survivors.container, snapshot: source.snapshot)
.map { $0.folder(under: source.rootURL) }
guard !folders.isEmpty else { return nil }
return (source, folders)
@@ -541,17 +542,17 @@ public final class ClipboardStore {
/// produces.
struct Subject {
let id: ItemID
let path: TrashModel.ItemPath
let path: ItemPath
let entry: ClipboardManifest.Entry
}
/// The selection, resolved into copy subjects in the order the clipboard records them or `nil`
/// when it names nothing the board renders on its own side.
/// when it names nothing its container holds.
///
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for all four
/// (side, kind) pairs: flatten order for live cards, left-to-right for live lanes, and the trash's
/// own deterministic sort for either kind of entry. Deriving it here would be a fifth definition
/// of an order the app already states once.
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
/// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the
/// trash's own `order` for trash cards. Deriving it here would be a second definition of an order
/// the app already states once.
///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
/// span, so `serialized()` on an untouched document returns the file's bytes exactly which
@@ -560,58 +561,68 @@ public final class ClipboardStore {
static func capture(
selection: ItemReferenceSet,
snapshot: BoardModel
) -> (kind: SelectionKind, side: Liveness, subjects: [Subject])? {
) -> (kind: SelectionKind, container: ItemContainer, subjects: [Subject])? {
guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil }
let side = selection.liveness
let ordered = SelectionGrammar.order(of: kind, on: side, in: snapshot)
let container = selection.container
let ordered = SelectionGrammar.order(of: kind, in: container, snapshot: snapshot)
.filter { selection.ids.contains($0) }
guard !ordered.isEmpty else { return nil }
var subjects: [ItemID: Subject] = [:]
for lane in snapshot.lanes {
if kind == .lane, Liveness(isDeleted: lane.isDeleted) == side {
subjects[lane.id] = Subject(
id: lane.id,
path: TrashModel.ItemPath(laneID: lane.id, cardID: nil),
entry: ClipboardManifest.Entry(
id: lane.id.rawValue,
folder: lane.id.rawValue,
title: lane.title.value,
index: lane.document.serialized(),
attachmentCount: 0,
// Live cards only a lane copy strips tombstoned cards, and the fallback
// only ever materializes a copy (see `ClipboardManifest.Entry.cards`).
cards: lane.cards.filter { !$0.isDeleted }.map { card in
ClipboardManifest.Entry.Card(
id: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
}
)
func addCard(_ card: Card, at path: ItemPath) {
subjects[card.id] = Subject(
id: card.id,
path: path,
entry: ClipboardManifest.Entry(
id: card.id.rawValue,
folder: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
)
}
switch container {
case .trash:
for card in snapshot.trash {
addCard(card, at: .trashCard(card.id))
}
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live and
// have no trash row of their own, so they are nobody's copy subject.
guard kind == .card, !lane.isDeleted else { continue }
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == side {
subjects[card.id] = Subject(
id: card.id,
path: TrashModel.ItemPath(laneID: lane.id, cardID: card.id),
entry: ClipboardManifest.Entry(
id: card.id.rawValue,
folder: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
case .board:
for lane in snapshot.lanes {
if kind == .lane {
subjects[lane.id] = Subject(
id: lane.id,
path: .lane(lane.id),
entry: ClipboardManifest.Entry(
id: lane.id.rawValue,
folder: lane.id.rawValue,
title: lane.title.value,
index: lane.document.serialized(),
attachmentCount: 0,
// Every card the lane has "a lane carries exactly its cards", and the
// trash is board-level, so there is nothing nested to strip
// (04-interactions.md Drag and drop, resettled 2026-07-28).
cards: lane.cards.map { card in
ClipboardManifest.Entry.Card(
id: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
}
)
)
)
continue
}
for card in lane.cards {
addCard(card, at: .card(lane: lane.id, id: card.id))
}
}
}
let resolved = ordered.compactMap { subjects[$0] }
guard !resolved.isEmpty else { return nil }
return (kind, side, resolved)
return (kind, container, resolved)
}
}