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