Lanes delete into the trash — storage, loader, writer, and undo
Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.
TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.
Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -17,9 +17,11 @@ import Foundation
|
||||
/// single container rule replacing the old liveness law, because Delete would otherwise mean two
|
||||
/// different things in one gesture (move-to-trash vs permanent)".
|
||||
///
|
||||
/// **Lanes live only on the board side.** "Cards only. Lanes are never trashed" (03-board-ui.md §
|
||||
/// Trash), so the trash's universe is cards and nothing else — which is why the trash needs no
|
||||
/// kind axis of its own any more.
|
||||
/// **Both kinds live on both sides** (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes trash
|
||||
/// too"): the trash's universe is its cards *and* its trashed lanes, flat and interleaved by rank.
|
||||
/// What the tombstone model needed and this does not is the old two-sided machinery — the ancestor
|
||||
/// walk, effective liveness, the entry-vs-universe split — not the kind axis, which is the board's
|
||||
/// own cards-XOR-lanes rule reaching a second container.
|
||||
///
|
||||
/// `String`-backed and `Codable` because the clipboard manifest carries one: a manifest written
|
||||
/// before a quit is decoded after the relaunch, so these raw spellings are pasteboard API, and they
|
||||
@@ -39,11 +41,16 @@ extension ItemContainer {
|
||||
/// held to (02-architecture.md § Live-reload resilience: "re-resolution matches UUID *and*
|
||||
/// container side ... presence in the snapshot is the whole question").
|
||||
///
|
||||
/// One walk, no filtering: the board side is the lanes plus their cards, the trash side is
|
||||
/// `snapshot.trash`. There is deliberately no liveness predicate anywhere in here — a legacy
|
||||
/// `deleted:` key still riding in from an unmigrated board (`BoardLoader`'s migration window)
|
||||
/// names an ordinary board card until its folder actually moves, which is the safe direction and
|
||||
/// the one the migration then takes (01-storage-format.md § Deletion).
|
||||
/// One walk, no filtering: the board side is the lanes plus their cards, the trash side is its
|
||||
/// cards **and its trashed lanes** — every id the container holds, which is what a universe is.
|
||||
/// A trashed lane's own cards are deliberately not in it: they are not in the snapshot at all
|
||||
/// (the entry is opaque — 03-board-ui.md § Trash), so a selected or cut card whose lane was
|
||||
/// trashed leaves every referencing set by the ordinary vanish rule, with no clause of its own.
|
||||
///
|
||||
/// There is deliberately no liveness predicate anywhere in here — a legacy `deleted:` key still
|
||||
/// riding in from an unmigrated board (`BoardLoader`'s migration window) names an ordinary board
|
||||
/// card until its folder actually moves, which is the safe direction and the one the migration
|
||||
/// then takes (01-storage-format.md § Deletion).
|
||||
public func ids(in snapshot: BoardModel) -> Set<ItemID> {
|
||||
var universe: Set<ItemID> = []
|
||||
switch self {
|
||||
@@ -58,6 +65,9 @@ extension ItemContainer {
|
||||
for card in snapshot.trash {
|
||||
universe.insert(card.id)
|
||||
}
|
||||
for lane in snapshot.trashedLanes {
|
||||
universe.insert(lane.id)
|
||||
}
|
||||
}
|
||||
return universe
|
||||
}
|
||||
@@ -71,9 +81,15 @@ extension ItemContainer {
|
||||
/// builds the URL off the store's *current* `rootURL`, so a board renamed or moved mid-session
|
||||
/// writes at the new location (02-architecture.md § Write-failure surfacing).
|
||||
///
|
||||
/// **Three cases, because the board has exactly three places an identity-bearing folder can be** —
|
||||
/// `<root>/<lane>`, `<root>/<lane>/<card>`, and `<root>/.trash/<card>`. The old two-optional-fields
|
||||
/// shape could spell a fourth thing that does not exist; this cannot.
|
||||
/// **Four cases, because the board has exactly four kinds of place an identity-bearing folder can
|
||||
/// be** — `<root>/<lane>`, `<root>/<lane>/<card>`, and, since lanes rejoined the trash (2026-07-29),
|
||||
/// `<root>/.trash/<card>` and `<root>/.trash/<lane>`. The old two-optional-fields shape could spell
|
||||
/// things that do not exist; this cannot.
|
||||
///
|
||||
/// **The trash's two cases share a path *shape* and differ in kind**, which is exactly the fact
|
||||
/// `kind:` exists to record (01-storage-format.md § Deletion): `.trash/` is flat, so the path alone
|
||||
/// cannot say what an entry is, and a caller that has resolved one through the snapshot knows —
|
||||
/// which is why the kind is in the case rather than re-derived from disk at every use.
|
||||
public enum ItemPath: Sendable, Equatable {
|
||||
|
||||
/// A lane: `<root>/<lane>/`.
|
||||
@@ -85,25 +101,36 @@ public enum ItemPath: Sendable, Equatable {
|
||||
/// A card in the board's trash: `<root>/.trash/<card>/`.
|
||||
case trashCard(ItemID)
|
||||
|
||||
/// A lane in the board's trash: `<root>/.trash/<lane>/` — an opaque unit, subtree intact
|
||||
/// (03-board-ui.md § Trash).
|
||||
case trashLane(ItemID)
|
||||
|
||||
/// The item this path names.
|
||||
public var id: ItemID {
|
||||
switch self {
|
||||
case let .lane(id): id
|
||||
case let .card(_, id): id
|
||||
case let .trashCard(id): id
|
||||
case let .trashLane(id): id
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this path names a lane, **in either container** — the kind axis, which the board's
|
||||
/// cards-XOR-lanes rule asks about on both sides of the container boundary.
|
||||
public var isLane: Bool {
|
||||
if case .lane = self { return true }
|
||||
return false
|
||||
switch self {
|
||||
case .lane, .trashLane: true
|
||||
case .card, .trashCard: false
|
||||
}
|
||||
}
|
||||
|
||||
/// Which container this path is in — the board for a lane or a lane's card, the trash for a
|
||||
/// trash card. Derived rather than stored: the case *is* the answer.
|
||||
/// Which container this path is in — the board for a lane or a lane's card, the trash for
|
||||
/// either kind of trash entry. Derived rather than stored: the case *is* the answer.
|
||||
public var container: ItemContainer {
|
||||
if case .trashCard = self { return .trash }
|
||||
return .board
|
||||
switch self {
|
||||
case .trashCard, .trashLane: .trash
|
||||
case .lane, .card: .board
|
||||
}
|
||||
}
|
||||
|
||||
/// This path resolved under a board root.
|
||||
@@ -115,7 +142,7 @@ public enum ItemPath: Sendable, Equatable {
|
||||
root
|
||||
.appendingPathComponent(lane.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
case let .trashCard(id):
|
||||
case let .trashCard(id), let .trashLane(id):
|
||||
BoardWriter.trashFolder(inBoard: root)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
}
|
||||
@@ -131,6 +158,11 @@ extension ItemPath {
|
||||
/// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice
|
||||
/// for the same reason).
|
||||
///
|
||||
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows
|
||||
/// and cards interleave in the one trash column purely by trash rank"), which is why the two
|
||||
/// arrays are merged here rather than concatenated: the column's order is the batch's order, and
|
||||
/// a selection is kind-homogeneous anyway, so the merge costs nothing the one time it matters.
|
||||
///
|
||||
/// Ids the container does not hold are simply absent, which is every caller's standing posture:
|
||||
/// a selection the next reload will drop writes nothing rather than being refused.
|
||||
public static func resolve(
|
||||
@@ -149,8 +181,11 @@ extension ItemPath {
|
||||
}
|
||||
}
|
||||
case .trash:
|
||||
for card in snapshot.trash where ids.contains(card.id) {
|
||||
result.append(.trashCard(card.id))
|
||||
let entries = snapshot.trash.map { (order: $0.order, path: ItemPath.trashCard($0.id)) }
|
||||
+ snapshot.trashedLanes.map { (order: $0.order, path: ItemPath.trashLane($0.id)) }
|
||||
for entry in Ranks.sortedForDisplay(entries, order: \.order, name: { $0.path.id.rawValue })
|
||||
where ids.contains(entry.path.id) {
|
||||
result.append(entry.path)
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -166,6 +201,7 @@ extension ItemPath {
|
||||
if lane.id == id { return .lane(id) }
|
||||
if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) }
|
||||
}
|
||||
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
|
||||
if snapshot.trash.contains(where: { $0.id == id }) { return .trashCard(id) }
|
||||
return snapshot.trashedLanes.contains { $0.id == id } ? .trashLane(id) : nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user