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
+26 -44
View File
@@ -471,15 +471,18 @@ public final class BannerCenter {
/// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
/// the app moved the user's folders on its own initiative, on a board it opened rather than on a
/// gesture they made. That must be said out loud, must not evaporate unread, and must not rank
/// as an error, because no action failed. The one nuance worth naming: the *lane* half is a
/// resurrection rather than a removal cards nobody asked to see again may reappear on the
/// board which is exactly the kind of surprise this class exists to announce.
/// as an error, because no action failed.
///
/// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for
/// an untitled one "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so
/// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
public func postMigratedTombstones(cards: [String?], lanes: [String?]) {
guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return }
/// **Cards only** (01-storage-format.md § Deletion, lane clause re-ruled 2026-07-29): the lane
/// half of this notice retired with the lane migration itself a lane's `deleted:` is inert now,
/// nothing is written for it, and a row announcing an act the app did not perform would be worse
/// than silence.
///
/// `cards` holds the migrated cards' titles, in the order they were written, `nil` for an
/// untitled one "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so the
/// phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
public func postMigratedTombstones(cards: [String?]) {
guard let message = Self.migratedTombstonesMessage(cards: cards) else { return }
postLoss(message)
}
@@ -962,49 +965,28 @@ public final class BannerCenter {
return "Moved '\(name)' into attachments — \(subject)"
}
/// The legacy tombstone migration's line **one folded sentence for both halves**, written in
/// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first,
/// the subject after an em dash, plurals folded, a sole item named.
/// The legacy tombstone migration's line, written in `relocatedLooseFilesMessage`'s voice
/// because it is the same kind of notice: the act first, the subject after an em dash, plurals
/// folded, a sole item named.
///
/// The two clauses are joined rather than posted as two rows, because it is **one migration**:
/// a board opened, its old deletion markers were resolved, and that is one thing that happened
/// to the user's files. Two rows would also mean two dismissals for one event, and would rank a
/// resurrection and a relocation against each other for no reason.
/// **One clause, since the lane half retired** (01-storage-format.md § Deletion, re-ruled
/// 2026-07-29 lanes ignore the key rather than migrating). The shapes:
///
/// The shapes, in the relocation's own idiom:
/// - **One card**: "Moved 'Fix login' to the trash it carried an old deleted marker".
/// - **Several**: "Moved 3 cards to the trash they carried old deleted markers".
///
/// - **One card**, no lanes: "Moved 'Fix login' to the trash it carried an old deleted marker".
/// - **Several cards**: "Moved 3 cards to the trash they carried old deleted markers".
/// - **One lane**, no cards: "Restored 'Doing' it carried an old deleted marker".
/// - **Both**: "Moved 3 cards to the trash and restored 2 lanes they carried old deleted markers".
///
/// **The tail names the cause once**, and it is the whole explanation the row owes: the user did
/// not delete anything just now, and without the clause the sentence would read as an action
/// they had somehow just taken. The singular/plural of the tail follows the *total*, so the
/// mixed case never has to spell a singular (two clauses carry at least two items).
/// **The tail names the cause**, and it is the whole explanation the row owes: the user did not
/// delete anything just now, and without the clause the sentence would read as an action they
/// had somehow just taken.
///
/// `nil` when nothing migrated a migration that migrated nothing is not news.
public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? {
let total = cards.count + lanes.count
guard total > 0 else { return nil }
var clauses: [String] = []
if !cards.isEmpty {
let subject = cards.count == 1
? sole(cards[0])
: "\(cards.count) cards"
clauses.append("Moved \(subject) to the trash")
}
if !lanes.isEmpty {
let subject = lanes.count == 1
? sole(lanes[0])
: "\(lanes.count) lanes"
clauses.append(clauses.isEmpty ? "Restored \(subject)" : "restored \(subject)")
}
let tail = total == 1
public nonisolated static func migratedTombstonesMessage(cards: [String?]) -> String? {
guard !cards.isEmpty else { return nil }
let subject = cards.count == 1 ? sole(cards[0]) : "\(cards.count) cards"
let tail = cards.count == 1
? "it carried an old deleted marker"
: "they carried old deleted markers"
return "\(clauses.joined(separator: " and "))\(tail)"
return "Moved \(subject) to the trash\(tail)"
}
/// The claimed-name displacement's line the relocation's own voice (the act first, the subject
+8 -3
View File
@@ -277,9 +277,12 @@ public struct BoardDiff: Sendable, Equatable {
/// Whether anything the digest can see differs the whole value while the trash is being
/// walked, everything but the trash while it is not.
///
/// `trash` is the one `var` on `BoardModel`, which is what makes "everything except the trash"
/// expressible as value equality rather than as a second field-by-field list that would go
/// stale the moment the model grows a field.
/// `trash` and `trashedLanes` are the two `var`s on `BoardModel`, which is what makes "everything
/// except the trash" expressible as value equality rather than as a second field-by-field list
/// that would go stale the moment the model grows a field. Both are cleared, for one reason: a
/// trashed lane is trash, so churn among the rows must stay invisible while the column is hidden
/// a lane *entering* the trash is still seen, because it left `lanes`, which is the board side
/// and is exactly the "1 lane deleted" event the user watched happen.
private static func boardSideDiffers(
_ old: BoardModel,
_ new: BoardModel,
@@ -290,6 +293,8 @@ public struct BoardDiff: Sendable, Equatable {
var newSansTrash = new
oldSansTrash.trash = []
newSansTrash.trash = []
oldSansTrash.trashedLanes = []
newSansTrash.trashedLanes = []
return oldSansTrash != newSansTrash
}
}
+122 -89
View File
@@ -1863,7 +1863,7 @@ public final class BoardStore: HealHost {
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
}
/// A card's title at a resolved path, in either container the skip banner's quoted subject.
/// An item's title at a resolved path, in either container the skip banner's quoted subject.
nonisolated static func cardTitle(at path: ItemPath, in snapshot: BoardModel) -> String? {
switch path {
case let .card(lane, id):
@@ -1872,6 +1872,8 @@ public final class BoardStore: HealHost {
snapshot.trash.first { $0.id == id }?.title.value
case let .lane(id):
snapshot.lanes.first { $0.id == id }?.title.value
case let .trashLane(id):
snapshot.trashedLanes.first { $0.id == id }?.title.value
}
}
@@ -2227,11 +2229,7 @@ public final class BoardStore: HealHost {
/// The parent folder an inverse move returns it to.
func parent(under root: URL) -> URL {
switch path {
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
case .trashCard: BoardWriter.trashFolder(inBoard: root)
case .lane: root
}
BoardStore.parentFolder(of: path, under: root)
}
}
@@ -2402,10 +2400,13 @@ public final class BoardStore: HealHost {
}
/// The parent folder an item at `path` sits in the destination an inverse move returns it to.
///
/// Both trash cases answer the container itself: `.trash/` is flat, so a trashed lane's parent is
/// the same folder a trashed card's is (01-storage-format.md § Deletion).
nonisolated static func parentFolder(of path: ItemPath, under root: URL) -> URL {
switch path {
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
case .trashCard: BoardWriter.trashFolder(inBoard: root)
case .trashCard, .trashLane: BoardWriter.trashFolder(inBoard: root)
case .lane: root
}
}
@@ -3121,20 +3122,22 @@ public final class BoardStore: HealHost {
public func deleteSelection() {
switch selection.container {
case .board: delete(selection.ids)
case .trash: deleteTrashCards(selection.ids)
case .trash: deleteTrashEntries(selection.ids)
}
}
/// Deletes every **board** item in `ids` cards into `.trash/`, lanes outright in one bracket.
/// Deletes every **board** item in `ids` cards and lanes alike into `.trash/` in one bracket.
///
/// **Two writes, because they are two acts** (03-board-ui.md § Trash): "deleting a card moves its
/// folder into `<board-root>/.trash/`", while "Cards only. Lanes are never trashed deleting a
/// lane deletes it, folder and contents, physically. The net is undo, not the trash."
/// **One act at two levels now** (03-board-ui.md § Trash, re-ruled 2026-07-29): "deleting a lane
/// moves its folder subtree intact into `.trash/`, exactly as a card moves". The two writes
/// below differ only in which Writer door they take (`deleteCardToTrash` / `deleteLaneToTrash`),
/// which is a `kind` question and not a semantic one no dialog either way, because the move is
/// recoverable and "nothing needs confirming".
///
/// **A set naming both is not a gesture this app can produce** the selection is cards XOR lanes
/// (04-interactions.md § Selection) so the partition below never actually splits, and when a
/// caller hands one anyway the lanes win: a lane delete takes its cards with it, and running the
/// trash move as well would put a second step on the undo stack for one keystroke.
/// caller hands one anyway the lanes win: a lane takes its cards with it, and moving them
/// separately would file the same cards twice and put two steps on the stack for one keystroke.
///
/// **Ids that name nothing are silently skipped**, not refused: the paths are resolved against
/// the snapshot, so a selection the next reload will drop writes nothing. An empty resolution
@@ -3170,7 +3173,7 @@ public final class BoardStore: HealHost {
}
let landed = lanes.isEmpty
? moveToTrash(paths.compactMap(Self.cardMove(of:)))
: removeLanes(lanes)
: moveLanesToTrash(lanes)
guard landed else { return }
if let successor {
@@ -3246,7 +3249,7 @@ public final class BoardStore: HealHost {
/// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the
/// trash's topmost position, minting an `order` rank above the current top"). That is a question
/// about the snapshot, which the stateless Writer does not have so `Ranks.insertAtHead` runs
/// here over `snapshot.trash`, and a multi-card delete threads the minted rank back through the
/// here over `trashRanks`, and a multi-card delete threads the minted rank back through the
/// running list so each card in the run lands above the one before it. Newest-first therefore
/// falls out of ordinary ranks, with no timestamp sort anywhere.
///
@@ -3265,7 +3268,7 @@ public final class BoardStore: HealHost {
// The ranks, minted against the trash as it stands and threaded forward: each arrival is
// above the previous one, so a three-card reads newest-first in the column exactly as three
// separate deletes would.
var ladder = snapshot.trash.map(\.order)
var ladder = trashRanks
var ranks: [Double] = []
for _ in moves {
let rank = Ranks.insertAtHead(ofVisible: ladder)
@@ -3329,72 +3332,109 @@ public final class BoardStore: HealHost {
return true
}
/// **Deleting a lane is physical** the folder and its contents go (03-board-ui.md § Trash:
/// "Cards only. Lanes are never trashed The net is undo, not the trash").
/// **Deleting a lane is a move into `.trash/`, subtree intact** (03-board-ui.md § Trash,
/// re-ruled 2026-07-29: "deleting a lane moves its folder subtree intact into `.trash/`,
/// exactly as a card moves The no-dialog posture survives for a better reason: the move is
/// recoverable, so nothing needs confirming").
///
/// **Capture before you remove.** The undo replays the lane's bytes, which only works if the step
/// is holding them: `captureSubtree` reads the whole tree nested cards, their `attachments/`,
/// every stray, symlinks as links, POSIX modes inside the same bracket as the removal, so
/// nothing can change between the two. The capture is the reason this is a *destructive* delete
/// with a real inverse rather than an unrecoverable one.
/// **`moveToTrash`'s twin, and deliberately its mirror image**: the same head-of-the-trash rank
/// mint threaded through the run, the same one bracket, the same one step because on disk it is
/// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard
/// it passes and the `kind` it stamps). What is *not* here any more is the whole capture layer:
/// the lane's bytes never leave the disk, so nothing has to hold them (13-native-undo.md
/// Interaction with the trash: "the recreate-from-capture inverse retires with the last
/// destructive delete").
///
/// **In-session only, and that is the accepted net** (13-native-undo.md Rules session-only
/// persistence): the bytes live on the stack, so closing the board loses them. Git boards keep
/// the lane reachable forever (06-history-undo.md's delete-never-forgets) a Pro difference,
/// stated honestly.
///
/// A capture that fails takes the whole bracket down and nothing is removed: better a delete that
/// visibly did not happen than one whose undo could not.
/// **The cards ride along and are not the store's business**: they are inside the folder that
/// moved, so they are neither read nor written, and they leave the snapshot with their lane
/// which is what dismisses their card windows and voids their pending cuts, through the ordinary
/// vanish rule and no clause of its own (02-architecture.md § Live-reload resilience).
@discardableResult
private func removeLanes(_ ids: [ItemID]) -> Bool {
private func moveLanesToTrash(_ ids: [ItemID]) -> Bool {
let lanes = ids.compactMap { id in snapshot.lanes.first { $0.id == id } }
guard !lanes.isEmpty else { return false }
let root = rootURL
let folders = lanes.map { ItemPath.lane($0.id).folder(under: root) }
var captures: [SubtreeSnapshot] = []
var ladder = trashRanks
var ranks: [Double] = []
for _ in lanes {
let rank = Ranks.insertAtHead(ofVisible: ladder)
ranks.append(rank)
ladder.insert(rank, at: 0)
}
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
captures.append(try BoardWriter.captureSubtree(at: folder, operation: .delete(title: nil)))
try BoardWriter.removeLane(at: folder)
for (lane, rank) in zip(lanes, ranks) {
try BoardWriter.deleteLaneToTrash(
at: ItemPath.lane(lane.id).folder(under: root),
inBoard: root,
order: rank
)
}
}
guard landed != nil, captures.count == folders.count else { return false }
guard landed != nil else { return false }
// lane delete **recreate the folder from the registered inverse** (13 Rules). Values, not
// references: the capture is a `SubtreeSnapshot` of bytes taken before the removal, so the
// step means the same thing any number of reloads later.
// lane delete **the ordinary move back** (13-native-undo.md Interaction with the trash:
// "a lane [returns] to its strip position (subtree intact it never left the folder)"). The
// redo replays the forward write with its own captured trash rank, exactly as the card
// delete's does, so a redone delete lands the lane where the undo took it from rather than at
// whatever the top of the trash has become in the meantime.
//
// Its predicate is existence and nothing else the undo wants the paths still empty (a
// recreate refuses to clobber, so a lane somebody re-made at that id is not this step's to
// overwrite), the redo wants them back.
let steps = Array(zip(folders, captures))
// The expectations are one swap, and the container rides in the path (`HistoryStaleness`):
// the undo wants the lane in the trash holding the rank the delete gave it, the redo wants it
// back on the strip holding the rank it left. A foreign restore empties the trash path and
// the undo skips; a foreign re-delete empties the strip path and the redo skips.
let steps = zip(lanes, ranks).map { lane, rank in
(
trashed: ItemPath.trashLane(lane.id).folder(under: root),
origin: ItemPath.lane(lane.id).folder(under: root),
priorOrder: lane.order,
trashRank: rank
)
}
registerStep(
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
subject: lanes.count == 1 ? lanes[0].title.value : nil,
undoExpects: steps.map { .absent($0.0) },
redoExpects: steps.map { .present($0.0) }
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in
for (folder, capture) in steps {
try BoardWriter.recreateSubtree(at: folder, from: capture, operation: .createLane)
for step in steps {
_ = try BoardWriter.moveItem(
at: step.trashed,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.priorOrder
)
}
} redo: { _ in
// Reversed, so a multi-lane delete unwinds in the mirror of the order it was made in.
for (folder, _) in steps.reversed() {
try BoardWriter.removeLane(at: folder)
for step in steps {
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root, order: step.trashRank)
}
}
return true
}
/// **The trash's own Delete permanent** (03-board-ui.md § Trash: "on a trash card, Delete
/// (/) is permanent; in the trash it removes the folder").
/// Every rank the trash currently holds, **both kinds** what a fresh arrival mints above
/// (03-board-ui.md § Trash: "Every arrival lands at the top regardless of kind", so the ladder
/// is the whole container and not one array of it).
private var trashRanks: [Double] {
snapshot.trash.map(\.order) + snapshot.trashedLanes.map(\.order)
}
/// **The trash's own Delete permanent** (03-board-ui.md § Trash: "on a trash selection, Delete
/// (/) is permanent in the trash it removes the folder").
///
/// Its own method rather than a flag on `delete(_:)` because it is a different act with a
/// different safety story: it registers **no undo step**, and `purgeIsUnrecoverable` stays `true`
/// 13-native-undo.md Rules settles this by name ("Permanently delete (the trash's Delete,
/// Empty Trash) the confirm *is* the safety"). A stack entry here would be a promise the
/// filesystem cannot keep.
/// Empty Trash) the confirm *is* the safety", "lanes and their freight included"). A stack
/// entry here would be a promise the filesystem cannot keep.
///
/// **Entries, not cards** (lanes rejoined the trash 2026-07-29): a trashed lane row purges whole,
/// its subtree with it, through the same Writer call the walk is the recursive removal's, and
/// the *counting* the confirmation needs is `TrashModel.freight`'s, off the snapshot before this
/// runs.
///
/// **The confirmation is the window's** (`TrashConfirmations`), for `deleteSelection`'s reason
/// and it is why this seam is explicit: the alert has to be able to name what this will purge
@@ -3403,7 +3443,7 @@ public final class BoardStore: HealHost {
/// The selection moves to the successor sibling **within the trash**: the permanent delete is as
/// deliberate a gesture as the move-to-trash, so repeated walks down the column exactly as it
/// walks down a lane (04-interactions.md The map).
public func deleteTrashCards(_ ids: Set<ItemID>) {
public func deleteTrashEntries(_ ids: Set<ItemID>) {
let paths = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
guard !paths.isEmpty else { return }
let successor = SelectionGrammar.successor(
@@ -3416,7 +3456,7 @@ public final class BoardStore: HealHost {
try? performWrite { () throws(BoardWriteError) -> Void in
for path in paths {
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
try BoardWriter.purgeTrashEntry(at: path.folder(under: root), inBoard: root)
}
}
@@ -3427,9 +3467,11 @@ public final class BoardStore: HealHost {
}
}
/// **Empty Trash ** purges every card in `<root>/.trash/`, in one bracket.
/// **Empty Trash ** purges every entry in `<root>/.trash/`, **lane subtrees walked**, in
/// one bracket (03-board-ui.md § Trash).
///
/// Not undoable, `deleteTrashCards`' ruling this is the other half of 13's "Permanently delete".
/// Not undoable, `deleteTrashEntries`' ruling this is the other half of 13's "Permanently
/// delete".
///
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the writer walks
/// the folder itself, never a filtered view "a bulk command about the trash itself never
@@ -3437,7 +3479,7 @@ public final class BoardStore: HealHost {
/// is the strongest form of that guarantee, and strays a hand-editor left in the container are
/// preserved verbatim rather than swept up with the cards (`BoardWriter.emptyTrash`).
public func emptyTrash() {
guard !snapshot.trash.isEmpty else { return }
guard !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
@@ -3460,14 +3502,14 @@ public final class BoardStore: HealHost {
/// the tail hook, the lock deferral, the writability gate, the memo and the clear-on-success are
/// one implementation. What is this heal's own is below.
///
/// ### The two acts
/// ### One act, since 2026-07-29
///
/// - **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`).
/// - **A lane returns live** with the key stripped and nothing moved
/// (`BoardWriter.migrateTombstonedLane`) "resurrection is the safe direction, nothing is
/// destroyed by migration". A tombstoned lane's own cards come back with it; any of them
/// carrying their own key migrate on their own account, as ordinary tombstoned cards, in the
/// same pass.
/// **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`),
/// and that is the whole of it. The lane half is **retired wholesale** with the lane trash
/// (01-storage-format.md § Deletion: "cards migrate, lanes ignore" a lane carrying `deleted:`
/// "simply loads live with the key ignored no migration machinery, no key-strip write, no
/// notice"). Old tombstoned lanes reappearing is the accepted cost, stated in the ruling; a
/// tombstoned lane's own cards still migrate on their own account, as ordinary tombstoned cards.
///
/// ### The order among migrating cards is `deleted:`-ascending, deliberately
///
@@ -3481,44 +3523,35 @@ public final class BoardStore: HealHost {
///
/// ### The write half re-verifies against disk
///
/// Each item's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
/// Each card's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
/// has gone an agent removed it, another window migrated first skips silently: "losing the
/// race to a foreign fix is success, never an error" (§ Validation and healing, generalized from
/// the Repair-races-a-vanished-duplicate precedent). Without it a lane whose key vanished under
/// the write would be rewritten for nothing, stamping `modified` on a file with no defect left.
/// the Repair-races-a-vanished-duplicate precedent). Without it a card whose key vanished under
/// the write would be moved into the trash for nothing.
public func migrateLegacyTombstones() {
let work = legacyTombstones
let root = rootURL
let cards = Self.migrationOrder(of: work, in: snapshot)
let lanes = work.filter { $0.kind == .lane }
var movedCards: [String?] = []
var returnedLanes: [String?] = []
// The ranks are minted exactly as a delete's are head of the trash, threaded forward so a
// migrated card is indistinguishable on disk from one the user deletes today.
var ladder = snapshot.trash.map(\.order)
var ladder = trashRanks
heals.run(
.legacyTombstone,
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
on: self
) { () throws(BoardWriteError) -> Void in
for card in cards {
guard let cardID = card.cardID else { continue }
let folder = ItemPath.card(lane: card.laneID, id: cardID).folder(under: root)
let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
let rank = Ranks.insertAtHead(ofVisible: ladder)
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
ladder.insert(rank, at: 0)
movedCards.append(card.title)
}
for lane in lanes {
let folder = ItemPath.lane(lane.laneID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
try BoardWriter.migrateTombstonedLane(at: folder)
returnedLanes.append(lane.title)
}
} posting: {
.migratedTombstones(cards: movedCards, lanes: returnedLanes)
.migratedTombstones(cards: movedCards)
}
}
@@ -3538,7 +3571,7 @@ public final class BoardStore: HealHost {
return !document.deleted.isMissing
}
/// The `.card` tombstones in the order they should be filed into the trash oldest `deleted:`
/// The tombstoned cards in the order they should be filed into the trash oldest `deleted:`
/// first, so the newest ends up on top (see `migrateLegacyTombstones`).
///
/// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the
@@ -3556,10 +3589,9 @@ public final class BoardStore: HealHost {
}
return work
.enumerated()
.filter { $0.element.kind == .card }
.sorted { lhs, rhs in
let left = lhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
let right = rhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
let left = stamps[lhs.element.cardID] ?? .distantPast
let right = stamps[rhs.element.cardID] ?? .distantPast
return left == right ? lhs.offset < rhs.offset : left < right
}
.map(\.element)
@@ -3899,8 +3931,9 @@ public final class BoardStore: HealHost {
/// visible and a non-empty trash selection, Select All selects all visible trash cards; in every
/// other state, all visible live cards the container boundary decides which 'all' is meant").
/// A trash selection naming nothing (a foreign restore, a purge) falls through to the board
/// rather than selecting the trash wholesale on a guess. There is no kind clause any more:
/// lanes are never trashed, so every trash row is a card.
/// rather than selecting the trash wholesale on a guess. **Select All is card-scoped in both
/// containers, never lane rows** (04 The trash, re-affirmed 2026-07-29 with lanes back in the
/// trash), which is why the trash branch reads its cards and asks no kind question.
///
/// The anchor and the navigation head with it **survives if it is still in the set** and is
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
+5 -4
View File
@@ -158,8 +158,9 @@ public final class HealScheduler {
case none
/// Loss row, warning tone: files were moved into a card's `attachments/`.
case relocatedLooseFiles([BannerCenter.Relocation])
/// Loss row, warning tone: legacy `deleted:` keys were resolved.
case migratedTombstones(cards: [String?], lanes: [String?])
/// Loss row, warning tone: legacy `deleted:` keys on **cards** were resolved (the lane half
/// retired 2026-07-29 a lane's key is inert, and nothing is written for it).
case migratedTombstones(cards: [String?])
/// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
case displacedClaimedNames([BannerCenter.Displacement])
/// Loss row, warning tone: withheld duplicate ids were reminted, named by title.
@@ -178,8 +179,8 @@ public final class HealScheduler {
break
case let .relocatedLooseFiles(relocations):
banners.postRelocatedLooseFiles(relocations)
case let .migratedTombstones(cards, lanes):
banners.postMigratedTombstones(cards: cards, lanes: lanes)
case let .migratedTombstones(cards):
banners.postMigratedTombstones(cards: cards)
case let .displacedClaimedNames(displacements):
banners.postDisplacedClaimedNames(displacements)
case let .remintedDuplicateIDs(titles):
+57 -21
View File
@@ -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
}
}
+23 -5
View File
@@ -27,11 +27,14 @@ import Foundation
///
/// ### What is *not* the predicate's business
///
/// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the
/// query stays on the board showing an empty body and a `0` badge, because the width division is
/// layout and the filter is content. There is no lane overload at all: the tombstone model's trash
/// held lane *entries* that had to be filtered like rows, and lanes are never trashed now
/// (03-board-ui.md § Trash), so a card predicate is the whole of the filter.
/// **A live lane is never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss
/// the query stays on the board showing an empty body and a `0` badge, because the width division is
/// layout and the filter is content.
///
/// **A trashed lane row is the one lane that filters** (03-board-ui.md § Trash, re-ruled 2026-07-29
/// "The row matches the search filter by lane title only"), and the difference is exactly what an
/// opaque unit is: it is a row in a column, not a container the query can empty, so it either shows
/// or it does not. Hence one overload for it, deliberately title-only.
public struct SearchFilter: Sendable, Equatable {
/// The query exactly as typed kept so a caller can key a transaction or a test on it.
@@ -78,6 +81,16 @@ public struct SearchFilter: Sendable, Equatable {
matches(title: card.title.value, body: card.body)
}
/// A trashed lane row **by title only** (03-board-ui.md § Trash, re-ruled 2026-07-29: "The row
/// matches the search filter by lane title only").
///
/// It is the opaque unit's own rule rather than an omission: the row's cards "are invisible to
/// search and not individually addressable", and the lane's *body* is not on screen either, so
/// matching anything the row does not show would hand the user a hit they cannot see.
public func matches(_ lane: TrashedLane) -> Bool {
matches(title: lane.title.value, body: "")
}
// MARK: - The visible universe
/// Every id the filter leaves visible in `container` **the universe
@@ -102,6 +115,11 @@ public struct SearchFilter: Sendable, Equatable {
for card in snapshot.trash where matches(card) {
ids.insert(card.id)
}
// A trash lane row *is* filtered, unlike a live lane: it is a row in a column rather than
// a container the query empties, and it matches by title only (03-board-ui.md § Trash).
for lane in snapshot.trashedLanes where matches(lane) {
ids.insert(lane.id)
}
}
return ids
}
+18 -14
View File
@@ -59,12 +59,13 @@ public enum ClickModifier: Sendable, Equatable {
/// (`SelectionGrammarTests`).
///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
/// homogeneous on **two** axes now cards XOR lanes (§ Selection) and board XOR trash (§ The
/// trash's "single container rule replacing the old liveness law") and the third, kind-inside-the
/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
/// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below
/// is ever mixed: a modifier that would cross an axis degrades to a replace, which is the only
/// answer that keeps the invariant true without silently dropping what the user asked for.
/// homogeneous on **two** axes cards XOR lanes (§ Selection) and board XOR trash (§ The trash's
/// "single container rule replacing the old liveness law") and since lanes rejoined the trash
/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is
/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a
/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier
/// that would cross an axis degrades to a replace, which is the only answer that keeps the invariant
/// true without silently dropping what the user asked for.
///
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
@@ -270,9 +271,10 @@ public enum SelectionGrammar {
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
///
/// **The lane list takes no filter**, because a card query hides no lane see `SearchFilter`.
/// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed"
/// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to.
/// **The lane list takes no filter**, because a card query hides no *live* lane see
/// `SearchFilter`. **`(.trash, .lane)` is the trash's lane rows** (04-interactions.md The
/// trash, lanes rejoined 2026-07-29): the list is empty until the rows are addressable by the
/// grammar, which is where the pointer and keyboard vocabulary for them lands.
public static func order(
of kind: SelectionKind,
in container: ItemContainer,
@@ -337,8 +339,9 @@ public enum SelectionGrammar {
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
/// as empty: a selection the next reload will drop must not decide what a click does now.
///
/// **The trash answers `.card` or nothing**, because lanes are never trashed which is why the
/// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
/// **The trash answers `.card` or nothing** while its lane rows are not yet addressable the
/// kind axis reaches both containers (04-interactions.md The trash), and this is where the
/// second container's answer to it lands.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil }
switch selection.container {
@@ -453,9 +456,10 @@ public struct MarqueeTarget: Sendable, Equatable {
/// across lanes" across them, not over them). Lanes are simply never registered as targets, and
/// the filter below keeps the rule true even if one were.
///
/// **There is no kind rule any more.** Under the tombstone model the trash interleaved card rows and
/// lane rows in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind;
/// lanes are never trashed now, so both containers hold cards and the rule is one line for both.
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the
/// side it started on and selects cards only (as the board marquee does); lane rows join by click
/// grammar" (04-interactions.md The trash, re-affirmed 2026-07-29). A band never has to break a
/// tie between a card and a lane row, because it never sweeps a lane row at all.
public enum MarqueeMath {
/// The ids `rect` sweeps.
+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"
}
}