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:
2026-07-30 16:16:57 -04:00
parent 785ef5fe14
commit 8014bde7c6
21 changed files with 1357 additions and 910 deletions
+120 -26
View File
@@ -10,25 +10,95 @@ import Foundation
/// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a
/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to
/// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a
/// special place", so the trash's contents *are* `snapshot.trash` already parsed by the same card
/// parse the lanes use, already in `order` display order, already newest-first because every arrival
/// mints a rank above the current top. There is nothing to derive, and no second definition to keep
/// in step with the loader's.
/// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes`
/// already parsed by the loader, already in `order` display order, already newest-first because
/// every arrival mints a rank above the current top. There is nothing to derive, and no second
/// definition to keep in step with the loader's.
///
/// What genuinely remains is what the *commands* need and no view can answer: the two purge
/// confirmations' phrasing, and the menu validation that stages Delete by place. Both are pure
/// functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is testable
/// without an alert on screen.
/// confirmations' phrasing which since lanes rejoined the trash (2026-07-29) has to **count the
/// freight** a trashed lane carries and the menu validation that stages Delete by place. Both are
/// pure functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is
/// testable without an alert on screen. The one number that is not derivable from the snapshot's
/// own shape is the freight itself, and it does not need deriving: the loader counted it at load
/// (`TrashedLane.heldCards`), because the subtree it counts is deliberately not in the snapshot.
public enum TrashModel {
// MARK: - Counts and phrasing
/// "41 cards", "1 card" 06-history-undo.md's **plural folding**, which is all the folding a
/// cards-only container can need ("Cards only. Lanes are never trashed" 03-board-ui.md).
/// "41 cards", "1 card" 06-history-undo.md's **plural folding**.
public static func phrase(_ count: Int) -> String {
"\(count) card\(count == 1 ? "" : "s")"
}
/// "2 lanes", "1 lane" the same folding for the container's other kind (03-board-ui.md
/// § Trash, lanes rejoined 2026-07-29).
public static func lanePhrase(_ count: Int) -> String {
"\(count) lane\(count == 1 ? "" : "s")"
}
/// **What a purge is about to destroy**, counted honestly: the entries themselves, and the cards
/// a trashed lane is carrying (03-board-ui.md § Trash: "Confirms name the freight honestly").
///
/// A value rather than three returns because every phrasing below asks the same three questions,
/// and because "how many cards does this lose" is `cards + freight` in one place rather than at
/// each call site.
public struct Freight: Sendable, Equatable {
/// Trash entries that are cards.
public let cards: Int
/// Trash entries that are lanes.
public let lanes: Int
/// The cards those lanes are holding `TrashedLane.heldCards`, summed.
public let heldCards: Int
public var isEmpty: Bool { cards == 0 && lanes == 0 }
}
/// The freight of a set of resolved trash paths.
public static func freight(of paths: [ItemPath], in snapshot: BoardModel) -> Freight {
var cards = 0
var lanes = 0
var heldCards = 0
for path in paths {
switch path {
case .trashCard:
cards += 1
case .trashLane:
lanes += 1
heldCards += snapshot.trashedLanes.first { $0.id == path.id }?.heldCards ?? 0
case .lane, .card:
// A board path is not this command's business; the callers resolve in `.trash` and
// never produce one. Counted as nothing rather than refused, the vanished-target
// shrug this file gives everywhere.
continue
}
}
return Freight(cards: cards, lanes: lanes, heldCards: heldCards)
}
/// **The aggregate subject both confirmations share** 03-board-ui.md § Trash's own example
/// phrasings, with 06-history-undo.md's plural folding: "41 cards", "41 cards and 2 lanes
/// containing 9 more cards".
///
/// The lane clause says **"more"** only when cards were already counted, because that is the
/// only reading in which the word means anything; a lane holding nothing contributes no clause
/// of its own, since "2 lanes containing 0 cards" says less than "2 lanes".
public static func subject(for freight: Freight) -> String {
var clauses: [String] = []
if freight.cards > 0 {
clauses.append(phrase(freight.cards))
}
if freight.lanes > 0 {
var clause = lanePhrase(freight.lanes)
if freight.heldCards > 0 {
let more = freight.cards > 0 ? "more " : ""
clause += " containing \(freight.heldCards) \(more)card\(freight.heldCards == 1 ? "" : "s")"
}
clauses.append(clause)
}
return clauses.joined(separator: " and ")
}
// MARK: - Confirmations
/// A purge confirmation's three strings, built once and rendered by the window's alert.
@@ -41,26 +111,40 @@ public enum TrashModel {
public let confirmTitle: String
}
/// The alert in front of a **permanent** card delete the trash's own / (03-board-ui.md §
/// The alert in front of a **permanent** delete the trash's own / (03-board-ui.md §
/// Trash: "confirms exactly where the loss is real: the alert stands between one keystroke and
/// unrecoverable deletion").
///
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in the
/// trash, which is also the command's own refusal so the prompt and the action can never
/// disagree about whether there is anything to purge.
/// A sole entry is **named**; several fold into a count. A sole trashed **lane** names its
/// freight as well "Permanently delete lane 'Doing' and its 5 cards" is the design's own
/// phrasing, and the word *lane* is in it because an opaque row's title alone would not say what
/// the extra five cards are doing in the sentence.
///
/// **A mixed set is not a gesture this app can produce** a selection is kind-homogeneous on
/// both axes (04-interactions.md The trash) but the aggregate phrasing covers one anyway
/// rather than picking a kind to lie about: it is the same sentence Empty Trash builds.
///
/// `nil` when the ids name nothing in the trash, which is also the command's own refusal so
/// the prompt and the action can never disagree about whether there is anything to purge.
public static func purgePrompt(
for ids: Set<ItemID>,
snapshot: BoardModel,
unrecoverable: Bool
) -> PurgePrompt? {
let targets = ItemPath.resolve(ids, in: .trash, snapshot: snapshot).filter { !$0.isLane }
let targets = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
guard !targets.isEmpty else { return nil }
let subject: String
if targets.count == 1, let only = targets.first {
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
let name = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
if case let .trashLane(id) = only {
let held = snapshot.trashedLanes.first { $0.id == id }?.heldCards ?? 0
subject = held > 0 ? "lane \(name) and its \(phrase(held))" : "lane \(name)"
} else {
subject = name
}
} else {
subject = phrase(targets.count)
subject = self.subject(for: freight(of: targets, in: snapshot))
}
return PurgePrompt(
title: "Permanently delete \(subject)?",
@@ -70,15 +154,23 @@ public enum TrashModel {
}
/// Empty Trash's alert **always shown** ("Empty Trash confirms everywhere"), and always
/// naming the **true count**: every card in `.trash/`, never the filtered view (03-board-ui.md §
/// Trash: "search-independent, the confirmation naming the card count").
/// naming the **true count**: every entry in `.trash/`, never the filtered view (03-board-ui.md §
/// Trash: "search-independent, the confirmation naming the full count").
///
/// Counts rather than names even for a single card, because the command is about the trash
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing.
/// rather than about an item: "Permanently delete 41 cards" and " 41 cards and 2 lanes
/// containing 9 more cards" are the design's own example phrasings, and the second is why the
/// lane freight is counted rather than left implied a bulk permanent delete must not
/// understate what it takes.
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
guard !snapshot.trash.isEmpty else { return nil }
let freight = Freight(
cards: snapshot.trash.count,
lanes: snapshot.trashedLanes.count,
heldCards: snapshot.trashedLanes.reduce(0) { $0 + $1.heldCards }
)
guard !freight.isEmpty else { return nil }
return PurgePrompt(
title: "Permanently delete \(phrase(snapshot.trash.count))?",
title: "Permanently delete \(subject(for: freight))?",
message: message(unrecoverable: unrecoverable),
confirmTitle: "Delete"
)
@@ -87,9 +179,9 @@ public enum TrashModel {
/// The alert's body: whether any of it comes back.
///
/// The tombstone era's second sentence "Deleting a lane also deletes every card inside it"
/// is gone with the lane entries it warned about: no purge path reaches a lane any more
/// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with
/// undo as its net).
/// stays gone, and now for a better reason than "no purge reaches a lane": the *title* carries
/// the freight explicitly ("and its 5 cards"), which is 03's own phrasing and says the same
/// thing where the user is already reading.
private static func message(unrecoverable: Bool) -> String {
// m7-git: on a git board the content stays reachable in history, so the second sentence is
// the honest one and the trash's own Delete does not confirm there at all
@@ -99,9 +191,9 @@ public enum TrashModel {
: "The board\u{2019}s history still has them."
}
/// What to call a card in a prompt its title, or the "Untitled" rendering.
/// What to call an item in a prompt its title, or the "Untitled" rendering.
///
/// Total by construction: a path whose card has gone since the prompt was asked for reads
/// Total by construction: a path whose item has gone since the prompt was asked for reads
/// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
/// the app gives.
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
@@ -113,6 +205,8 @@ public enum TrashModel {
.cards.first { $0.id == id }?.title.value ?? "Untitled"
case let .trashCard(id):
return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
case let .trashLane(id):
return snapshot.trashedLanes.first { $0.id == id }?.title.value ?? "Untitled"
}
}