From 8014bde7c6980605ffd86610e5a03f3db4726d9d Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 30 Jul 2026 16:16:57 -0400 Subject: [PATCH] =?UTF-8?q?Lanes=20delete=20into=20the=20trash=20=E2=80=94?= =?UTF-8?q?=20storage,=20loader,=20writer,=20and=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/LiveStore/BannerCenter.swift | 70 ++-- Kanban/LiveStore/BoardDiff.swift | 11 +- Kanban/LiveStore/BoardStore.swift | 211 ++++++----- Kanban/LiveStore/HealScheduler.swift | 9 +- Kanban/LiveStore/ItemLocation.swift | 78 ++-- Kanban/LiveStore/SearchFilter.swift | 28 +- Kanban/LiveStore/SelectionGrammar.swift | 32 +- Kanban/LiveStore/TrashModel.swift | 146 ++++++-- Kanban/Storage/BoardLoader.swift | 229 ++++++++---- Kanban/Storage/BoardModel.swift | 101 ++++-- Kanban/Storage/BoardWriter.swift | 459 ++++++------------------ Kanban/Storage/IntegrityRules.swift | 40 +-- Kanban/UI/Board/TrashCommands.swift | 21 +- KanbanTests/CardWindowFateTests.swift | 24 ++ KanbanTests/FixtureBoardTests.swift | 4 +- KanbanTests/IntegrityRulesTests.swift | 8 +- KanbanTests/TrashModelTests.swift | 119 ++++++ KanbanTests/TrashStorageTests.swift | 445 ++++++++++++++--------- KanbanTests/TrashWriteTests.swift | 122 ++++--- KanbanTests/UndoWriteTests.swift | 67 +++- KanbanTests/WriteFidelityTests.swift | 43 +++ 21 files changed, 1357 insertions(+), 910 deletions(-) diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 4f8d6ed..85e5217 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -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 diff --git a/Kanban/LiveStore/BoardDiff.swift b/Kanban/LiveStore/BoardDiff.swift index 22905d0..e4b9cb4 100644 --- a/Kanban/LiveStore/BoardDiff.swift +++ b/Kanban/LiveStore/BoardDiff.swift @@ -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 } } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index eef80b5..2846a6a 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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 `/.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) { + public func deleteTrashEntries(_ ids: Set) { 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 `/.trash/`, in one bracket. + /// **Empty Trash… ⇧⌘⌫** — purges every entry in `/.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, diff --git a/Kanban/LiveStore/HealScheduler.swift b/Kanban/LiveStore/HealScheduler.swift index 71e11bf..68a94da 100644 --- a/Kanban/LiveStore/HealScheduler.swift +++ b/Kanban/LiveStore/HealScheduler.swift @@ -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): diff --git a/Kanban/LiveStore/ItemLocation.swift b/Kanban/LiveStore/ItemLocation.swift index 483a082..cb026c9 100644 --- a/Kanban/LiveStore/ItemLocation.swift +++ b/Kanban/LiveStore/ItemLocation.swift @@ -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 { var universe: Set = [] 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** — -/// `/`, `//`, and `/.trash/`. 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** — `/`, `//`, and, since lanes rejoined the trash (2026-07-29), +/// `/.trash/` and `/.trash/`. 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: `//`. @@ -85,25 +101,36 @@ public enum ItemPath: Sendable, Equatable { /// A card in the board's trash: `/.trash//`. case trashCard(ItemID) + /// A lane in the board's trash: `/.trash//` — 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 } } diff --git a/Kanban/LiveStore/SearchFilter.swift b/Kanban/LiveStore/SearchFilter.swift index c820b1b..d000fda 100644 --- a/Kanban/LiveStore/SearchFilter.swift +++ b/Kanban/LiveStore/SearchFilter.swift @@ -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 } diff --git a/Kanban/LiveStore/SelectionGrammar.swift b/Kanban/LiveStore/SelectionGrammar.swift index 2f83a31..76bb4cc 100644 --- a/Kanban/LiveStore/SelectionGrammar.swift +++ b/Kanban/LiveStore/SelectionGrammar.swift @@ -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. diff --git a/Kanban/LiveStore/TrashModel.swift b/Kanban/LiveStore/TrashModel.swift index 54c9421..8eb2659 100644 --- a/Kanban/LiveStore/TrashModel.swift +++ b/Kanban/LiveStore/TrashModel.swift @@ -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, 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" } } diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 49878cb..695214d 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -46,30 +46,34 @@ import os /// /// ## The trash is a container, not a level /// -/// `/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly -/// (01-storage-format.md § Deletion, resettled 2026-07-28) — "same shape as a lane's children, no -/// `index.md` of its own". The walk therefore treats it as a second card container beside the -/// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane -/// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands -/// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and -/// never warns; absent, the trash is simply empty. +/// `/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders +/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined +/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside +/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the +/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never +/// warns; absent, the trash is simply empty. +/// +/// **`kind:` decides what each entry is** (`IntegrityRules.trashKind`): depth defines meaning on the +/// live board, but the trash is flat and an empty lane folder is shape-identical to a card folder. +/// A card lands in `BoardModel.trash` parsed like any card; a lane lands in +/// `BoardModel.trashedLanes` as an **opaque unit** — title, rank, and a count of the cards it holds +/// — and its subtree is never walked into the snapshot (03-board-ui.md § Trash). /// /// ## The migration window /// -/// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on -/// load is *migration input* — a card relocates into `.trash/` with the key removed, a lane -/// returns live with the key removed, a board-level key stays meaningless (ignored + logged). -/// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports -/// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated -/// fix. +/// The tombstone model is retired: no `deleted:` key is ever written again. A key found on a **card** +/// is *migration input* — the card relocates into `.trash/` with the key removed. A key on a lane or +/// on the board is meaningless and stays where it is (ignored + logged, the tolerate tier: lanes +/// stopped migrating on 2026-07-29, when they gained a real trash of their own). Detection is +/// read-only here, the loose-file carve-out's posture exactly: this loader reports what it found +/// through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated fix. /// -/// **Until that write lands, such items still load through the retiring tombstone path** — -/// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a -/// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy", -/// and the safe direction while the fix is still pending (it is deferred under any read-only lock, -/// and can be pending for a whole session) is for nothing to silently vanish from view before its -/// folder has actually moved. The window closes per board on the first successful migration write, -/// after which no `deleted:` key exists to read and the flag is permanently `false`. +/// **Until that write lands, such cards still load through the retiring tombstone path** — a +/// `deleted:`-carrying card stays filed under its lane with `isDeleted` set. That is a deliberate +/// intermediate, not an oversight: the migration's whole promise is "never destroy", and the safe +/// direction while the fix is still pending (it is deferred under any read-only lock, and can be +/// pending for a whole session) is for nothing to silently vanish from view before its folder has +/// actually moved. The window closes per board on the first successful migration write. public enum BoardLoader: Sendable { /// Schema version this app understands; anything higher fails fast @@ -281,7 +285,6 @@ public enum BoardLoader: Sendable { // and the key's removal are the store's, through the Writer. if card.isDeleted { defects.append(.legacyTombstone(LegacyTombstone( - kind: .card, laneID: ItemID(rawValue: laneName), cardID: ItemID(rawValue: cardName), title: card.title.value @@ -292,14 +295,14 @@ public enum BoardLoader: Sendable { cards.append(card) } + // **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md + // § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads + // live with the key ignored — no migration machinery, no key-strip write, no notice + // (backward compatibility deliberately not funded …; the key is inert, preserved verbatim + // like any unhandled key, logged)". A warning, exactly like the board-level key: this was + // ignored, it is staying exactly where it is, there is nothing to do. if !laneDocument.deleted.isMissing { - defects.append(.legacyTombstone(LegacyTombstone( - kind: .lane, - laneID: ItemID(rawValue: laneName), - cardID: nil, - title: laneDocument.title.value - ))) - logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed") + warn(.laneLevelDeletedIgnored(path: laneName)) } walkedLanes.append(WalkedLane( @@ -312,30 +315,86 @@ public enum BoardLoader: Sendable { } var trash: [Card] = [] + var trashedLanes: [TrashedLane] = [] + /// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to + /// the identity rule, whatever the snapshot splits it into. + var trashEntries: [(id: ItemID, title: String?, order: Double)] = [] var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:] - for cardURL in trashCandidates(in: boardRoot) { - let cardName = cardURL.lastPathComponent - let cardRelPath = trashFolderName + "/" + cardName - guard isUUIDShaped(cardName) else { - warn(.nonUUIDFolderIgnored(path: cardRelPath)) + for entryURL in trashCandidates(in: boardRoot) { + let entryName = entryURL.lastPathComponent + let entryRelPath = trashFolderName + "/" + entryName + guard isUUIDShaped(entryName) else { + warn(.nonUUIDFolderIgnored(path: entryRelPath)) continue } - guard hasIndex(cardURL) else { - warn(.missingIndex(path: cardRelPath)) + guard hasIndex(entryURL) else { + warn(.missingIndex(path: entryRelPath)) continue } - let entry = try parseCard(at: cardURL, path: cardRelPath) - noteCoercions(in: entry.document, at: cardRelPath + "/" + indexFileName) + // The two kinds are validated identically — `schema` and `order` are required of both + // (`IntegrityRules.requiresOrder`) — so the strict parse happens once, before the + // discriminator, and a malformed entry fails fast whichever kind it turns out to be. + let entryPath = entryRelPath + "/" + indexFileName + let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath) + let schema = try validatedSchema(in: document, path: entryPath) + let order = try validatedOrder(in: document, path: entryPath) + noteCoercions(in: document, at: entryPath) + // **The trash's discriminator, applied where the flat container needs it** // (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted // outright, and only an unrecognized value or no key at all falls through to shape. - // Reading the shape half is one directory listing, and only when the value did not - // answer — see `looksLikeALaneFolder(_:)`. - trashKinds[entry.id] = IntegrityRules.trashKind( - kindValue: entry.document.kind.value, - hasIdentityShapedChildIndex: looksLikeALaneFolder(cardURL) + // + // The children are listed at most once per entry and only where an answer needs them — + // the shape fallback asks when `kind` did not, the lane arm asks for the count — so a + // board of trashed cards written by this app pays for no directory reads at all. + var listed: [URL]? + func children() -> [URL] { + if let listed { return listed } + let found = identityShapedChildren(of: entryURL) + listed = found + return found + } + let kind = IntegrityRules.trashKind( + kindValue: document.kind.value, + hasIdentityShapedChildIndex: !children().isEmpty ) - trash.append(entry) + let id = ItemID(rawValue: entryName) + trashKinds[id] = kind + trashEntries.append((id: id, title: document.title.value, order: order)) + + switch kind { + case .lane: + // **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit + // showing its title and held-card count). The count is the same listing the shape + // fallback asks for, so a `kind: lane` entry pays for exactly one directory read and + // a kindless one pays for none extra. + trashedLanes.append(TrashedLane( + id: id, + schema: schema, + title: document.title, + order: order, + heldCards: children().count, + document: document + )) + case .card, .board: + // `kind: board` never reaches here as itself — `trashKind` treats it as unrecognized + // and answers by shape — so this arm is the card answer and nothing else. + trash.append(Card( + id: id, + schema: schema, + title: document.title, + created: document.created, + modified: document.modified, + modifiedBy: document.modifiedBy, + deleted: document.deleted, + background: document.background, + icon: document.icon, + iconColor: document.iconColor, + order: order, + attachments: attachmentNames(in: entryURL), + document: document + )) + } } // **The board-wide identity dedupe** (01-storage-format.md § Fractal layout ▸ Rules: @@ -348,10 +407,15 @@ public enum BoardLoader: Sendable { // dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it. let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name) let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }) + let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue }) + // The trash's own display order, **both kinds at once** — the column interleaves them by rank + // (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so + // the two kinds are merged before the rule sees them rather than after. + let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue }) let verdict = dedupeIdentities( inBoardAt: boardRoot, lanes: orderedLanes, - trash: orderedTrash, + trash: orderedTrashEntries, historyRanker: historyRanker ) @@ -386,6 +450,7 @@ public enum BoardLoader: Sendable { template: boardDocument.value(for: templateKey), lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) }, trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) }, + trashedLanes: orderedTrashedLanes.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) }, document: boardDocument ) @@ -430,7 +495,7 @@ public enum BoardLoader: Sendable { private static func dedupeIdentities( inBoardAt root: URL, lanes: [WalkedLane], - trash: [Card], + trash: [(id: ItemID, title: String?, order: Double)], historyRanker: IdentityHistoryRanker? ) -> IntegrityRules.DedupeVerdict { typealias Container = IntegrityRules.IdentityOccurrence.Container @@ -458,7 +523,7 @@ public enum BoardLoader: Sendable { path: trashFolderName + "/" + entry.id.rawValue, name: entry.id.rawValue, container: .trashed, - title: entry.title.value + title: entry.title )) } @@ -559,26 +624,26 @@ public enum BoardLoader: Sendable { } } - /// Whether a folder in `.trash/` has the *shape* of a lane — at least one identity-shaped child - /// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with - /// their own `index.md` → lane … else card"). + /// A folder's **identity-shaped children holding their own `index.md`** — the cards a trash + /// entry would render as, which is two answers in one listing (01-storage-format.md § Deletion: + /// "UUID-shaped children with their own `index.md` → lane … else card"; 03-board-ui.md § Trash: + /// the row's held-card count). /// - /// Only reached when `kind` did not answer (`IntegrityRules.trashKind`'s `@autoclosure`), and - /// deliberately not a parse: this asks what the folder *looks like*, not whether anything inside - /// it would load. A trashed lane's cards are never enumerated as levels — the walk stops at a - /// trash entry exactly as it stops at a card under a lane. - private static func looksLikeALaneFolder(_ folder: URL) -> Bool { + /// Deliberately not a parse: this asks what the folder *holds*, not whether anything inside it + /// would load. A trashed lane's cards are never enumerated as levels — the walk stops at a trash + /// entry exactly as it stops at a card under a lane — so the count is a fact about the freight + /// and the entry stays opaque. + private static func identityShapedChildren(of folder: URL) -> [URL] { let children = (try? directoryCandidates(in: folder)) ?? [] - return children.contains { isUUIDShaped($0.lastPathComponent) && hasIndex($0) } + return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) } } - /// One card folder read into a `Card` — **the card parse, shared by both containers**. + /// One card folder read into a `Card` — the lane walk's card parse. /// - /// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this - /// function is what makes that literally true rather than a claim two code paths have to keep - /// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the - /// same verbatim document. Its callers keep what genuinely differs by container — the - /// lane-keyed loose-file and legacy-tombstone channels — outside it. + /// The trash's own walk reads its entries inline instead, because the container is flat and its + /// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict + /// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a + /// function that has already decided what it is reading. /// /// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`. /// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has. @@ -605,7 +670,7 @@ public enum BoardLoader: Sendable { ) } - /// The candidate card folders inside `/.trash/`, or `[]` when there is no trash. + /// The candidate entry folders inside `/.trash/`, or `[]` when there is no trash. /// /// **Absent is empty, not an error** — the container is minted by the first delete, so most /// boards never have one, and a board without a trash is a board with an empty trash. @@ -620,12 +685,10 @@ public enum BoardLoader: Sendable { /// the "window measured in one reload, not a standing state" the ruling accepts. /// /// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in - /// folder-name order — so the trash gets the same stray tolerance every other container gets, - /// including the one that matters most here: a **lane-shaped nesting** inside `.trash` (a - /// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are - /// never enumerated, because the walk stops at a card exactly as it does under a lane; the - /// outer folder either parses as a card (it has an `index.md`) or is skipped as - /// `.missingIndex`, and either way nothing below it renders. + /// folder-name order — so the trash gets the same stray tolerance every other container gets. + /// **Nothing below an entry is ever enumerated as a level**: the walk stops at a trash entry + /// exactly as it stops at a card under a lane, so a lane-shaped folder here reads as one opaque + /// entry (a trashed lane, by `kind` or by shape) and its cards are counted rather than walked. private static func trashCandidates(in boardRoot: URL) -> [URL] { let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true) guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { @@ -945,10 +1008,12 @@ public struct LoadResult: Sendable { /// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the /// `kind` value trusted outright, falling through to shape only when it does not answer. /// - /// A *reading*, not a rendering: `BoardModel.trash` parses every entry through the one card - /// parse (a trashed card is "an ordinary card in a special place"), and the container is flat, - /// so this is where the answer to "which of these was a lane?" lives until the lanes-in-trash - /// surface consumes it. Keyed by identity, so it survives the display sort. + /// **The reading the snapshot's own split is made of**: `BoardModel.trash` holds the entries + /// this called a card and `BoardModel.trashedLanes` the ones it called a lane. It is carried out + /// of the load as well because it is the *verdict* rather than its consequence — a suite pins + /// the discriminator directly, and a consumer asking "what did this entry read as" gets the + /// answer without inferring it from which array the entry landed in. Keyed by identity, so it + /// survives the display sort. public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:] /// The cards this walk found holding loose files — a **view over `defects`**, under the name it @@ -959,10 +1024,12 @@ public struct LoadResult: Sendable { defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } } } - /// The legacy `deleted:` keys this walk found — the retired tombstone model's migration input, - /// as a **view over `defects`** (01-storage-format.md § Deletion). + /// The legacy `deleted:` keys this walk found on **cards** — the retired tombstone model's + /// surviving migration input, as a **view over `defects`** (01-storage-format.md § Deletion). /// - /// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane. + /// A lane's key is not here and never will be: it is inert, tolerated, and reported as a + /// `LoadWarning.laneLevelDeletedIgnored` (lane clause re-ruled 2026-07-29). Order is the walk's, + /// lane by lane. public var legacyTombstones: [LegacyTombstone] { defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } } } @@ -1018,6 +1085,16 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { /// (01-storage-format.md § Deletion) — ignored, never tombstones the board. case boardLevelDeletedIgnored + /// A **lane** carrying a legacy `deleted:` key (01-storage-format.md § Deletion, lane clause + /// re-ruled 2026-07-29): the lane loads live and the key is ignored — "no migration machinery, + /// no key-strip write, no notice", preserved verbatim like any unhandled key. + /// + /// Its home is here rather than in the defect stream because that is exactly the tolerate tier's + /// verdict on it: this was ignored, it is staying exactly where it is, there is nothing to do. + /// A *card*'s key is still work and still a `LegacyTombstone` defect — the two halves of the old + /// migration parted company with the ruling. `path` is relative to the board root. + case laneLevelDeletedIgnored(path: String) + /// A folder whose name is a **case-spelled twin** of another occurrence of the same identity — /// one item typed two ways (01-storage-format.md § Fractal layout ▸ Rules): a spelling some *live* /// occurrence carries wins over one only trash ghosts carry (the container preference, stated @@ -1039,6 +1116,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { "\(path): folder name is not UUID-shaped, ignored as a stray" case .boardLevelDeletedIgnored: "index.md: board-level 'deleted' key is meaningless, ignored" + case let .laneLevelDeletedIgnored(path): + "\(path): lane-level 'deleted' key is inert, ignored — the lane loads live" case let .caseTwinIgnored(path, winner): "\(path): case-spelled twin of \(winner), ignored as a spelling artifact" } diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index 0f9380e..ff4834d 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -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 /// `/.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 + /// `/.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: `/.trash//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 + + /// 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 +} diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index c6c73c3..c982ec7 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -1046,9 +1046,9 @@ public enum BoardWriter: Sendable { /// The sequence, which is the contract: /// /// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped - /// *under* a UUID-shaped parent). This is what makes "lanes are never trashed" structural - /// rather than a policy the caller has to remember — a lane, a board root and a stray are - /// all refused here, and lane deletion has its own call (`removeLane`). + /// *under* a UUID-shaped parent). A board root and a stray are refused here, and a lane takes + /// the sibling door (`deleteLaneToTrash`) — same move, different guard and a different `kind` + /// to stamp, which is exactly the pair `.trash/`'s flat container needs told apart. /// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) — the move rewrites it /// at the destination, so a file that cannot be round-tripped refuses *before* the folder /// travels. `moveItem`'s discover-before-you-write rule, for its reason. @@ -1088,9 +1088,48 @@ public enum BoardWriter: Sendable { inBoard boardRoot: URL, order: Double ) throws(BoardWriteError) -> ItemID { - try moveCardIntoTrash( + try moveIntoTrash( at: cardFolder, inBoard: boardRoot, + kind: .card, + order: order, + operation: .delete(title: nil), + removingLegacyKey: false + ) + } + + /// **Deleting a lane: the same physical move into `/.trash/`** (01-storage-format.md + /// § Deletion, lanes joined 2026-07-29 — "retiring the design's sole destructive delete"; + /// 03-board-ui.md § Trash: "deleting a lane moves its folder — subtree intact — into `.trash/`, + /// exactly as a card moves"). + /// + /// `deleteCardToTrash`'s body with two differences, and they are the whole of what a lane is: + /// + /// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which + /// refuses a card, a board root, a stray, and notably a folder already in `.trash/`. + /// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder + /// that is by then *inside* `.trash/`, where position cannot answer and shape would answer + /// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to + /// a card (01's own "honest limit"). The caller knows what it moved, so it says so — which is + /// also the backfill the ruling asks of this write ("`kind: lane` … backfilled on touch when + /// absent … the trash move's rank mint included"). + /// + /// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its + /// cards, their `attachments/` and every stray arrive byte-identical and come back with it on + /// restore — which is what makes the inverse an ordinary move rather than a replay of captured + /// bytes (13-native-undo.md ▸ Interaction with the trash). + /// + /// - Returns: the lane's identity, unchanged. + @discardableResult + public static func deleteLaneToTrash( + at laneFolder: URL, + inBoard boardRoot: URL, + order: Double + ) throws(BoardWriteError) -> ItemID { + try moveIntoTrash( + at: laneFolder, + inBoard: boardRoot, + kind: .lane, order: order, operation: .delete(title: nil), removingLegacyKey: false @@ -1117,29 +1156,39 @@ public enum BoardWriter: Sendable { inBoard boardRoot: URL, order: Double ) throws(BoardWriteError) -> ItemID { - try moveCardIntoTrash( + try moveIntoTrash( at: cardFolder, inBoard: boardRoot, + kind: .card, order: order, operation: .migrateTombstone(title: nil), removingLegacyKey: true ) } - /// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` — see the former for the - /// sequence and the latter for what `removingLegacyKey` adds. - private static func moveCardIntoTrash( - at cardFolder: URL, + /// The shared body of the three moves into `.trash/` — `deleteCardToTrash`, `deleteLaneToTrash` + /// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's + /// `kind` is doing here, and the third for what `removingLegacyKey` adds. + /// + /// **`kind` is both the guard and the stamp**: it picks which shape check the item must pass on + /// the way out, and it is handed to `updateIndex` so the arrived entry's `kind` is written from + /// what the caller *moved* rather than guessed from what the flat container makes it look like. + private static func moveIntoTrash( + at itemFolder: URL, inBoard boardRoot: URL, + kind: IntegrityRules.ObjectKind, order: Double, operation initialOperation: WriteOperation, removingLegacyKey: Bool ) throws(BoardWriteError) -> ItemID { var operation = initialOperation - try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) + try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation) try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation) - try checkIsCardFolder(cardFolder, operation: operation) - operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation) + switch kind { + case .lane: try checkIsLaneFolder(itemFolder, operation: operation) + case .card, .board: try checkIsCardFolder(itemFolder, operation: operation) + } + operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation) let trash = trashFolder(inBoard: boardRoot) do { @@ -1152,23 +1201,23 @@ public enum BoardWriter: Sendable { ) } - let name = cardFolder.lastPathComponent + let name = itemFolder.lastPathComponent let arrived = trash.appendingPathComponent(name, isDirectory: true) do { - try FileManager.default.moveItem(at: cardFolder, to: arrived) + try FileManager.default.moveItem(at: itemFolder, to: arrived) } catch { throw BoardWriteError( operation: operation, - path: cardFolder.path, + path: itemFolder.path, reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)") ) } // A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the // receipt is the move pair — and it reads correctly from either end: the board side sees an - // absence where the card was, the shown-trash side sees an arrival where it went. - EchoLedger.current?.recordMove(from: cardFolder, to: arrived) + // absence where the item was, the shown-trash side sees an arrival where it went. + EchoLedger.current?.recordMove(from: itemFolder, to: arrived) - try updateIndex(inItemFolder: arrived, operation: operation) { document in + try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(order)) if removingLegacyKey { document.remove(FrontmatterKeys.deleted) @@ -1185,134 +1234,80 @@ public enum BoardWriter: Sendable { return ItemID(rawValue: name) } - /// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns - /// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying - /// `deleted:` returns live with the key removed and a notice — resurrection is the safe - /// direction, nothing is destroyed by migration"). - /// - /// **Nothing moves and nothing is removed.** There is no lane trash to move it into, and - /// destroying a lane the user may never have meant to lose is the one direction migration is - /// forbidden to take. Its cards come back with it; any of *them* carrying their own - /// `deleted:` key migrate on their own account, as ordinary tombstoned cards. - /// - /// The lane's rank is untouched, so it returns to its own position among its siblings — - /// position-perfect for the same reason the retired Put Back was: the folder never moved. - /// - /// Refuses anything that is not a lane (`checkIsLaneFolder`): a card's migration is a move and - /// has its own call, and pointing this at one would strip the key while leaving the card - /// exactly where the tombstone had hidden it. - public static func migrateTombstonedLane(at laneFolder: URL) throws(BoardWriteError) { - let operation = WriteOperation.migrateTombstone(title: nil) - try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation) - try checkIsLaneFolder(laneFolder, operation: operation) - - try updateIndex(inItemFolder: laneFolder, operation: operation) { document in - document.remove(FrontmatterKeys.deleted) - } - EchoLedger.current?.markHeal(at: laneFolder.appendingPathComponent(BoardLoader.indexFileName)) - } - - /// **Deleting a lane is physical** — the folder and everything under it are removed - /// (01-storage-format.md § Deletion; 03-board-ui.md § Trash: "Cards only. Lanes are never - /// trashed"). There is no lane trash and no tombstone; the recovery net is native undo - /// in-session and git history on git boards. - /// - /// **Capture before you remove.** Undo restores a lane by replaying its bytes, which only - /// works if someone is holding them — `captureSubtree(at:operation:)` is that primitive, and - /// the pairing is the caller's (the undo step captures, then calls this). Deliberately not - /// folded in here: a purge that always paid for a full tree read would make Empty Trash on a - /// large board slow for a recovery nothing was going to use. - /// - /// **A folder that is already gone is success**, `purgeItem`'s rule and for its reason: a - /// Finder deletion converges on exactly the end state this produces, so there is nothing left - /// to distinguish. - /// - /// Refuses anything that is not a lane (`checkIsLaneFolder`) — a board root, a card, a stray, - /// and notably a *trash card*, whose parent is `.trash/` rather than the board root. - public static func removeLane(at laneFolder: URL) throws(BoardWriteError) { - let operation = WriteOperation.delete(title: nil) - guard FileManager.default.fileExists(atPath: laneFolder.path) else { return } - try checkIsLaneFolder(laneFolder, operation: operation) - - do { - try FileManager.default.removeItem(at: laneFolder) - } catch { - throw BoardWriteError( - operation: operation, - path: laneFolder.path, - reason: .io(message: "could not remove folder: \(error.localizedDescription)") - ) - } - // The absence marker — and it takes the lane's cards' receipts with it, which is what makes - // the digest's implied-events rule and the ledger agree that this was one event. - EchoLedger.current?.recordDeletion(at: laneFolder) - } - - /// Permanently removes one card from the trash — the trash's own **Delete** - /// (03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent"). + /// Permanently removes one **entry** from the trash — the trash's own **Delete** + /// (03-board-ui.md § Trash: "on a trash selection, Delete (⌫/⌘⌫) is permanent … in the trash it + /// removes the folder"). /// /// `purgeItem` with the container checked: the folder must actually sit in this board's - /// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. `purgeItem` + /// `.trash/`, so a mis-aimed permanent delete cannot reach a live item. `purgeItem` /// (unconstrained) is for the create-undo's own removal, not this call. /// + /// **A trashed lane purges whole, freight and all** (lanes joined the trash 2026-07-29): the + /// removal is recursive, so "permanent delete … walks lane subtrees" needs no walk of its own + /// here — what the confirmation must *count* before this runs is `TrashModel`'s job, off the + /// snapshot. Which kind the entry is therefore never comes up: the container and the shape are + /// the whole check, exactly as they were when only cards lived here. + /// /// An already-gone folder is success, `purgeItem`'s rule. - public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) { + public static func purgeTrashEntry(at entryFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) { let operation = WriteOperation.purge(title: nil) - guard FileManager.default.fileExists(atPath: cardFolder.path) else { return } + guard FileManager.default.fileExists(atPath: entryFolder.path) else { return } - try checkIsUUIDShaped(cardFolder, operation: operation) - guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else { + try checkIsUUIDShaped(entryFolder, operation: operation) + guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else { throw BoardWriteError( operation: operation, - path: cardFolder.path, + path: entryFolder.path, reason: .unreadable(message: "folder is not in this board's trash") ) } do { - try FileManager.default.removeItem(at: cardFolder) + try FileManager.default.removeItem(at: entryFolder) } catch { throw BoardWriteError( operation: operation, - path: cardFolder.path, + path: entryFolder.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } - EchoLedger.current?.recordDeletion(at: cardFolder) + EchoLedger.current?.recordDeletion(at: entryFolder) } - /// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every card in - /// `/.trash/`. Returns what it removed, in folder-name order. + /// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every entry in + /// `/.trash/` — cards and trashed lanes alike, **lane subtrees walked** by the + /// recursive removal. Returns what it removed, in folder-name order. /// - /// **The card folders, not the container.** The design says it "purges the whole `.trash/`", - /// and the cards are the whole of it in every board the app produces — but the container is a + /// **The entry folders, not the container.** The design says it "purges the whole `.trash/`", + /// and the entries are the whole of it in every board the app produces — but the container is a /// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim, /// never rendered") does not stop applying because the folder is the app's. Removing only what - /// the loader recognizes as a card keeps the count honest (the confirmation names cards) and - /// keeps this command from being the one place in the app that destroys a file nobody ever - /// saw. The emptied container is left standing; the next delete would only recreate it. + /// the loader recognizes as an entry keeps the count honest (the confirmation names cards and + /// lane freight) and keeps this command from being the one place in the app that destroys a file + /// nobody ever saw. The emptied container is left standing; the next delete would only recreate + /// it. /// /// **Search-independent**, by construction: this walks the folder, never a filtered view. /// - /// Removal is per card, in order, and a failure stops the batch and throws — everything + /// Removal is per entry, in order, and a failure stops the batch and throws — everything /// already removed stays removed, `importAttachments`' rule. A board with no trash at all /// removes nothing and returns `[]`. @discardableResult public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] { let operation = WriteOperation.purge(title: nil) var purged: [ItemID] = [] - for card in childCandidates(of: trashFolder(inBoard: boardRoot)) { + for entry in childCandidates(of: trashFolder(inBoard: boardRoot)) { do { - try FileManager.default.removeItem(at: card) + try FileManager.default.removeItem(at: entry) } catch { throw BoardWriteError( operation: operation, - path: card.path, + path: entry.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } - EchoLedger.current?.recordDeletion(at: card) - purged.append(ItemID(rawValue: card.lastPathComponent)) + EchoLedger.current?.recordDeletion(at: entry) + purged.append(ItemID(rawValue: entry.lastPathComponent)) } return purged } @@ -1336,206 +1331,6 @@ public enum BoardWriter: Sendable { } } - // MARK: - Subtree capture and replay - - /// Every byte of a folder tree, in memory — the capture half of a physical removal's undo - /// (13-native-undo.md ▸ Rules; 03-board-ui.md § Trash, "the net is undo, not the trash"). - /// - /// **`readIndexText(ofItem:operation:)` widened from one file to a whole tree**, and for the - /// same reason: the inverse of a physical removal is a recreation, and the only way ⌘Z can put - /// a lane back *with its identity and its cards* is for the step to be holding what was there. - /// One `index.md` is enough to replay a create; a lane delete takes its nested cards, their - /// `attachments/`, and every stray with it. - /// - /// **Everything, verbatim.** Unlike every other walk in this file, this one does *not* apply - /// the loader's stray exclusions: hidden files (`.DS_Store`, a dot-file a hand-editor left), - /// non-UUID folders, files with no meaning to the schema — all captured, because the promise is - /// that undo restores what was removed rather than what the app would have rendered. Bytes are - /// carried as `Data` and never decoded, so encoding, line endings and BOMs are non-questions; - /// POSIX permissions ride along per entry. - /// - /// **Symlinks are captured as links, never followed** (01-storage-format.md § Fractal layout ▸ - /// Rules) — the destination string is recorded and recreated as a link, so a cyclic or - /// cross-volume link is neither traversed here nor materialized as a copy of its target. - /// - /// Entries are sorted by name at every level, so a capture is a deterministic value: two - /// captures of one unchanged tree are `==`, which is what makes a round-trip assertable. - /// - /// It reads the whole tree into memory, so it is for the sizes a board actually has (a lane - /// and its cards) — not a general-purpose archiver. - public static func captureSubtree( - at folder: URL, - operation: WriteOperation - ) throws(BoardWriteError) -> SubtreeSnapshot { - try checkIsDirectory(folder, describedAs: "item folder", operation: operation) - - let entries: [URL] - do { - entries = try FileManager.default.contentsOfDirectory( - at: folder, - includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], - options: [] - ) - } catch { - throw BoardWriteError( - operation: operation, - path: folder.path, - reason: .unreadable(message: "could not list folder: \(error.localizedDescription)") - ) - } - - var captured: [SubtreeSnapshot.Entry] = [] - for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { - let name = entry.lastPathComponent - let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) - - if values?.isSymbolicLink == true { - guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: entry.path) else { - throw BoardWriteError( - operation: operation, - path: entry.path, - reason: .unreadable(message: "could not read symbolic link") - ) - } - captured.append(.symlink(name: name, destination: destination)) - } else if values?.isDirectory == true { - captured.append(.folder(try captureSubtree(at: entry, operation: operation))) - } else { - let contents: Data - do { - contents = try Data(contentsOf: entry) - } catch { - throw BoardWriteError( - operation: operation, - path: entry.path, - reason: .unreadable(message: "could not read file: \(error.localizedDescription)") - ) - } - captured.append(.file(name: name, contents: contents, permissions: posixPermissions(of: entry))) - } - } - - return SubtreeSnapshot( - name: folder.lastPathComponent, - permissions: posixPermissions(of: folder), - entries: captured - ) - } - - /// Puts a captured tree back, at `folder`, byte for byte — the replay half of - /// `captureSubtree(at:operation:)`, and `recreateItem`'s rules one level of nesting wider. - /// - /// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose - /// board root has since gone must fail rather than conjure a tree in mid-air. - /// - **It refuses to clobber**: anything at `folder` fails loudly rather than being written - /// over. Restoring on top of a folder someone recreated meanwhile would silently merge two - /// trees. - /// - **The bytes are written verbatim** — nothing is stamped, nothing is re-serialized, no - /// `index.md` is parsed. This replays; it does not edit. - /// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows, - /// `copyItem`'s rule — a half-restored lane is pure residue, since nothing was there. - /// - /// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed - /// passes the same URL, and the snapshot's name is carried for identification, not as an - /// instruction. - /// - /// Permissions are applied **after** a folder's children are written, so a captured read-only - /// directory does not lock out its own contents on the way back in. - public static func recreateSubtree( - at folder: URL, - from snapshot: SubtreeSnapshot, - operation: WriteOperation - ) throws(BoardWriteError) { - guard !FileManager.default.fileExists(atPath: folder.path) else { - throw BoardWriteError( - operation: operation, - path: folder.path, - reason: .io(message: "something already exists here") - ) - } - do throws(BoardWriteError) { - try materialize(snapshot, at: folder, operation: operation, intermediates: false) - } catch { - try? FileManager.default.removeItem(at: folder) - throw error - } - } - - /// `recreateSubtree`'s recursion, minus its clobber refusal and its cleanup — both belong to - /// the top-level call, which is the only one with a partial tree to remove. - private static func materialize( - _ snapshot: SubtreeSnapshot, - at folder: URL, - operation: WriteOperation, - intermediates: Bool - ) throws(BoardWriteError) { - do { - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: intermediates) - } catch { - throw BoardWriteError( - operation: operation, - path: folder.path, - reason: .io(message: "could not create folder: \(error.localizedDescription)") - ) - } - - for entry in snapshot.entries { - switch entry { - case let .file(name, contents, permissions): - let fileURL = folder.appendingPathComponent(name) - do { - try contents.write(to: fileURL) - // An undo restore recreates whole subtrees byte for byte; the bytes are already - // in hand, so every restored file gets its own receipt rather than only the - // `index.md` at the top. - EchoLedger.current?.recordWrite(at: fileURL, data: contents) - } catch { - throw BoardWriteError( - operation: operation, - path: fileURL.path, - reason: .io(message: "could not write file: \(error.localizedDescription)") - ) - } - setPosixPermissions(permissions, of: fileURL) - case let .folder(child): - try materialize( - child, - at: folder.appendingPathComponent(child.name, isDirectory: true), - operation: operation, - intermediates: false - ) - case let .symlink(name, destination): - let linkURL = folder.appendingPathComponent(name) - do { - try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: destination) - } catch { - throw BoardWriteError( - operation: operation, - path: linkURL.path, - reason: .io(message: "could not create symbolic link: \(error.localizedDescription)") - ) - } - } - } - - // After the children, so a captured read-only folder cannot lock out its own contents. - setPosixPermissions(snapshot.permissions, of: folder) - } - - /// An item's POSIX permission bits, or `nil` when they cannot be read — `attributesOfItem` - /// rather than a `URLResourceValues` key because it is `lstat`-based, so a symlink's own - /// attributes are never its target's. Best-effort by design: permissions decorate a capture, - /// and a tree that restores with default modes is a far better outcome than one that refuses - /// to restore. - private static func posixPermissions(of url: URL) -> Int? { - (try? FileManager.default.attributesOfItem(atPath: url.path))?[.posixPermissions] as? Int - } - - private static func setPosixPermissions(_ permissions: Int?, of url: URL) { - guard let permissions else { return } - try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) - } - /// Physical removal — the create-undo's own primitive (13-native-undo.md: "create → remove the /// created folder"): deletes the folder tree from disk. Irreversible, and distinct from the /// ordinary delete, which is a *move* into `.trash/` — this call does **not** require the item to @@ -2569,46 +2364,6 @@ public struct MoveResult: Sendable, Equatable { } } -// MARK: - Subtree vocabulary - -/// A folder tree captured whole, in memory — what `BoardWriter.captureSubtree(at:operation:)` -/// produces and `recreateSubtree(at:from:operation:)` replays. -/// -/// **A value, deliberately**: `Sendable` so an undo step can carry it across isolation domains, -/// and `Equatable` so a capture → recreate → capture round trip is one assertion. Equality is -/// exact — names, bytes, link destinations, permissions, and order — which holds because a capture -/// sorts every level by name. -/// -/// It describes bytes, never meaning. There is no `index.md` here, no frontmatter, no identity: -/// a lane, a card, an `attachments/` folder and a hand-made `notes/` are all just folders with -/// entries, which is exactly what a byte-faithful restore needs and all it may assume. -public struct SubtreeSnapshot: Sendable, Equatable { - /// The captured folder's own name. Carried for identification and for nested folders' paths; - /// the top-level replay takes its path from the caller instead (see `recreateSubtree`). - public let name: String - - /// POSIX permission bits as captured, `nil` when unreadable — applied best-effort on replay. - public let permissions: Int? - - /// The folder's direct children, sorted by name. - public let entries: [Entry] - - public init(name: String, permissions: Int?, entries: [Entry]) { - self.name = name - self.permissions = permissions - self.entries = entries - } - - /// One captured child. Symlinks are their own case rather than a file holding their target's - /// bytes — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules), so - /// a capture records the link and a replay recreates the link. - public enum Entry: Sendable, Equatable { - case file(name: String, contents: Data, permissions: Int?) - case folder(SubtreeSnapshot) - case symlink(name: String, destination: String) - } -} - /// How a copy stamps the files it materializes — the one axis on which the two kinds of copy /// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter). public enum CopyStamps: Sendable { @@ -2665,8 +2420,9 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for /// an untitled item. case paste(title: String?) - /// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed - /// outright (`removeLane`). The word the user pressed, whichever staging it took. + /// ⌫ / ⌘⌫ — a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`. + /// One word for one gesture: since lanes rejoined the trash (2026-07-29) both are the same + /// physical move, and neither is destructive. /// /// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed /// restore says the app couldn't *move* the card — which is exactly what it couldn't do @@ -2674,15 +2430,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case delete(title: String?) case purge(title: String?) - /// A legacy `deleted:` key being migrated away — a card relocating into `.trash/` with the key - /// removed, or a lane getting the key stripped and returning live (01-storage-format.md - /// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy"). + /// A legacy `deleted:` key being migrated away — a **card** relocating into `.trash/` with the + /// key removed (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half + /// retired 2026-07-29 with the lane trash, so this operation has one performer left). /// /// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing /// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its /// own, on a board an older version wrote, and a banner telling the user the app "couldn't - /// delete 'Fix login'" would name a gesture they never made — on a lane, one whose outcome is - /// the opposite of deletion. + /// delete 'Fix login'" would name a gesture they never made. case migrateTombstone(title: String?) case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md) case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane) diff --git a/Kanban/Storage/IntegrityRules.swift b/Kanban/Storage/IntegrityRules.swift index fe56d20..194b78e 100644 --- a/Kanban/Storage/IntegrityRules.swift +++ b/Kanban/Storage/IntegrityRules.swift @@ -502,7 +502,8 @@ public enum IntegrityRules: Sendable { public enum Defect: Sendable, Equatable { /// A card holding files that belong in its `attachments/` (the loose-file carve-out). case looseCardFiles(LooseCardFiles) - /// An item carrying a legacy `deleted:` key (the retired tombstone model's migration input). + /// A **card** carrying a legacy `deleted:` key (the retired tombstone model's surviving + /// migration input; a lane's key is inert and tolerated instead). case legacyTombstone(LegacyTombstone) /// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29). case claimedNameSquatted(ClaimedNameSquatter) @@ -567,7 +568,7 @@ public enum IntegrityRules: Sendable { case let .looseCardFiles(work): work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" } case let .legacyTombstone(work): - ["tombstone:\(work.laneID.rawValue)/\(work.cardID?.rawValue ?? "")"] + ["tombstone:\(work.laneID.rawValue)/\(work.cardID.rawValue)"] case let .claimedNameSquatted(work): // The node *kind* is part of the picture: a squatter replaced by a different kind // of squatter is a new defect, and a heal that failed on one has no claim to have @@ -846,35 +847,30 @@ public struct LooseCardFiles: Sendable, Equatable { } } -/// One item found carrying a legacy `deleted:` key — everything its migration and notice need, and -/// nothing more (01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, -/// never destroy"). +/// One **card** found carrying a legacy `deleted:` key — everything its migration and notice need, +/// and nothing more (01-storage-format.md § Deletion: "a card carrying `deleted:` is relocated into +/// `.trash/` (key removed)"). +/// +/// **Cards only, since 2026-07-29.** The rule used to have two halves, and the lane half is retired +/// wholesale: "a lane carrying `deleted:` simply loads live with the key ignored — no migration +/// machinery, no key-strip write, no notice". A lane's key is now the tolerate tier's business +/// (`LoadWarning.laneLevelDeletedIgnored`), which is why there is no `kind` here to switch on: the +/// one surviving act is a move, and a payload that could spell the other one would be a defect +/// nothing can heal. /// /// The path is carried as its identity components rather than as a URL — `LooseCardFiles`' -/// convention, for its reason. `title` is the item's as written, `nil` for an untitled one. +/// convention, for its reason. `title` is the card's as written, `nil` for an untitled one. public struct LegacyTombstone: Sendable, Equatable { - /// Which migration this item takes — the two are genuinely different acts, not one act at two - /// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays - /// exactly where it is (the key is stripped and it returns live). - public enum Kind: Sendable, Equatable { - case card - case lane - } - public let kind: Kind - - /// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the context - /// the relocation needs to find the folder at all. + /// The card's **containing** lane — the context the relocation needs to find the folder at all. public let laneID: ItemID - /// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload so - /// the common "which folder is this" question is one path join at every call site. - public let cardID: ItemID? + /// The card itself. + public let cardID: ItemID public let title: String? - public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) { - self.kind = kind + public init(laneID: ItemID, cardID: ItemID, title: String?) { self.laneID = laneID self.cardID = cardID self.title = title diff --git a/Kanban/UI/Board/TrashCommands.swift b/Kanban/UI/Board/TrashCommands.swift index 9393b39..74282dc 100644 --- a/Kanban/UI/Board/TrashCommands.swift +++ b/Kanban/UI/Board/TrashCommands.swift @@ -35,7 +35,7 @@ final class TrashConfirmations { /// reachable inside the trash now: the trash's own staged Delete, and Empty Trash (which names /// the whole container and re-derives its targets at the moment it runs). enum Action: Equatable { - case deleteTrashCards(Set) + case deleteTrashEntries(Set) case emptyTrash } } @@ -43,8 +43,8 @@ final class TrashConfirmations { /// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the /// trash side owes and the board side does not. /// - /// A board selection goes straight through: moving a card into the trash and deleting a lane are - /// both recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither + /// A board selection goes straight through: a card and a lane alike move into the trash, and the + /// move is recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither /// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where /// the loss is real": `purgeIsUnrecoverable` decides. /// @@ -71,7 +71,7 @@ final class TrashConfirmations { /// is what a board that keeps history does for every permanent delete (delete-never-forgets). func requestTrashDelete(of ids: Set, in store: BoardStore) { guard store.purgeIsUnrecoverable else { - store.deleteTrashCards(ids) + store.deleteTrashEntries(ids) return } guard let prompt = TrashModel.purgePrompt( @@ -79,7 +79,7 @@ final class TrashConfirmations { snapshot: store.snapshot, unrecoverable: true ) else { return } - pending = Pending(prompt: prompt, action: .deleteTrashCards(ids)) + pending = Pending(prompt: prompt, action: .deleteTrashEntries(ids)) } /// Raises Empty Trash…'s alert. **Always** — it guards bulk scope rather than per-item @@ -98,7 +98,7 @@ final class TrashConfirmations { guard let pending else { return } self.pending = nil switch pending.action { - case let .deleteTrashCards(ids): store.deleteTrashCards(ids) + case let .deleteTrashEntries(ids): store.deleteTrashEntries(ids) case .emptyTrash: store.emptyTrash() } } @@ -168,9 +168,10 @@ struct TrashCommands: View { extension BoardStore { /// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash… row) — - /// where "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: - /// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides - /// every trash card leaves the command enabled and its confirmation still names the true count). + /// where "non-empty" reads `.trash/` itself — **both kinds of entry**, and never the filtered + /// view (03-board-ui.md § Trash: "menu validation's non-empty reads `.trash/`, not the filtered + /// view", so a search that hides every trash row leaves the command enabled and its confirmation + /// still names the true count, lane freight included). /// /// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is /// invisible to every gesture". @@ -179,7 +180,7 @@ extension BoardStore { /// menu (`TrashModel`'s own reason for being a pure function of a snapshot). var canEmptyTrash: Bool { guard acceptsBoardMutations, transient.isTrashVisible else { return false } - return !snapshot.trash.isEmpty + return !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty } } diff --git a/KanbanTests/CardWindowFateTests.swift b/KanbanTests/CardWindowFateTests.swift index 8c30ec8..aff2d2d 100644 --- a/KanbanTests/CardWindowFateTests.swift +++ b/KanbanTests/CardWindowFateTests.swift @@ -82,6 +82,30 @@ struct CardWindowFateTests { #expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses) } + /// The lane's own delete is a *move* now (03 § Trash, re-ruled 2026-07-29), and the dismissal + /// still falls out of the same absence rather than needing a rule: a trashed lane is an opaque + /// unit, so its cards are not in the snapshot at all — not under a lane, not among the trash's + /// cards — and the walk simply does not find them. + @Test("Trashing a card's lane dismisses its window too — the freight leaves the snapshot with it") + func aLaneTrashedDismissesItsCards() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try BoardWriter.deleteLaneToTrash( + at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024 + ) + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + #expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1]) + #expect(!snapshot.trash.contains { $0.id.rawValue == Ident.card1 }) + #expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses) + // And the same absence is what ejects it from every item-referencing set — selection, the + // pending cut, drag membership (02-architecture.md § Live-reload resilience). + #expect(!ItemContainer.board.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1))) + #expect(!ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1))) + #expect(ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.lane1)), + "the lane row itself is in the trash's universe") + } + @Test("A card that is not in this board's snapshot dismisses — the cross-board move") func anAbsentCardDismisses() throws { let fixture = try makeBoard() diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift index 266112b..7d05dbf 100644 --- a/KanbanTests/FixtureBoardTests.swift +++ b/KanbanTests/FixtureBoardTests.swift @@ -269,7 +269,9 @@ struct FixtureTombstonesTests { let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005" let result = try loadFixture("Valid/tombstones.kanban") - #expect(result.warnings.isEmpty) + // The lane's key is the tolerate tier's since 2026-07-29 — ignored, logged, and the lane + // loads live (01 § Deletion, lane clause). + #expect(result.warnings == [.laneLevelDeletedIgnored(path: laneDead)]) #expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead]) let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive }) diff --git a/KanbanTests/IntegrityRulesTests.swift b/KanbanTests/IntegrityRulesTests.swift index c169746..2d46063 100644 --- a/KanbanTests/IntegrityRulesTests.swift +++ b/KanbanTests/IntegrityRulesTests.swift @@ -292,7 +292,7 @@ struct IntegrityDefectTests { LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"]) ).healClass == .looseCardFiles) #expect(IntegrityRules.Defect.legacyTombstone( - LegacyTombstone(kind: .lane, laneID: lane, cardID: nil, title: nil) + LegacyTombstone(laneID: lane, cardID: card, title: nil) ).healClass == .legacyTombstone) #expect(IntegrityRules.Defect.claimedNameSquatted( ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory) @@ -318,10 +318,10 @@ struct IntegrityDefectTests { @Test("Signatures identify the work, not its display") func signaturesIdentifyTheWork() { let one = IntegrityRules.Defect.legacyTombstone( - LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "Before") + LegacyTombstone(laneID: lane, cardID: card, title: "Before") ) let two = IntegrityRules.Defect.legacyTombstone( - LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "After") + LegacyTombstone(laneID: lane, cardID: card, title: "After") ) #expect(one.signatures == two.signatures) } @@ -334,7 +334,7 @@ struct IntegrityDefectTests { LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"]) ) let tombstone = IntegrityRules.Defect.legacyTombstone( - LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: nil) + LegacyTombstone(laneID: lane, cardID: card, title: nil) ) let squatter = IntegrityRules.Defect.claimedNameSquatted( ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory) diff --git a/KanbanTests/TrashModelTests.swift b/KanbanTests/TrashModelTests.swift index e1154f7..53b2d80 100644 --- a/KanbanTests/TrashModelTests.swift +++ b/KanbanTests/TrashModelTests.swift @@ -292,6 +292,125 @@ struct TrashPhrasingTests { } } +// MARK: - Lane freight + +/// "Confirms name the freight honestly — a trashed lane's alert counts its cards" (03 § Trash, +/// re-ruled 2026-07-29). The count is `TrashedLane.heldCards`, which the loader took at load +/// precisely because the subtree it counts is deliberately not in the snapshot. +@MainActor +@Suite("TrashModel ▸ lane freight in the confirmations") +struct TrashFreightTests { + + /// A board whose trash holds one card and one lane carrying `held` cards. + private func makeFreightBoard(held: Int) throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(More.laneA, card(order: "1024", title: "Todo")) + try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "A card")) + try fixture.item(".trash/\(Ident.lane2)", "---\nschema: 1\ntitle: Doing\norder: 512\nkind: lane\n---\n") + for index in 0 ..< held { + try fixture.item( + ".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())", + card(order: "\((index + 1) * 1024)", title: "Freight \(index)") + ) + } + return fixture + } + + /// 03's own phrasing, verbatim: "Permanently delete lane 'Doing' and its 5 cards". + @Test("A sole trashed lane names itself and its freight") + func soleLaneNamesItsFreight() throws { + let fixture = try makeFreightBoard(held: 5) + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + let prompt = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true + )) + #expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 5 cards?") + #expect(prompt.message == "This can\u{2019}t be undone.") + } + + /// An empty lane has no freight clause to add — "2 lanes containing 0 cards" would say less + /// than naming the lane. + @Test("An empty trashed lane names only itself") + func emptyLaneNamesOnlyItself() throws { + let fixture = try makeFreightBoard(held: 0) + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + let prompt = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true + )) + #expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D}?") + } + + @Test("A sole held card folds to the singular") + func oneHeldCardFoldsSingular() throws { + let fixture = try makeFreightBoard(held: 1) + defer { fixture.tearDown() } + + let prompt = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.lane2)], snapshot: try load(fixture), unrecoverable: true + )) + #expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 1 card?") + } + + /// 03's other example phrasing: "… 41 cards and 2 lanes containing 9 more cards". + @Test("Empty Trash counts both kinds and the lanes' freight") + func emptyTrashCountsFreight() throws { + let fixture = try makeFreightBoard(held: 5) + defer { fixture.tearDown() } + + let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true)) + #expect(prompt.title == "Permanently delete 1 card and 1 lane containing 5 more cards?") + } + + /// The subject builder as a pure value, so the phrasing rules are pinned without a board: + /// **"more" only where cards precede it**, and no clause for freight that is not there. + @Test("The aggregate subject folds every shape") + func theSubjectFolds() { + func subject(cards: Int, lanes: Int, held: Int) -> String { + TrashModel.subject(for: TrashModel.Freight(cards: cards, lanes: lanes, heldCards: held)) + } + #expect(subject(cards: 41, lanes: 0, held: 0) == "41 cards") + #expect(subject(cards: 1, lanes: 0, held: 0) == "1 card") + #expect(subject(cards: 41, lanes: 2, held: 9) == "41 cards and 2 lanes containing 9 more cards") + #expect(subject(cards: 0, lanes: 2, held: 9) == "2 lanes containing 9 cards", + "nothing precedes it, so nothing is 'more'") + #expect(subject(cards: 0, lanes: 1, held: 1) == "1 lane containing 1 card") + #expect(subject(cards: 3, lanes: 1, held: 0) == "3 cards and 1 lane") + } + + /// A trashed lane is deletable from the trash like any entry — the menu validation is by + /// container, and the kind never enters it. + @Test("Delete is enabled on a trash lane selection, and resolves to a purgeable path") + func laneRowsValidate() throws { + let fixture = try makeFreightBoard(held: 2) + defer { fixture.tearDown() } + let snapshot = try load(fixture) + let selection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.lane2)], container: .trash) + + #expect(TrashModel.canDelete(selection: selection, in: snapshot)) + #expect(ItemPath.resolve(selection.ids, in: .trash, snapshot: snapshot) + == [.trashLane(ItemID(rawValue: Ident.lane2))]) + } + + /// The column is one list: `resolve` hands back the trash's paths interleaved by rank, because + /// a batch's order is the column's order (03 § Trash). + @Test("Resolution interleaves the container's two kinds by rank") + func resolutionInterleaves() throws { + let fixture = try makeFreightBoard(held: 0) + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + // The lane sits at 512 and the card at 1024, so the lane row comes first. + #expect(ItemPath.resolve( + [ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot + ) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)]) + } +} + // MARK: - Menu validation @MainActor diff --git a/KanbanTests/TrashStorageTests.swift b/KanbanTests/TrashStorageTests.swift index 476558e..048c540 100644 --- a/KanbanTests/TrashStorageTests.swift +++ b/KanbanTests/TrashStorageTests.swift @@ -140,10 +140,12 @@ struct TrashContainerLoadTests { #expect(result.warnings.count == 2) } - /// "A lane-shaped nesting inside `.trash` is a stray": the walk stops at a card in the trash - /// exactly as it does under a lane, so a UUID-shaped folder *inside* a trash card is content, - /// never a level — invisible, preserved, and not warned about (a card's subfolders never are). - @Test("A lane-shaped nesting inside .trash renders as one card, its children invisible") + /// The walk stops at a trash entry exactly as it stops at a card under a lane, so a UUID-shaped + /// folder *inside* one is never a level. What that folder makes of its parent changed with the + /// lanes-in-trash ruling: a kindless entry holding identity-shaped children with their own + /// `index.md` reads as a **lane** by shape (01 § Deletion), one opaque row whose children are + /// counted rather than surfaced — invisible, preserved, and not warned about. + @Test("A lane-shaped nesting inside .trash reads as one opaque lane row, its children counted") func laneShapedNestingInsideTrash() throws { let fixture = try TrashFixture() defer { fixture.tearDown() } @@ -157,7 +159,9 @@ struct TrashContainerLoadTests { let result = try BoardLoader.load(boardRoot: fixture.root) - #expect(result.model.trash.map(\.id.rawValue) == [outer]) + #expect(result.model.trashedLanes.map(\.id.rawValue) == [outer]) + #expect(result.model.trashedLanes.first?.heldCards == 1) + #expect(result.model.trash.isEmpty) #expect(result.warnings.isEmpty) // Nowhere in the snapshot, and still on disk. #expect(!result.model.lanes.contains { $0.cards.contains { $0.id.rawValue == nested } }) @@ -277,33 +281,43 @@ struct LegacyTombstoneDetectionTests { let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.legacyTombstones == [ - LegacyTombstone(kind: .card, laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone") + LegacyTombstone(laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone") ]) // Still rendered by the retiring path — read-only detection has moved nothing yet. #expect(result.model.lanes[0].cards.map(\.isDeleted) == [true]) #expect(result.model.trash.isEmpty) } - @Test("A tombstoned lane is reported with no card, and keeps loading flagged") - func tombstonedLaneIsReported() throws { + /// "A lane carrying `deleted:` simply loads **live** with the key ignored — no migration + /// machinery, no key-strip write, no notice" (01 § Deletion, lane clause re-ruled 2026-07-29): + /// the tolerate tier's whole verdict, and the bytes are never touched. + @Test("A tombstoned lane loads live, is warned rather than migrated, and keeps its bytes") + func tombstonedLaneIsTolerated() throws { let fixture = try TrashFixture() defer { fixture.tearDown() } let lane = uuidName() + let text = "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n" try fixture.index("", "schema: 1\n") - try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n") + try fixture.index(lane, text) let result = try BoardLoader.load(boardRoot: fixture.root) - #expect(result.legacyTombstones == [ - LegacyTombstone(kind: .lane, laneID: ItemID(rawValue: lane), cardID: nil, title: "Old lane") - ]) - #expect(result.model.lanes[0].isDeleted) + #expect(result.legacyTombstones.isEmpty, "the lane half of the migration is retired") + #expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)]) + #expect(result.model.lanes.map(\.title.value) == ["Old lane"], "it loads live") + #expect(result.model.lanes[0].isDeleted, "the key is still on disk — it just decides nothing") + #expect( + try String(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md"), encoding: .utf8) + == "---\n" + text + "---\n", + "preserved verbatim, like any unhandled key" + ) } /// Presence, not validity — the same rule the flag has always read by, so a broken timestamp - /// still migrates rather than being left behind as the one key the pivot forgot. - @Test("A malformed deleted value is still migration input") + /// still migrates rather than being left behind as the one key the pivot forgot. The lane beside + /// it takes the tolerate posture whatever its value's condition. + @Test("A malformed deleted value is still migration input on a card, still inert on a lane") func malformedDeletedIsStillReported() throws { let fixture = try TrashFixture() defer { fixture.tearDown() } @@ -316,8 +330,8 @@ struct LegacyTombstoneDetectionTests { let result = try BoardLoader.load(boardRoot: fixture.root) - #expect(result.legacyTombstones.map(\.kind) == [.card, .lane]) - #expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card), nil]) + #expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card)]) + #expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)]) } /// "A `deleted:` key at board level remains meaningless — ignored and logged, preserved @@ -553,10 +567,11 @@ struct TombstoneMigrationTests { #expect(result.model.trash.map(\.id.rawValue) == [Ident.card1]) } - /// "A lane carrying `deleted:` returns **live** with the key removed" — resurrection is the - /// safe direction, and the folder does not move, so it returns to its own position. - @Test("A tombstoned lane keeps its place and returns live") - func laneMigratesInPlace() throws { + /// The lane half of this migration is **retired** (01 § Deletion, re-ruled 2026-07-29): there is + /// no `migrateTombstonedLane` to call, and the loader hands the store no lane work to do — the + /// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`). + @Test("A tombstoned lane is no longer migration input at all") + func laneIsNotMigrationInput() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } @@ -572,74 +587,124 @@ struct TombstoneMigrationTests { Lane notes. """) - let cardBefore = Item.rich(order: "1024", title: "Kept") - try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore) - - try BoardWriter.migrateTombstonedLane(at: fixture.url(Ident.lane1)) - - let text = try fixture.indexText(Ident.lane1) - #expect(!text.contains("deleted:")) - #expect(text.contains("width: 2")) - #expect(text.contains("Lane notes.")) - #expect(try FrontmatterDocument.parse(text).order == .valid(2048)) - // Its cards are none of the lane migration's business. - #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == cardBefore) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.legacyTombstones.isEmpty) - #expect(result.model.lanes.map(\.isDeleted) == [false]) - } - - @Test("The lane migration refuses a card, whose migration is a move") - func laneMigrationRefusesACard() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane")) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card")) - - let error = writeFailure { - try BoardWriter.migrateTombstonedLane(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) - } - #expect(error?.operation == .migrateTombstone(title: nil)) + #expect(result.model.lanes.map(\.title.value) == ["Old lane"]) } } -// MARK: - Writer: lane delete is physical +// MARK: - Writer: a lane delete is the same move -@Suite("BoardWriter ▸ removeLane") -struct RemoveLaneTests { +@Suite("BoardWriter ▸ deleteLaneToTrash") +struct DeleteLaneToTrashTests { - @Test("The lane folder and everything under it go") - func laneIsRemovedWhole() throws { + /// "Deleting a lane moves its folder — subtree intact — into `.trash/`, exactly as a card moves" + /// (03 § Trash, re-ruled 2026-07-29). Byte fidelity of the freight is the assertion: nothing + /// under the lane is read or rewritten, so a restore is an ordinary move back. + @Test("The lane folder moves whole, its subtree byte-identical") + func laneMovesWithItsSubtree() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF]) try fixture.item("", Item.board) - try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane")) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card")) - try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1])) - try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Other")) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Doing")) + let cardBefore = Item.rich(order: "1024", title: "Kept") + try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png) + try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8)) - try BoardWriter.removeLane(at: fixture.url(Ident.lane1)) + let id = try BoardWriter.deleteLaneToTrash( + at: fixture.url(Ident.lane1), + inBoard: fixture.root, + order: -1024 + ) + #expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one") #expect(!fixture.exists(Ident.lane1)) - #expect(fixture.exists(Ident.lane2)) + #expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card1)") == cardBefore) + #expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card2)") == Item.uneditable, + "even a card whose own index.md refuses writes — nothing below the root is touched") + #expect(try fixture.data(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png) + #expect(try fixture.data(".trash/\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8), + "strays ride along like everything else") } - @Test("A lane that is already gone is success") - func absentLaneIsSuccess() throws { + /// The container-changing move stamps both provenance keys (01 § Frontmatter ▸ `modified`'s + /// scope, refined 2026-07-30) — the same rule a card's trash move obeys. + @Test("The rank is rewritten, modified stamped and modified-by cleared") + func theRankRewriteStamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } + try fixture.item("", Item.board) - try BoardWriter.removeLane(at: fixture.url(Ident.lane1)) + try fixture.item(Ident.lane1, """ + --- + schema: 1 + title: Doing + order: 2048 + modified: 2020-01-01T00:00:00Z + modified-by: claude + width: 3 + --- + Lane notes. + + """) + + try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024) + + let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.lane1)")) + #expect(document.order == .valid(-1024)) + #expect(document.modifiedBy.isMissing) + #expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true) + let text = try fixture.indexText(".trash/\(Ident.lane1)") + #expect(text.contains("width: 3"), "everything else round-trips") + #expect(text.contains("Lane notes.")) } - /// The guard has to tell a lane from a card *and* from a trash card, whose parent is `.trash/` - /// rather than a UUID — otherwise a permanent delete would be reachable through this door. - @Test("A card, a trash card, a board root and a stray are all refused") - func onlyLanesAreRemoved() throws { + /// "`kind: lane` … backfilled on touch when absent … the trash move's rank mint included" + /// (01 § Deletion). The **empty** lane is the case that needs it: in a flat container it is + /// shape-identical to a card, so a derived kind would answer wrongly and the row would come back + /// as a card. + @Test("The move stamps kind: lane, even on an empty lane where shape would say card") + func kindIsStampedNotGuessed() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n") + + try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024) + + #expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane")) + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.trashKinds[ItemID(rawValue: Ident.lane1)] == .lane) + #expect(result.model.trashedLanes.map(\.id.rawValue) == [Ident.lane1]) + #expect(result.model.trash.isEmpty) + } + + /// A present `kind` is never rewritten and never corroborated — the value names the kind + /// (01 § Frontmatter, the `kind` row). + @Test("An existing kind is left exactly as it was") + func existingKindIsLeftAlone() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n") + + try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024) + + let text = try fixture.indexText(".trash/\(Ident.lane1)") + #expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice") + } + + /// The guard is the mirror of `deleteCardToTrash`'s: a lane is `/`, so a card, a + /// board root, a stray, and an entry already in `.trash/` are all refused. + @Test("A card, a trash entry, a board root and a stray are all refused") + func onlyLanesTakeThisDoor() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } @@ -650,107 +715,27 @@ struct RemoveLaneTests { try fixture.item("notes", Item.rich(order: "1024", title: "Stray")) for target in ["\(Ident.lane1)/\(Ident.card1)", ".trash/\(Ident.card2)", "", "notes"] { - #expect(writeFailure { try BoardWriter.removeLane(at: fixture.url(target)) } != nil) + #expect( + writeFailure { + try BoardWriter.deleteLaneToTrash( + at: fixture.url(target), + inBoard: fixture.root, + order: -1024 + ) + } != nil + ) } #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) #expect(fixture.exists(".trash/\(Ident.card2)")) } } -// MARK: - Writer: capture and replay - -@Suite("BoardWriter ▸ subtree capture and replay") -struct SubtreeCaptureTests { - - /// A lane delete's undo in one round trip: capture, remove, recreate, capture again. Equality - /// of the two captures is the byte-fidelity assertion — names, bytes, nesting and order. - @Test("Capture → remove → recreate → capture is identical, nested cards and attachments included") - func roundTripIsByteIdentical() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - - let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A]) - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane")) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "One")) - try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable) - try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png) - try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sub/deep.bin", Data([7, 8, 9])) - // The verbatim promise covers what the loader would never render, too. - try fixture.file("\(Ident.lane1)/\(Ident.card1)/.DS_Store", Data([0, 1])) - try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8)) - try FileManager.default.createDirectory( - at: fixture.url("\(Ident.lane1)/\(Ident.indexless)"), - withIntermediateDirectories: true - ) - - let lane = fixture.url(Ident.lane1) - let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane")) - try BoardWriter.removeLane(at: lane) - #expect(!fixture.exists(Ident.lane1)) - - try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: "Lane")) - - let recaptured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane")) - #expect(recaptured == captured) - // …and spot-checked against disk, so the equality is not two identical bugs. - #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") == Item.uneditable) - #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png) - #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/.DS_Store") == Data([0, 1])) - #expect(try fixture.data("\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8)) - #expect(try fixture.entryNames(Ident.lane1).contains(Ident.indexless)) - } - - @Test("A symlink is captured as a link and recreated as one, never followed") - func symlinksAreNotFollowed() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane")) - try FileManager.default.createSymbolicLink( - atPath: fixture.url(Ident.lane1).appendingPathComponent("link").path, - withDestinationPath: "../nowhere" - ) - - let lane = fixture.url(Ident.lane1) - let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil)) - #expect(captured.entries.contains(.symlink(name: "link", destination: "../nowhere"))) - - try BoardWriter.removeLane(at: lane) - try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil)) - - let destination = try FileManager.default.destinationOfSymbolicLink( - atPath: lane.appendingPathComponent("link").path - ) - #expect(destination == "../nowhere") - } - - @Test("Recreating over something that exists refuses rather than merging") - func recreateRefusesToClobber() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane")) - - let lane = fixture.url(Ident.lane1) - let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil)) - let error = writeFailure { - try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil)) - } - #expect(error?.reason == .io(message: "something already exists here")) - // The refusal left the folder that was there exactly as it was. - #expect(try fixture.indexText(Ident.lane1).contains("title: Lane")) - } -} - // MARK: - Writer: permanent removal @Suite("BoardWriter ▸ purging the trash") struct TrashContainerPurgeTests { - @Test("A trash card purges; a live card is unreachable through this door") + @Test("A trash entry purges; a live card is unreachable through this door") func purgeIsScopedToTheTrash() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } @@ -760,11 +745,11 @@ struct TrashContainerPurgeTests { try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live")) try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed")) - try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root) + try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root) #expect(!fixture.exists(".trash/\(Ident.card2)")) let error = writeFailure { - try BoardWriter.purgeTrashCard( + try BoardWriter.purgeTrashEntry( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), inBoard: fixture.root ) @@ -773,7 +758,7 @@ struct TrashContainerPurgeTests { #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) // Already gone is success, as everywhere else on the purge path. - try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root) + try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root) } @Test("Empty Trash takes every card and leaves a hand-editor's strays standing") @@ -801,6 +786,44 @@ struct TrashContainerPurgeTests { try fixture.item("", Item.board) #expect(try BoardWriter.emptyTrash(inBoard: fixture.root).isEmpty) } + + /// "Permanent deletion … walks lane subtrees" (03 § Trash): the trashed lane's freight goes with + /// it, through the same recursive removal a card takes. + @Test("Purging a trashed lane takes its whole subtree") + func purgingALaneTakesItsFreight() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + try fixture.item("", Item.board) + try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n") + try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight")) + try fixture.file(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1])) + try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Beside it")) + + try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.lane1)"), inBoard: fixture.root) + + #expect(!fixture.exists(".trash/\(Ident.lane1)")) + #expect(fixture.exists(".trash/\(Ident.card2)"), "its neighbour is untouched") + } + + @Test("Empty Trash walks lane subtrees too") + func emptyTrashWalksLanes() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + try fixture.item("", Item.board) + try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n") + try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight")) + try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Card")) + try fixture.file(".trash/readme.txt", Data("mine".utf8)) + + let purged = try BoardWriter.emptyTrash(inBoard: fixture.root) + + #expect(Set(purged) == [ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card2)]) + #expect(!fixture.exists(".trash/\(Ident.lane1)")) + #expect(!fixture.exists(".trash/\(Ident.card2)")) + #expect(try fixture.data(".trash/readme.txt") == Data("mine".utf8), "strays still stand") + } } // MARK: - Board-wide uniqueness spans the trash @@ -887,6 +910,9 @@ struct TrashKindDiscriminatorTests { let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.trashKinds[ItemID(rawValue: entry)] == .lane) + #expect(result.model.trashedLanes.map(\.id.rawValue) == [entry]) + #expect(result.model.trashedLanes.first?.heldCards == 0, "an honored lane can be empty") + #expect(result.model.trash.isEmpty, "and it is not a card anywhere") } /// No key, or a value outside the schema's three, falls through to shape — UUID-shaped children @@ -938,7 +964,104 @@ struct TrashKindDiscriminatorTests { ) { $0.set(FrontmatterKeys.order, to: .double(4096)) } let after = try BoardLoader.load(boardRoot: fixture.root) - #expect(after.model.trash.first?.document.kind == .valid("lane")) + #expect(after.model.trashedLanes.first?.document.kind == .valid("lane")) #expect(after.trashKinds[ItemID(rawValue: entry)] == .lane) } + + /// **The opaque unit** (03 § Trash): a trashed lane is one row with a title, a rank and a count. + /// Its cards are not in the snapshot at all — not as trash cards, not as anybody's lane's cards + /// — and the count is what the loader would have rendered as cards, so the row's number and a + /// restore's outcome agree. + @Test("A trashed lane surfaces as one opaque row: title, rank, held-card count") + func trashedLaneIsOpaque() throws { + let fixture = try TrashFixture() + defer { fixture.tearDown() } + let lane = uuidName() + let held = [uuidName(), uuidName(), uuidName()] + + try fixture.index("", "schema: 1\n") + try fixture.index(".trash/\(lane)", "schema: 1\norder: 1024\ntitle: Doing\nkind: lane\n") + for (index, card) in held.enumerated() { + try fixture.index(".trash/\(lane)/\(card)", "schema: 1\norder: \((index + 1) * 1024)\n") + } + // Not a card, so not counted: a stray folder and an identity-shaped one with no index.md. + try fixture.index(".trash/\(lane)/attachments", "schema: 1\norder: 1024\n") + try fixture.folder(".trash/\(lane)/\(uuidName())") + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.model.trashedLanes.count == 1) + let row = try #require(result.model.trashedLanes.first) + #expect(row.id == ItemID(rawValue: lane)) + #expect(row.title.value == "Doing") + #expect(row.order == 1024) + #expect(row.heldCards == 3) + #expect(result.model.trash.isEmpty, "the freight is not surfaced as trash cards") + #expect(result.model.lanes.isEmpty) + #expect(result.warnings.isEmpty, "nothing inside an opaque entry is walked, so nothing strays") + } + + /// The ruling's own **honest limit**, pinned so it stays a decision rather than a surprise: "a + /// kind-less trashed lane emptied of its children before any touch becomes indistinguishable + /// from a card" (01 § Deletion). The app never produces one — `deleteLaneToTrash` stamps the + /// key — so this is only reachable by a hand-editor. + @Test("A kindless empty lane folder reads as a card — the on-touch-only limit") + func kindlessEmptyLaneReadsAsACard() throws { + let fixture = try TrashFixture() + defer { fixture.tearDown() } + let entry = uuidName() + + try fixture.index("", "schema: 1\n") + try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\ntitle: Was a lane\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.trashKinds[ItemID(rawValue: entry)] == .card) + #expect(result.model.trash.map(\.id.rawValue) == [entry]) + #expect(result.model.trashedLanes.isEmpty) + } + + /// Both kinds are validated by the one rulebook: `schema` and `order` are required of a lane + /// exactly as of a card (`IntegrityRules.requiresOrder`), so a malformed entry fails fast + /// whichever kind the discriminator would have called it. + @Test("A trashed lane missing order fails the load, like any entry") + func trashedLaneFailsFastOnOrder() throws { + let fixture = try TrashFixture() + defer { fixture.tearDown() } + let lane = uuidName() + + try fixture.index("", "schema: 1\n") + try fixture.index(".trash/\(lane)", "schema: 1\nkind: lane\n") + try fixture.index(".trash/\(lane)/\(uuidName())", "schema: 1\norder: 1024\n") + + #expect(throws: BoardLoadError.self) { + try BoardLoader.load(boardRoot: fixture.root) + } + } + + /// The column is one list interleaved by rank (03 § Trash), which the snapshot expresses as two + /// arrays carrying the ranks that interleave them — so a consumer merging by `order` gets the + /// column, and neither array is "after" the other. + @Test("Both kinds carry the ranks that interleave them") + func kindsInterleaveByRank() throws { + let fixture = try TrashFixture() + defer { fixture.tearDown() } + let newestLane = uuidName() + let middleCard = uuidName() + let oldestLane = uuidName() + + try fixture.index("", "schema: 1\n") + try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 3072\nkind: lane\n") + try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\n") + try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 1024\nkind: lane\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane]) + #expect(result.model.trash.map(\.id.rawValue) == [middleCard]) + let column = (result.model.trash.map { (order: $0.order, id: $0.id) } + + result.model.trashedLanes.map { (order: $0.order, id: $0.id) }) + .sorted { $0.order < $1.order } + .map(\.id.rawValue) + #expect(column == [newestLane, middleCard, oldestLane]) + } } diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index fbaf83e..1f94b4e 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -267,28 +267,73 @@ struct DeleteCardTests { } } -// MARK: - Delete: a lane is physical +// MARK: - Delete: a lane takes the same move @MainActor @Suite("BoardStore ▸ delete a lane") struct DeleteLaneTests { - @Test("Deleting a lane removes the folder and its contents — nothing is trashed") - func laneDeleteIsPhysical() throws { + /// 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 freight is inside the folder that + /// moved, so it is not filed separately and not surfaced. + @Test("Deleting a lane moves the folder into .trash/, subtree intact and opaque") + func laneDeleteIsAMoveIntoTheTrash() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.delete([lane3]) - // 03-board-ui.md § Trash: "Cards only. Lanes are never trashed … deleting a lane deletes it, - // folder and contents, physically." #expect(!fixture.exists(Ident.lane3)) - #expect(!fixture.exists(".trash/\(Ident.card4)"), "its cards go with it, not into the trash") - #expect(try loaded(fixture).trash.map(\.id) == [newer, trashed], "the trash is untouched") + #expect(fixture.exists(".trash/\(Ident.lane3)")) + #expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card4)"), "its cards ride along inside it") + #expect(!fixture.exists(".trash/\(Ident.card4)"), "and are never filed as trash cards") + + let model = try loaded(fixture) + #expect(model.trash.map(\.id) == [newer, trashed], "the trash's cards are unchanged") + #expect(model.trashedLanes.map(\.id.rawValue) == [Ident.lane3]) + #expect(model.trashedLanes.first?.heldCards == 1) #expect(store.banners.oneShots.isEmpty) } + /// "Every arrival lands at the trash's topmost position … regardless of kind" (03 § Trash): the + /// ladder the rank is minted against is the whole container, cards and lane rows alike. + @Test("The lane lands at the top of the trash, above every existing entry") + func laneLandsOnTop() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let topBefore = try #require(loaded(fixture).trash.map(\.order).min()) + + store.delete([lane3]) + let landed = try #require(loaded(fixture).trashedLanes.first?.order) + #expect(landed < topBefore) + + // And the next card delete mints above *that* — the lane row is in the ladder the store + // mints against, which is the whole container rather than one array of it. The reload is + // what puts the new row in the snapshot; without it the store is still holding the + // pre-delete picture, as it is for two of any deletes in a row. + await reload(store) + store.delete([card1]) + let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order) + #expect(card < landed) + } + + /// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash). + @Test("A lane delete raises no confirmation and needs none") + func laneDeleteIsNotConfirmed() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let confirmations = TrashConfirmations() + store.select([lane3], in: .board) + + confirmations.requestDelete(in: store) + + #expect(confirmations.pending == nil) + #expect(fixture.exists(".trash/\(Ident.lane3)"), "it went straight through") + } + @Test("The selection moves to the successor lane") func laneSuccessor() throws { let fixture = try makeBoard() @@ -344,7 +389,7 @@ struct StagedDeleteTests { // The column's order is [newer, trashed]. store.select([newer], in: .trash) - store.deleteTrashCards([newer]) + store.deleteTrashEntries([newer]) #expect(store.selection.ids == [trashed]) #expect(store.selection.container == .trash) @@ -357,7 +402,7 @@ struct StagedDeleteTests { let store = try BoardStore(rootURL: fixture.root) store.select([newer, trashed], in: .trash) - store.deleteTrashCards([newer, trashed]) + store.deleteTrashEntries([newer, trashed]) #expect(try loaded(fixture).trash.isEmpty) #expect(store.selection.isEmpty) @@ -369,7 +414,7 @@ struct StagedDeleteTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.deleteTrashCards([card1]) + store.deleteTrashEntries([card1]) #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) #expect(store.banners.oneShots.isEmpty) @@ -432,8 +477,8 @@ struct PurgeTests { #expect(store.purgeIsUnrecoverable) store.select([trashed], in: .trash) - store.deleteTrashCards([trashed]) - store.deleteTrashCards([newer]) + store.deleteTrashEntries([trashed]) + store.deleteTrashEntries([newer]) store.emptyTrash() // 13-native-undo.md ▸ Rules: "Permanently delete (the trash's Delete, Empty Trash) … @@ -472,10 +517,11 @@ struct StoreTombstoneMigrationTests { return fixture } - @Test("Cards relocate into .trash/ with the key removed; lanes return live in place") - func migrationMovesCardsAndResurrectsLanes() throws { + @Test("Cards relocate into .trash/ with the key removed; a lane's key is never written to") + func migrationMovesCardsAndLeavesLanesAlone() throws { let fixture = try makeLegacyBoard() defer { fixture.tearDown() } + let laneBefore = try fixture.indexText(Ident.lane2) let store = try BoardStore(rootURL: fixture.root) store.migrateLegacyTombstones() @@ -488,12 +534,12 @@ struct StoreTombstoneMigrationTests { #expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:"))) #expect(!(try fixture.indexText(".trash/\(Ident.card3)").contains("deleted:"))) - // The lane stayed exactly where it was, key stripped — "resurrection is the safe direction, - // nothing is destroyed by migration". - #expect(fixture.exists(Ident.lane2)) - #expect(!(try fixture.indexText(Ident.lane2).contains("deleted:"))) - #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"), "its cards come back with it") - #expect(try document(fixture, Ident.lane2).order.value == 2048, "at its own position") + // "A lane carrying `deleted:` simply loads live with the key ignored — no migration + // machinery, no key-strip write" (01 § Deletion, re-ruled 2026-07-29): its bytes are + // untouched, `modified` included, and it renders as an ordinary lane. + #expect(try fixture.indexText(Ident.lane2) == laneBefore, "not one byte written") + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) + #expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2)) } @Test("Cards migrate oldest-first, so the newest deletion ends up on top") @@ -554,7 +600,7 @@ struct StoreTombstoneMigrationTests { #expect(store.banners.losses.isEmpty, "and nothing to announce") } - @Test("The notice is one folded warning-tone row naming both halves") + @Test("The notice is one warning-tone row, and names only what was moved") func theNotice() throws { let fixture = try makeLegacyBoard() defer { fixture.tearDown() } @@ -563,8 +609,10 @@ struct StoreTombstoneMigrationTests { store.migrateLegacyTombstones() #expect(store.banners.losses.count == 1, "one migration, one row") + // No lane clause: nothing happened to the lane, so a row claiming otherwise would announce + // an act the app did not perform. #expect(store.banners.losses.first?.message - == "Moved 2 cards to the trash and restored 'Retired' — they carried old deleted markers") + == "Moved 2 cards to the trash — they carried old deleted markers") } @Test("A board with nothing legacy migrates nothing and says nothing") @@ -628,7 +676,7 @@ struct StoreTombstoneMigrationTests { // read, so the channel empties and stays empty. #expect(store.legacyTombstones.isEmpty) #expect(store.snapshot.trash.count == 2) - #expect(store.snapshot.lanes.count == 2, "the resurrected lane is an ordinary lane again") + #expect(store.snapshot.lanes.count == 2, "the key-carrying lane was always an ordinary lane") } @Test("It is armed by the reload seam, exactly like the loose-file relocation") @@ -659,31 +707,17 @@ struct MigrationNoticeTests { @Test("One card names it; several fold to a count") func cardsFold() { - #expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"], lanes: []) + #expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"]) == "Moved 'Fix login' to the trash — it carried an old deleted marker") - #expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: []) + #expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"]) == "Moved 3 cards to the trash — they carried old deleted markers") } - @Test("A lane reads as a restoration, which is what it is") - func lanesRead() { - #expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["Doing"]) - == "Restored 'Doing' — it carried an old deleted marker") - #expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["A", "B"]) - == "Restored 2 lanes — they carried old deleted markers") - } - - @Test("Both halves fold into one sentence — one migration, one row") - func bothFold() { - #expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: ["D", "E"]) - == "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers") - } - @Test("An untitled item reads as a rendering, and nothing migrated is not news") func edges() { - #expect(BannerCenter.migratedTombstonesMessage(cards: [nil], lanes: []) + #expect(BannerCenter.migratedTombstonesMessage(cards: [nil]) == "Moved an untitled item to the trash — it carried an old deleted marker") - #expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: []) == nil) + #expect(BannerCenter.migratedTombstonesMessage(cards: []) == nil) } } @@ -712,7 +746,7 @@ struct TrashConfirmationsTests { store.select([trashed], in: .trash) confirmations.requestDelete(in: store) let pending = try #require(confirmations.pending) - #expect(pending.action == .deleteTrashCards([trashed])) + #expect(pending.action == .deleteTrashEntries([trashed])) #expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet") confirmations.confirm(in: store) @@ -844,7 +878,7 @@ struct TrashMenuValidationTests { confirmations.requestDelete(in: store) // The trash staging: permanent, and behind the alert. let pending = try #require(confirmations.pending) - #expect(pending.action == .deleteTrashCards([trashed])) + #expect(pending.action == .deleteTrashEntries([trashed])) } /// A context menu names its target by where it was invoked, so the trash row's Delete must purge @@ -862,7 +896,7 @@ struct TrashMenuValidationTests { let pending = try #require(confirmations.pending) #expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?") - #expect(pending.action == .deleteTrashCards([trashed])) + #expect(pending.action == .deleteTrashEntries([trashed])) confirmations.confirm(in: store) #expect(!fixture.exists(".trash/\(Ident.indexless)")) diff --git a/KanbanTests/UndoWriteTests.swift b/KanbanTests/UndoWriteTests.swift index a43e89e..21f15a2 100644 --- a/KanbanTests/UndoWriteTests.swift +++ b/KanbanTests/UndoWriteTests.swift @@ -664,29 +664,63 @@ struct TrashUndoTests { #expect(history.canUndo == false) } - @Test("A lane delete is physical, and its undo recreates the folder byte for byte") - func laneDeleteRecreatesTheSubtree() throws { + /// 13 ▸ Interaction with the trash: "a lane [returns] to its strip position (subtree intact — it + /// never left the folder)". The inverse is the ordinary move back, so the capture/recreate + /// machinery is retired: the lane's bytes never left the disk to need replaying. + @Test("A lane delete is a move into the trash, and its undo is the move back to its strip rank") + func laneDeleteRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) - let laneText = try fixture.indexText(Ident.lane2) + let laneBefore = try fixture.indexText(Ident.lane2) let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") + let laneRank = try #require(try document(fixture, Ident.lane2).order.value) store.delete([lane2]) - #expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically") - #expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed") + #expect(!fixture.exists(Ident.lane2)) + #expect(fixture.exists(".trash/\(Ident.lane2)")) + #expect(try loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards") + #expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2]) #expect(history.undoActionName == "Delete Lane") + let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) + let resident = try #require(try document(fixture, trashedPath).order.value) + #expect(trashRank < resident, "entry is at the top — a rank above the current topmost") history.undo() #expect(fixture.exists(Ident.lane2)) - #expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit") + #expect(!fixture.exists(".trash/\(Ident.lane2)")) + #expect(try document(fixture, Ident.lane2).order.value == laneRank, "at its own strip position") + #expect(untouchedLines(try fixture.indexText(Ident.lane2)) == untouchedLines(laneBefore), + "byte-identical but for the stamps every container-changing move owns") #expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText, - "and the whole subtree comes back with it — nested cards included") + "the subtree never moved relative to its lane, so not one nested byte changed") history.redo() - #expect(!fixture.exists(Ident.lane2)) + #expect(fixture.exists(".trash/\(Ident.lane2)")) + #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank, + "the redo replays the write's own captured rank") + } + + @Test("A multi-lane delete is one step with a plural title") + func batchLaneDeleteIsOneStep() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.delete([lane1, lane2]) + + #expect(history.undoActionName == "Delete 2 Lanes") + let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value) + let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) + #expect(second < first, "each arrival in the run mints a rank above the one before it") + + history.undo() + #expect(fixture.exists(Ident.lane1)) + #expect(fixture.exists(Ident.lane2)) + #expect(try loadedTrashedLanes(fixture).isEmpty) + #expect(history.canUndo == false) } @Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse") @@ -721,6 +755,11 @@ private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] { try BoardLoader.load(boardRoot: fixture.root).model.trash } +@MainActor +private func loadedTrashedLanes(_ fixture: WriterFixture) throws -> [TrashedLane] { + try BoardLoader.load(boardRoot: fixture.root).model.trashedLanes +} + // MARK: - The Edit session @MainActor @@ -820,7 +859,7 @@ struct NotUndoableTests { let armed = try #require(history.undoActionName) store.select([trashed], in: .trash) - store.deleteTrashCards([trashed]) + store.deleteTrashEntries([trashed]) #expect(fixture.exists(trashedPath) == false) #expect(store.purgeIsUnrecoverable) @@ -963,7 +1002,9 @@ private enum Foreign { try fixture.move(path, toTrash: id) } - /// A foreign delete of a **lane** — physical, since lanes are never trashed. + /// A **Finder deletion** of a lane: the folder disappears entirely, which "is also a delete" + /// (01 § Deletion) — such items never enter the trash, so this is the shape a foreign writer's + /// destructive removal has, distinct from the app's own delete (a move into `.trash/`). static func removeLane(_ fixture: WriterFixture, _ path: String) throws { try FileManager.default.removeItem(at: fixture.url(path)) } @@ -1093,8 +1134,10 @@ struct StaleStepTests { #expect(store.banners.signposts.count == 1) } - /// The card's own path is the check: a lane delete is physical, so a card under it is simply not - /// there any more — no ancestor walk, which is what materializing the trash bought. + /// The card's own path is the check: its lane's folder is gone, so the card is simply not there + /// any more — no ancestor walk, which is what materializing the trash bought. (The same holds + /// when the app trashes the lane: the card's path moves under `.trash/` and nothing is left at + /// the one the step named.) @Test("A card whose lane a foreign writer deleted is stale too") func aCardUnderARemovedLaneIsStale() throws { let fixture = try makeBoard() diff --git a/KanbanTests/WriteFidelityTests.swift b/KanbanTests/WriteFidelityTests.swift index a2e0896..9d665be 100644 --- a/KanbanTests/WriteFidelityTests.swift +++ b/KanbanTests/WriteFidelityTests.swift @@ -179,6 +179,29 @@ struct WriteFidelityMinimalTouchTests { } #expect(BoardLoader.isUUIDShaped(createdCard)) + // A **lane** delete is the same move, one level up (03 § Trash, re-ruled 2026-07-29): the + // lane's folder leaves the visible tree carrying its whole subtree, and the cards inside it + // are neither read nor rewritten — their mtimes prove it, since they are still tracked here + // under their new path only after the restore puts the lane back. + try step( + "delete lane", + targeting: [], + departed: [Ident.lane2, "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(Ident.card4)"] + ) { + try BoardWriter.deleteLaneToTrash( + at: fixture.url(Ident.lane2), inBoard: fixture.root, order: 512 + ) + } + try step("restore lane", targeting: []) { + _ = try BoardWriter.moveItem( + at: BoardWriter.trashFolder(inBoard: fixture.root).appendingPathComponent(Ident.lane2), + toParent: fixture.root, + sourceBoardRoot: fixture.root, + destinationBoardRoot: fixture.root, + order: 2048 + ) + } + try step("import attachment", targeting: []) { // Dot-prefixed so it never becomes a board-root stray the loader has to warn about // — the source lives outside the board tree in spirit, just not in path. @@ -547,6 +570,26 @@ struct WriteFidelityStampingTests { #expect(restored.modifiedBy == nil, "out of the trash is a container change too") } + /// A lane's trash move is the same container change, and its **subtree is not** — the nested + /// card is neither read nor rewritten, so its stamps are exactly what they were. + @Test("A lane's trash move stamps the lane and nothing beneath it") + func aLaneTrashMoveStampsOnlyTheLane() throws { + let fixture = try twoLaneBoard() + defer { fixture.tearDown() } + + try BoardWriter.deleteLaneToTrash( + at: fixture.url(Ident.lane1), inBoard: fixture.root, order: 1024 + ) + + let lane = try stamps(fixture, ".trash/\(Ident.lane1)") + #expect(lane.modified != Self.priorModified, "into the trash is a container change") + #expect(lane.modifiedBy == nil) + + let card = try stamps(fixture, ".trash/\(Ident.lane1)/\(Ident.card1)") + #expect(card.modified == Self.priorModified, "the freight's own container did not change") + #expect(card.modifiedBy == "claude", "so even a foreign stamp survives, untouched") + } + /// A cross-board arrival changes the container as surely as a cross-lane move does, and the /// import boundary's remint does not change that: the arrived file is stamped either way. @Test("A cross-board arrival stamps")