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:
@@ -98,21 +98,29 @@ public struct BoardModel: Sendable, Equatable {
|
||||
|
||||
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
||||
///
|
||||
/// **Legacy tombstones ride along flagged during the migration window.** A lane still
|
||||
/// carrying a `deleted:` key loads here with `Lane.isDeleted` set, exactly as it did under
|
||||
/// the retired tombstone model, *and* is reported through `LoadResult.legacyTombstones` — see
|
||||
/// `BoardLoader`'s "The migration window" note for why both happen at once and what ends it.
|
||||
/// **A lane carrying a legacy `deleted:` key is here, live** (01-storage-format.md § Deletion,
|
||||
/// lane clause re-ruled 2026-07-29): the key is ignored outright — no migration, no notice, no
|
||||
/// write — so `Lane.isDeleted` describes the bytes and decides nothing. A *card* carrying one
|
||||
/// still rides along flagged until its migration relocates it (`LoadResult.legacyTombstones` —
|
||||
/// see `BoardLoader`'s "The migration window" note).
|
||||
public let lanes: [Lane]
|
||||
|
||||
/// The board's **materialized trash**: the card folders sitting directly in
|
||||
/// The board's **materialized trash**, card side: the card folders sitting directly in
|
||||
/// `<root>/.trash/`, in display order (01-storage-format.md § Deletion, resettled
|
||||
/// 2026-07-28; 03-board-ui.md § Trash).
|
||||
///
|
||||
/// **A sibling container of `lanes`, not a lane.** `.trash/` is a reserved, app-claimed name
|
||||
/// at board root that "holds card folders directly — same shape as a lane's children, no
|
||||
/// `index.md` of its own", so it has no identity, no title, no `order`, and no frontmatter to
|
||||
/// model: the container *is* the list. That is why this is `[Card]` rather than a `Lane` or a
|
||||
/// `Trash` struct — there is nothing for either to carry that this array does not.
|
||||
/// at board root that "holds card and lane folders interleaved directly, no `index.md` of its
|
||||
/// own", so it has no identity, no title, no `order`, and no frontmatter to model: the
|
||||
/// container *is* the list. That is why this is `[Card]` rather than a `Lane` or a `Trash`
|
||||
/// struct — there is nothing for either to carry that this array does not.
|
||||
///
|
||||
/// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 — lanes trash too).
|
||||
/// The two are separate arrays rather than one list of a sum type because they are separate
|
||||
/// *things*: a trashed card is an ordinary card that every card-shaped surface already reads,
|
||||
/// and a trashed lane is an opaque row that none of them may. Interleaving the two by trash
|
||||
/// rank is a rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the
|
||||
/// one trash column purely by trash rank"), and both arrays carry the `order` that answers it.
|
||||
///
|
||||
/// **Display order is `order` ascending, like any lane's cards** — `Ranks.sortedForDisplay`,
|
||||
/// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a
|
||||
@@ -129,6 +137,14 @@ public struct BoardModel: Sendable, Equatable {
|
||||
/// usable from tests and future fixtures that have no trash to describe.
|
||||
public var trash: [Card] = []
|
||||
|
||||
/// The board's materialized trash, **lane side**: the lane folders sitting directly in
|
||||
/// `<root>/.trash/`, in display order (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes
|
||||
/// trash too", retiring the design's sole destructive delete).
|
||||
///
|
||||
/// See `TrashedLane` for why a trashed lane is not a `Lane`, and `trash` for why the container's
|
||||
/// two kinds are two arrays.
|
||||
public var trashedLanes: [TrashedLane] = []
|
||||
|
||||
/// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
|
||||
/// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer
|
||||
/// can round-trip them without this model knowing what they mean.
|
||||
@@ -175,18 +191,15 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
||||
/// The lane description / WIP policy / notes — equivalent to `document.body`.
|
||||
public var body: String { document.body }
|
||||
|
||||
/// A tombstoned lane: the `deleted` key is *present* (valid or malformed), not merely
|
||||
/// absent. Deliberate: a malformed timestamp still hides the lane from the board — the
|
||||
/// key's presence is what encodes deletion intent, a missing key is the only thing that
|
||||
/// means "not deleted" (`FieldValue.isMissing` already treats an explicit `deleted: null`
|
||||
/// as absent, matching the engine's own null-is-missing rule).
|
||||
/// Whether this lane carries a legacy `deleted:` key — presence, not validity
|
||||
/// (`FieldValue.isMissing` already treats an explicit `deleted: null` as absent).
|
||||
///
|
||||
/// **Retiring.** The tombstone model is retired (01-storage-format.md § Deletion, resettled
|
||||
/// 2026-07-28): the app never writes a `deleted:` key again, and one found on load is
|
||||
/// migration input reported through `LoadResult.legacyTombstones` (a lane's migration removes
|
||||
/// the key and returns the lane live). This accessor survives only for the migration window,
|
||||
/// during which the retiring tombstone renderers still read it; it becomes permanently
|
||||
/// `false` for every board once the migration has run, and goes away with its last consumer.
|
||||
/// **Retired, and on a lane the key is now inert** (01-storage-format.md § Deletion, lane clause
|
||||
/// re-ruled 2026-07-29): the app never writes `deleted:`, and a lane found carrying one "simply
|
||||
/// loads live with the key ignored — no migration machinery, no key-strip write, no notice",
|
||||
/// preserved verbatim like any unhandled key and logged (`LoadWarning.laneLevelDeletedIgnored`).
|
||||
/// So this reads the key without meaning anything by it: nothing hides a lane, nothing rewrites
|
||||
/// it, and the field survives only so the value round-trips.
|
||||
public var isDeleted: Bool { !deleted.isMissing }
|
||||
}
|
||||
|
||||
@@ -238,8 +251,50 @@ public struct Card: Identifiable, Sendable, Equatable {
|
||||
public var body: String { document.body }
|
||||
|
||||
/// A tombstoned card. See `Lane.isDeleted`'s doc comment — the same "presence, not
|
||||
/// validity" rule applies here, and the same retirement: a trashed card lives in
|
||||
/// `BoardModel.trash` and carries no `deleted:` key at all, so this reads `false` for every
|
||||
/// card in the container that replaced the flag.
|
||||
/// validity" rule applies here. A card's key is still migration input (a card carrying it
|
||||
/// relocates into `.trash/` with the key removed), which is the one half of the legacy rule
|
||||
/// that survives; a trashed card carries no `deleted:` key at all, so this reads `false` for
|
||||
/// every card in the container that replaced the flag.
|
||||
public var isDeleted: Bool { !deleted.isMissing }
|
||||
}
|
||||
|
||||
/// A lane in the board's trash: `<root>/.trash/<guid>/index.md` — **an opaque unit**
|
||||
/// (03-board-ui.md § Trash, re-ruled 2026-07-29: "A trashed lane is an opaque unit: one distinct
|
||||
/// dimmed row showing its title and held-card count … never expandable; its cards are invisible to
|
||||
/// search and not individually addressable — it restores whole or purges whole").
|
||||
///
|
||||
/// ### Why it is not a `Lane`
|
||||
///
|
||||
/// A `Lane` carries its cards, its styling and its width because the board renders all three. None
|
||||
/// of that is true here: the row shows a title and a count, takes no styling accents, and its
|
||||
/// subtree is deliberately **not walked into the snapshot** — the loader stops at the trash entry
|
||||
/// exactly as it stops at a card under a lane. A `Lane` with an empty `cards` array would be a lie
|
||||
/// the first consumer to read it would believe; this type can only answer what the design says the
|
||||
/// row knows.
|
||||
///
|
||||
/// The kind itself is `kind:`'s to answer, never position's: the trash is flat, and an empty lane
|
||||
/// folder is shape-identical to a card folder (01-storage-format.md § Deletion —
|
||||
/// `IntegrityRules.trashKind`).
|
||||
public struct TrashedLane: Identifiable, Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
|
||||
public let schema: Int
|
||||
public let title: FieldValue<String>
|
||||
|
||||
/// Rank within the trash, ascending = top to bottom — the same required, strictly validated
|
||||
/// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's
|
||||
/// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost.
|
||||
public let order: Double
|
||||
|
||||
/// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards").
|
||||
///
|
||||
/// Counted from disk at load, never derived from a walked subtree: the count is the one fact
|
||||
/// about the freight the snapshot carries, and counting is what keeps the entry opaque. The
|
||||
/// unit counted is what the loader *would* render as a card — an identity-shaped child holding
|
||||
/// its own `index.md` — so the row's number and a restore's outcome agree.
|
||||
public let heldCards: Int
|
||||
|
||||
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted, so a restore
|
||||
/// (an ordinary move out) returns the lane exactly as it went in.
|
||||
public let document: FrontmatterDocument
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user