Lanes delete into the trash — storage, loader, writer, and undo
Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.
TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.
Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -471,15 +471,18 @@ public final class BannerCenter {
|
|||||||
/// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
|
/// **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
|
/// 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
|
/// 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
|
/// as an error, because no action failed.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for
|
/// **Cards only** (01-storage-format.md § Deletion, lane clause re-ruled 2026-07-29): the lane
|
||||||
/// an untitled one — "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so
|
/// half of this notice retired with the lane migration itself — a lane's `deleted:` is inert now,
|
||||||
/// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
|
/// nothing is written for it, and a row announcing an act the app did not perform would be worse
|
||||||
public func postMigratedTombstones(cards: [String?], lanes: [String?]) {
|
/// than silence.
|
||||||
guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return }
|
///
|
||||||
|
/// `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)
|
postLoss(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,49 +965,28 @@ public final class BannerCenter {
|
|||||||
return "Moved '\(name)' into attachments — \(subject)"
|
return "Moved '\(name)' into attachments — \(subject)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The legacy tombstone migration's line — **one folded sentence for both halves**, written in
|
/// The legacy tombstone migration's line, written in `relocatedLooseFilesMessage`'s voice
|
||||||
/// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first,
|
/// because it is the same kind of notice: the act first, the subject after an em dash, plurals
|
||||||
/// the subject after an em dash, plurals folded, a sole item named.
|
/// folded, a sole item named.
|
||||||
///
|
///
|
||||||
/// The two clauses are joined rather than posted as two rows, because it is **one migration**:
|
/// **One clause, since the lane half retired** (01-storage-format.md § Deletion, re-ruled
|
||||||
/// a board opened, its old deletion markers were resolved, and that is one thing that happened
|
/// 2026-07-29 — lanes ignore the key rather than migrating). The shapes:
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// 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".
|
/// **The tail names the cause**, and it is the whole explanation the row owes: the user did not
|
||||||
/// - **Several cards**: "Moved 3 cards to the trash — they carried old deleted markers".
|
/// delete anything just now, and without the clause the sentence would read as an action they
|
||||||
/// - **One lane**, no cards: "Restored 'Doing' — it carried an old deleted marker".
|
/// had somehow just taken.
|
||||||
/// - **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).
|
|
||||||
///
|
///
|
||||||
/// `nil` when nothing migrated — a migration that migrated nothing is not news.
|
/// `nil` when nothing migrated — a migration that migrated nothing is not news.
|
||||||
public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? {
|
public nonisolated static func migratedTombstonesMessage(cards: [String?]) -> String? {
|
||||||
let total = cards.count + lanes.count
|
guard !cards.isEmpty else { return nil }
|
||||||
guard total > 0 else { return nil }
|
let subject = cards.count == 1 ? sole(cards[0]) : "\(cards.count) cards"
|
||||||
|
let tail = cards.count == 1
|
||||||
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
|
|
||||||
? "it carried an old deleted marker"
|
? "it carried an old deleted marker"
|
||||||
: "they carried old deleted markers"
|
: "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
|
/// The claimed-name displacement's line — the relocation's own voice (the act first, the subject
|
||||||
|
|||||||
@@ -277,9 +277,12 @@ public struct BoardDiff: Sendable, Equatable {
|
|||||||
/// Whether anything the digest can see differs — the whole value while the trash is being
|
/// Whether anything the digest can see differs — the whole value while the trash is being
|
||||||
/// walked, everything but the trash while it is not.
|
/// walked, everything but the trash while it is not.
|
||||||
///
|
///
|
||||||
/// `trash` is the one `var` on `BoardModel`, which is what makes "everything except the trash"
|
/// `trash` and `trashedLanes` are the two `var`s on `BoardModel`, which is what makes "everything
|
||||||
/// expressible as value equality rather than as a second field-by-field list that would go
|
/// except the trash" expressible as value equality rather than as a second field-by-field list
|
||||||
/// stale the moment the model grows a field.
|
/// 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(
|
private static func boardSideDiffers(
|
||||||
_ old: BoardModel,
|
_ old: BoardModel,
|
||||||
_ new: BoardModel,
|
_ new: BoardModel,
|
||||||
@@ -290,6 +293,8 @@ public struct BoardDiff: Sendable, Equatable {
|
|||||||
var newSansTrash = new
|
var newSansTrash = new
|
||||||
oldSansTrash.trash = []
|
oldSansTrash.trash = []
|
||||||
newSansTrash.trash = []
|
newSansTrash.trash = []
|
||||||
|
oldSansTrash.trashedLanes = []
|
||||||
|
newSansTrash.trashedLanes = []
|
||||||
return oldSansTrash != newSansTrash
|
return oldSansTrash != newSansTrash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1863,7 +1863,7 @@ public final class BoardStore: HealHost {
|
|||||||
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
|
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? {
|
nonisolated static func cardTitle(at path: ItemPath, in snapshot: BoardModel) -> String? {
|
||||||
switch path {
|
switch path {
|
||||||
case let .card(lane, id):
|
case let .card(lane, id):
|
||||||
@@ -1872,6 +1872,8 @@ public final class BoardStore: HealHost {
|
|||||||
snapshot.trash.first { $0.id == id }?.title.value
|
snapshot.trash.first { $0.id == id }?.title.value
|
||||||
case let .lane(id):
|
case let .lane(id):
|
||||||
snapshot.lanes.first { $0.id == id }?.title.value
|
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.
|
/// The parent folder an inverse move returns it to.
|
||||||
func parent(under root: URL) -> URL {
|
func parent(under root: URL) -> URL {
|
||||||
switch path {
|
BoardStore.parentFolder(of: path, under: root)
|
||||||
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
|
|
||||||
case .trashCard: BoardWriter.trashFolder(inBoard: root)
|
|
||||||
case .lane: 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.
|
/// 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 {
|
nonisolated static func parentFolder(of path: ItemPath, under root: URL) -> URL {
|
||||||
switch path {
|
switch path {
|
||||||
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
|
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
|
case .lane: root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3121,20 +3122,22 @@ public final class BoardStore: HealHost {
|
|||||||
public func deleteSelection() {
|
public func deleteSelection() {
|
||||||
switch selection.container {
|
switch selection.container {
|
||||||
case .board: delete(selection.ids)
|
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
|
/// **One act at two levels now** (03-board-ui.md § Trash, re-ruled 2026-07-29): "deleting a lane
|
||||||
/// folder into `<board-root>/.trash/`", while "Cards only. Lanes are never trashed — deleting a
|
/// moves its folder — subtree intact — into `.trash/`, exactly as a card moves". The two writes
|
||||||
/// lane deletes it, folder and contents, physically. The net is undo, not the trash."
|
/// 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
|
/// **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
|
/// (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
|
/// caller hands one anyway the lanes win: a lane takes its cards with it, and moving them
|
||||||
/// trash move as well would put a second step on the undo stack for one keystroke.
|
/// 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
|
/// **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
|
/// 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
|
let landed = lanes.isEmpty
|
||||||
? moveToTrash(paths.compactMap(Self.cardMove(of:)))
|
? moveToTrash(paths.compactMap(Self.cardMove(of:)))
|
||||||
: removeLanes(lanes)
|
: moveLanesToTrash(lanes)
|
||||||
guard landed else { return }
|
guard landed else { return }
|
||||||
|
|
||||||
if let successor {
|
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
|
/// **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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
// 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
|
// above the previous one, so a three-card ⌫ reads newest-first in the column exactly as three
|
||||||
// separate deletes would.
|
// separate deletes would.
|
||||||
var ladder = snapshot.trash.map(\.order)
|
var ladder = trashRanks
|
||||||
var ranks: [Double] = []
|
var ranks: [Double] = []
|
||||||
for _ in moves {
|
for _ in moves {
|
||||||
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
||||||
@@ -3329,72 +3332,109 @@ public final class BoardStore: HealHost {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Deleting a lane is physical** — the folder and its contents go (03-board-ui.md § Trash:
|
/// **Deleting a lane is a move into `.trash/`, subtree intact** (03-board-ui.md § Trash,
|
||||||
/// "Cards only. Lanes are never trashed … The net is undo, not the 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
|
/// **`moveToTrash`'s twin, and deliberately its mirror image**: the same head-of-the-trash rank
|
||||||
/// is holding them: `captureSubtree` reads the whole tree — nested cards, their `attachments/`,
|
/// mint threaded through the run, the same one bracket, the same one step — because on disk it is
|
||||||
/// every stray, symlinks as links, POSIX modes — inside the same bracket as the removal, so
|
/// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard
|
||||||
/// nothing can change between the two. The capture is the reason this is a *destructive* delete
|
/// it passes and the `kind` it stamps). What is *not* here any more is the whole capture layer:
|
||||||
/// with a real inverse rather than an unrecoverable one.
|
/// 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
|
/// **The cards ride along and are not the store's business**: they are inside the folder that
|
||||||
/// persistence): the bytes live on the stack, so closing the board loses them. Git boards keep
|
/// moved, so they are neither read nor written, and they leave the snapshot with their lane —
|
||||||
/// the lane reachable forever (06-history-undo.md's delete-never-forgets) — a Pro difference,
|
/// which is what dismisses their card windows and voids their pending cuts, through the ordinary
|
||||||
/// stated honestly.
|
/// vanish rule and no clause of its own (02-architecture.md § Live-reload resilience).
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
@discardableResult
|
@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 } }
|
let lanes = ids.compactMap { id in snapshot.lanes.first { $0.id == id } }
|
||||||
guard !lanes.isEmpty else { return false }
|
guard !lanes.isEmpty else { return false }
|
||||||
|
|
||||||
let root = rootURL
|
let root = rootURL
|
||||||
let folders = lanes.map { ItemPath.lane($0.id).folder(under: root) }
|
var ladder = trashRanks
|
||||||
var captures: [SubtreeSnapshot] = []
|
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
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
for folder in folders {
|
for (lane, rank) in zip(lanes, ranks) {
|
||||||
captures.append(try BoardWriter.captureSubtree(at: folder, operation: .delete(title: nil)))
|
try BoardWriter.deleteLaneToTrash(
|
||||||
try BoardWriter.removeLane(at: folder)
|
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
|
// lane delete → **the ordinary move back** (13-native-undo.md ▸ Interaction with the trash:
|
||||||
// references: the capture is a `SubtreeSnapshot` of bytes taken before the removal, so the
|
// "a lane [returns] to its strip position (subtree intact — it never left the folder)"). The
|
||||||
// step means the same thing any number of reloads later.
|
// 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
|
// The expectations are one swap, and the container rides in the path (`HistoryStaleness`):
|
||||||
// recreate refuses to clobber, so a lane somebody re-made at that id is not this step's to
|
// the undo wants the lane in the trash holding the rank the delete gave it, the redo wants it
|
||||||
// overwrite), the redo wants them back.
|
// back on the strip holding the rank it left. A foreign restore empties the trash path and
|
||||||
let steps = Array(zip(folders, captures))
|
// 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(
|
registerStep(
|
||||||
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
|
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
|
||||||
subject: lanes.count == 1 ? lanes[0].title.value : nil,
|
subject: lanes.count == 1 ? lanes[0].title.value : nil,
|
||||||
undoExpects: steps.map { .absent($0.0) },
|
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
|
||||||
redoExpects: steps.map { .present($0.0) }
|
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
|
||||||
) { _ in
|
) { _ in
|
||||||
for (folder, capture) in steps {
|
for step in steps {
|
||||||
try BoardWriter.recreateSubtree(at: folder, from: capture, operation: .createLane)
|
_ = try BoardWriter.moveItem(
|
||||||
|
at: step.trashed,
|
||||||
|
toParent: root,
|
||||||
|
sourceBoardRoot: root,
|
||||||
|
destinationBoardRoot: root,
|
||||||
|
order: step.priorOrder
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} redo: { _ in
|
} redo: { _ in
|
||||||
// Reversed, so a multi-lane delete unwinds in the mirror of the order it was made in.
|
for step in steps {
|
||||||
for (folder, _) in steps.reversed() {
|
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root, order: step.trashRank)
|
||||||
try BoardWriter.removeLane(at: folder)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The trash's own Delete — permanent** (03-board-ui.md § Trash: "on a trash card, Delete
|
/// Every rank the trash currently holds, **both kinds** — what a fresh arrival mints above
|
||||||
/// (⌫/⌘⌫) is permanent; in the trash it removes the folder").
|
/// (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
|
/// 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`
|
/// 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,
|
/// — 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
|
/// Empty Trash) … the confirm *is* the safety", "lanes and their freight included"). A stack
|
||||||
/// filesystem cannot keep.
|
/// 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 —
|
/// **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
|
/// 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
|
/// 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
|
/// 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).
|
/// walks down a lane (04-interactions.md ▸ The map).
|
||||||
public func deleteTrashCards(_ ids: Set<ItemID>) {
|
public func deleteTrashEntries(_ ids: Set<ItemID>) {
|
||||||
let paths = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
|
let paths = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
|
||||||
guard !paths.isEmpty else { return }
|
guard !paths.isEmpty else { return }
|
||||||
let successor = SelectionGrammar.successor(
|
let successor = SelectionGrammar.successor(
|
||||||
@@ -3416,7 +3456,7 @@ public final class BoardStore: HealHost {
|
|||||||
|
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
for path in paths {
|
for path in paths {
|
||||||
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
|
try BoardWriter.purgeTrashEntry(at: path.folder(under: root), inBoard: root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3427,9 +3467,11 @@ public final class BoardStore: HealHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Empty Trash… ⇧⌘⌫** — purges every card in `<root>/.trash/`, in one bracket.
|
/// **Empty Trash… ⇧⌘⌫** — purges every entry in `<root>/.trash/`, **lane subtrees walked**, in
|
||||||
|
/// one bracket (03-board-ui.md § Trash).
|
||||||
///
|
///
|
||||||
/// Not undoable, `deleteTrashCards`' ruling — this is the other half of 13's "Permanently delete".
|
/// Not undoable, `deleteTrashEntries`' ruling — this is the other half of 13's "Permanently
|
||||||
|
/// delete".
|
||||||
///
|
///
|
||||||
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the writer walks
|
/// **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
|
/// 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
|
/// 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`).
|
/// preserved verbatim rather than swept up with the cards (`BoardWriter.emptyTrash`).
|
||||||
public func emptyTrash() {
|
public func emptyTrash() {
|
||||||
guard !snapshot.trash.isEmpty else { return }
|
guard !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty else { return }
|
||||||
let root = rootURL
|
let root = rootURL
|
||||||
|
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
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
|
/// 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.
|
/// 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 card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`),
|
||||||
/// - **A lane returns live** with the key stripped and nothing moved
|
/// and that is the whole of it. The lane half is **retired wholesale** with the lane trash
|
||||||
/// (`BoardWriter.migrateTombstonedLane`) — "resurrection is the safe direction, nothing is
|
/// (01-storage-format.md § Deletion: "cards migrate, lanes ignore" — a lane carrying `deleted:`
|
||||||
/// destroyed by migration". A tombstoned lane's own cards come back with it; any of them
|
/// "simply loads live with the key ignored — no migration machinery, no key-strip write, no
|
||||||
/// carrying their own key migrate on their own account, as ordinary tombstoned cards, in the
|
/// notice"). Old tombstoned lanes reappearing is the accepted cost, stated in the ruling; a
|
||||||
/// same pass.
|
/// tombstoned lane's own cards still migrate on their own account, as ordinary tombstoned cards.
|
||||||
///
|
///
|
||||||
/// ### The order among migrating cards is `deleted:`-ascending, deliberately
|
/// ### 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
|
/// ### 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
|
/// 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
|
/// 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 Repair-races-a-vanished-duplicate precedent). Without it a card whose key vanished under
|
||||||
/// the write would be rewritten for nothing, stamping `modified` on a file with no defect left.
|
/// the write would be moved into the trash for nothing.
|
||||||
public func migrateLegacyTombstones() {
|
public func migrateLegacyTombstones() {
|
||||||
let work = legacyTombstones
|
let work = legacyTombstones
|
||||||
let root = rootURL
|
let root = rootURL
|
||||||
let cards = Self.migrationOrder(of: work, in: snapshot)
|
let cards = Self.migrationOrder(of: work, in: snapshot)
|
||||||
let lanes = work.filter { $0.kind == .lane }
|
|
||||||
|
|
||||||
var movedCards: [String?] = []
|
var movedCards: [String?] = []
|
||||||
var returnedLanes: [String?] = []
|
|
||||||
// The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a
|
// 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.
|
// migrated card is indistinguishable on disk from one the user deletes today.
|
||||||
var ladder = snapshot.trash.map(\.order)
|
var ladder = trashRanks
|
||||||
heals.run(
|
heals.run(
|
||||||
.legacyTombstone,
|
.legacyTombstone,
|
||||||
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
|
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
|
||||||
on: self
|
on: self
|
||||||
) { () throws(BoardWriteError) -> Void in
|
) { () throws(BoardWriteError) -> Void in
|
||||||
for card in cards {
|
for card in cards {
|
||||||
guard let cardID = card.cardID else { continue }
|
let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root)
|
||||||
let folder = ItemPath.card(lane: card.laneID, id: cardID).folder(under: root)
|
|
||||||
guard Self.stillTombstoned(at: folder) else { continue }
|
guard Self.stillTombstoned(at: folder) else { continue }
|
||||||
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
||||||
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
|
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
|
||||||
ladder.insert(rank, at: 0)
|
ladder.insert(rank, at: 0)
|
||||||
movedCards.append(card.title)
|
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: {
|
} posting: {
|
||||||
.migratedTombstones(cards: movedCards, lanes: returnedLanes)
|
.migratedTombstones(cards: movedCards)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3538,7 +3571,7 @@ public final class BoardStore: HealHost {
|
|||||||
return !document.deleted.isMissing
|
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`).
|
/// 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
|
/// `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
|
return work
|
||||||
.enumerated()
|
.enumerated()
|
||||||
.filter { $0.element.kind == .card }
|
|
||||||
.sorted { lhs, rhs in
|
.sorted { lhs, rhs in
|
||||||
let left = lhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
|
let left = stamps[lhs.element.cardID] ?? .distantPast
|
||||||
let right = rhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
|
let right = stamps[rhs.element.cardID] ?? .distantPast
|
||||||
return left == right ? lhs.offset < rhs.offset : left < right
|
return left == right ? lhs.offset < rhs.offset : left < right
|
||||||
}
|
}
|
||||||
.map(\.element)
|
.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
|
/// 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").
|
/// 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
|
/// 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:
|
/// rather than selecting the trash wholesale on a guess. **Select All is card-scoped in both
|
||||||
/// lanes are never trashed, so every trash row is a card.
|
/// 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
|
/// 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,
|
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
|
||||||
|
|||||||
@@ -158,8 +158,9 @@ public final class HealScheduler {
|
|||||||
case none
|
case none
|
||||||
/// Loss row, warning tone: files were moved into a card's `attachments/`.
|
/// Loss row, warning tone: files were moved into a card's `attachments/`.
|
||||||
case relocatedLooseFiles([BannerCenter.Relocation])
|
case relocatedLooseFiles([BannerCenter.Relocation])
|
||||||
/// Loss row, warning tone: legacy `deleted:` keys were resolved.
|
/// Loss row, warning tone: legacy `deleted:` keys on **cards** were resolved (the lane half
|
||||||
case migratedTombstones(cards: [String?], lanes: [String?])
|
/// 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.
|
/// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
|
||||||
case displacedClaimedNames([BannerCenter.Displacement])
|
case displacedClaimedNames([BannerCenter.Displacement])
|
||||||
/// Loss row, warning tone: withheld duplicate ids were reminted, named by title.
|
/// Loss row, warning tone: withheld duplicate ids were reminted, named by title.
|
||||||
@@ -178,8 +179,8 @@ public final class HealScheduler {
|
|||||||
break
|
break
|
||||||
case let .relocatedLooseFiles(relocations):
|
case let .relocatedLooseFiles(relocations):
|
||||||
banners.postRelocatedLooseFiles(relocations)
|
banners.postRelocatedLooseFiles(relocations)
|
||||||
case let .migratedTombstones(cards, lanes):
|
case let .migratedTombstones(cards):
|
||||||
banners.postMigratedTombstones(cards: cards, lanes: lanes)
|
banners.postMigratedTombstones(cards: cards)
|
||||||
case let .displacedClaimedNames(displacements):
|
case let .displacedClaimedNames(displacements):
|
||||||
banners.postDisplacedClaimedNames(displacements)
|
banners.postDisplacedClaimedNames(displacements)
|
||||||
case let .remintedDuplicateIDs(titles):
|
case let .remintedDuplicateIDs(titles):
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import Foundation
|
|||||||
/// single container rule replacing the old liveness law, because Delete would otherwise mean two
|
/// single container rule replacing the old liveness law, because Delete would otherwise mean two
|
||||||
/// different things in one gesture (move-to-trash vs permanent)".
|
/// 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 §
|
/// **Both kinds live on both sides** (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes trash
|
||||||
/// Trash), so the trash's universe is cards and nothing else — which is why the trash needs no
|
/// too"): the trash's universe is its cards *and* its trashed lanes, flat and interleaved by rank.
|
||||||
/// kind axis of its own any more.
|
/// 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
|
/// `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
|
/// 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*
|
/// held to (02-architecture.md § Live-reload resilience: "re-resolution matches UUID *and*
|
||||||
/// container side ... presence in the snapshot is the whole question").
|
/// 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
|
/// One walk, no filtering: the board side is the lanes plus their cards, the trash side is its
|
||||||
/// `snapshot.trash`. There is deliberately no liveness predicate anywhere in here — a legacy
|
/// cards **and its trashed lanes** — every id the container holds, which is what a universe is.
|
||||||
/// `deleted:` key still riding in from an unmigrated board (`BoardLoader`'s migration window)
|
/// A trashed lane's own cards are deliberately not in it: they are not in the snapshot at all
|
||||||
/// names an ordinary board card until its folder actually moves, which is the safe direction and
|
/// (the entry is opaque — 03-board-ui.md § Trash), so a selected or cut card whose lane was
|
||||||
/// the one the migration then takes (01-storage-format.md § Deletion).
|
/// trashed leaves every referencing set by the ordinary vanish rule, with no clause of its own.
|
||||||
|
///
|
||||||
|
/// There is deliberately no liveness predicate anywhere in here — a legacy `deleted:` key still
|
||||||
|
/// riding in from an unmigrated board (`BoardLoader`'s migration window) names an ordinary board
|
||||||
|
/// card until its folder actually moves, which is the safe direction and the one the migration
|
||||||
|
/// then takes (01-storage-format.md § Deletion).
|
||||||
public func ids(in snapshot: BoardModel) -> Set<ItemID> {
|
public func ids(in snapshot: BoardModel) -> Set<ItemID> {
|
||||||
var universe: Set<ItemID> = []
|
var universe: Set<ItemID> = []
|
||||||
switch self {
|
switch self {
|
||||||
@@ -58,6 +65,9 @@ extension ItemContainer {
|
|||||||
for card in snapshot.trash {
|
for card in snapshot.trash {
|
||||||
universe.insert(card.id)
|
universe.insert(card.id)
|
||||||
}
|
}
|
||||||
|
for lane in snapshot.trashedLanes {
|
||||||
|
universe.insert(lane.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return universe
|
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
|
/// 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).
|
/// 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** —
|
/// **Four cases, because the board has exactly four kinds of place an identity-bearing folder can
|
||||||
/// `<root>/<lane>`, `<root>/<lane>/<card>`, and `<root>/.trash/<card>`. The old two-optional-fields
|
/// be** — `<root>/<lane>`, `<root>/<lane>/<card>`, and, since lanes rejoined the trash (2026-07-29),
|
||||||
/// shape could spell a fourth thing that does not exist; this cannot.
|
/// `<root>/.trash/<card>` and `<root>/.trash/<lane>`. The old two-optional-fields shape could spell
|
||||||
|
/// things that do not exist; this cannot.
|
||||||
|
///
|
||||||
|
/// **The trash's two cases share a path *shape* and differ in kind**, which is exactly the fact
|
||||||
|
/// `kind:` exists to record (01-storage-format.md § Deletion): `.trash/` is flat, so the path alone
|
||||||
|
/// cannot say what an entry is, and a caller that has resolved one through the snapshot knows —
|
||||||
|
/// which is why the kind is in the case rather than re-derived from disk at every use.
|
||||||
public enum ItemPath: Sendable, Equatable {
|
public enum ItemPath: Sendable, Equatable {
|
||||||
|
|
||||||
/// A lane: `<root>/<lane>/`.
|
/// A lane: `<root>/<lane>/`.
|
||||||
@@ -85,25 +101,36 @@ public enum ItemPath: Sendable, Equatable {
|
|||||||
/// A card in the board's trash: `<root>/.trash/<card>/`.
|
/// A card in the board's trash: `<root>/.trash/<card>/`.
|
||||||
case trashCard(ItemID)
|
case trashCard(ItemID)
|
||||||
|
|
||||||
|
/// A lane in the board's trash: `<root>/.trash/<lane>/` — an opaque unit, subtree intact
|
||||||
|
/// (03-board-ui.md § Trash).
|
||||||
|
case trashLane(ItemID)
|
||||||
|
|
||||||
/// The item this path names.
|
/// The item this path names.
|
||||||
public var id: ItemID {
|
public var id: ItemID {
|
||||||
switch self {
|
switch self {
|
||||||
case let .lane(id): id
|
case let .lane(id): id
|
||||||
case let .card(_, id): id
|
case let .card(_, id): id
|
||||||
case let .trashCard(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 {
|
public var isLane: Bool {
|
||||||
if case .lane = self { return true }
|
switch self {
|
||||||
return false
|
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
|
/// Which container this path is in — the board for a lane or a lane's card, the trash for
|
||||||
/// trash card. Derived rather than stored: the case *is* the answer.
|
/// either kind of trash entry. Derived rather than stored: the case *is* the answer.
|
||||||
public var container: ItemContainer {
|
public var container: ItemContainer {
|
||||||
if case .trashCard = self { return .trash }
|
switch self {
|
||||||
return .board
|
case .trashCard, .trashLane: .trash
|
||||||
|
case .lane, .card: .board
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This path resolved under a board root.
|
/// This path resolved under a board root.
|
||||||
@@ -115,7 +142,7 @@ public enum ItemPath: Sendable, Equatable {
|
|||||||
root
|
root
|
||||||
.appendingPathComponent(lane.rawValue, isDirectory: true)
|
.appendingPathComponent(lane.rawValue, isDirectory: true)
|
||||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||||
case let .trashCard(id):
|
case let .trashCard(id), let .trashLane(id):
|
||||||
BoardWriter.trashFolder(inBoard: root)
|
BoardWriter.trashFolder(inBoard: root)
|
||||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
.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
|
/// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice
|
||||||
/// for the same reason).
|
/// 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:
|
/// 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.
|
/// a selection the next reload will drop writes nothing rather than being refused.
|
||||||
public static func resolve(
|
public static func resolve(
|
||||||
@@ -149,8 +181,11 @@ extension ItemPath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .trash:
|
case .trash:
|
||||||
for card in snapshot.trash where ids.contains(card.id) {
|
let entries = snapshot.trash.map { (order: $0.order, path: ItemPath.trashCard($0.id)) }
|
||||||
result.append(.trashCard(card.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
|
return result
|
||||||
@@ -166,6 +201,7 @@ extension ItemPath {
|
|||||||
if lane.id == id { return .lane(id) }
|
if lane.id == id { return .lane(id) }
|
||||||
if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ import Foundation
|
|||||||
///
|
///
|
||||||
/// ### What is *not* the predicate's business
|
/// ### 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
|
/// **A live lane is never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss
|
||||||
/// query stays on the board showing an empty body and a `0` badge, because the width division is
|
/// 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
|
/// layout and the filter is content.
|
||||||
/// 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 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 {
|
public struct SearchFilter: Sendable, Equatable {
|
||||||
|
|
||||||
/// The query exactly as typed — kept so a caller can key a transaction or a test on it.
|
/// 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)
|
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
|
// MARK: - The visible universe
|
||||||
|
|
||||||
/// Every id the filter leaves visible in `container` — **the 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) {
|
for card in snapshot.trash where matches(card) {
|
||||||
ids.insert(card.id)
|
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
|
return ids
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,12 +59,13 @@ public enum ClickModifier: Sendable, Equatable {
|
|||||||
/// (`SelectionGrammarTests`).
|
/// (`SelectionGrammarTests`).
|
||||||
///
|
///
|
||||||
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
|
/// **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
|
/// homogeneous on **two** axes — cards XOR lanes (§ Selection) and board XOR trash (§ The trash's
|
||||||
/// trash's "single container rule replacing the old liveness law") — and the third, kind-inside-the
|
/// "single container rule replacing the old liveness law") — and since lanes rejoined the trash
|
||||||
/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
|
/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is
|
||||||
/// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below
|
/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a
|
||||||
/// is ever mixed: a modifier that would cross an axis degrades to a replace, which is the only
|
/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier
|
||||||
/// answer that keeps the invariant true without silently dropping what the user asked for.
|
/// 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
|
/// **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,
|
/// 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
|
/// 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.
|
/// 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`.
|
/// **The lane list takes no filter**, because a card query hides no *live* lane — see
|
||||||
/// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed"
|
/// `SearchFilter`. **`(.trash, .lane)` is the trash's lane rows** (04-interactions.md ▸ The
|
||||||
/// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to.
|
/// 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(
|
public static func order(
|
||||||
of kind: SelectionKind,
|
of kind: SelectionKind,
|
||||||
in container: ItemContainer,
|
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
|
/// 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.
|
/// 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
|
/// **The trash answers `.card` or nothing** while its lane rows are not yet addressable — the
|
||||||
/// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
|
/// 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? {
|
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
|
||||||
guard !selection.isEmpty else { return nil }
|
guard !selection.isEmpty else { return nil }
|
||||||
switch selection.container {
|
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
|
/// 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.
|
/// 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
|
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the
|
||||||
/// lane rows in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind;
|
/// side it started on and selects cards only (as the board marquee does); lane rows join by click
|
||||||
/// lanes are never trashed now, so both containers hold cards and the rule is one line for both.
|
/// 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 {
|
public enum MarqueeMath {
|
||||||
|
|
||||||
/// The ids `rect` sweeps.
|
/// The ids `rect` sweeps.
|
||||||
|
|||||||
@@ -10,25 +10,95 @@ import Foundation
|
|||||||
/// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a
|
/// 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
|
/// 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
|
/// 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
|
/// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes` —
|
||||||
/// parse the lanes use, already in `order` display order, already newest-first because every arrival
|
/// already parsed by the loader, already in `order` display order, already newest-first because
|
||||||
/// mints a rank above the current top. There is nothing to derive, and no second definition to keep
|
/// every arrival mints a rank above the current top. There is nothing to derive, and no second
|
||||||
/// in step with the loader's.
|
/// 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
|
/// 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
|
/// confirmations' phrasing — which since lanes rejoined the trash (2026-07-29) has to **count the
|
||||||
/// functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is testable
|
/// freight** a trashed lane carries — and the menu validation that stages Delete by place. Both are
|
||||||
/// without an alert on screen.
|
/// 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 {
|
public enum TrashModel {
|
||||||
|
|
||||||
// MARK: - Counts and phrasing
|
// MARK: - Counts and phrasing
|
||||||
|
|
||||||
/// "41 cards", "1 card" — 06-history-undo.md's **plural folding**, which is all the folding a
|
/// "41 cards", "1 card" — 06-history-undo.md's **plural folding**.
|
||||||
/// cards-only container can need ("Cards only. Lanes are never trashed" — 03-board-ui.md).
|
|
||||||
public static func phrase(_ count: Int) -> String {
|
public static func phrase(_ count: Int) -> String {
|
||||||
"\(count) card\(count == 1 ? "" : "s")"
|
"\(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
|
// MARK: - Confirmations
|
||||||
|
|
||||||
/// A purge confirmation's three strings, built once and rendered by the window's alert.
|
/// 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
|
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
|
/// Trash: "confirms exactly where the loss is real: the alert stands between one keystroke and
|
||||||
/// unrecoverable deletion").
|
/// unrecoverable deletion").
|
||||||
///
|
///
|
||||||
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in the
|
/// A sole entry is **named**; several fold into a count. A sole trashed **lane** names its
|
||||||
/// trash, which is also the command's own refusal — so the prompt and the action can never
|
/// freight as well — "Permanently delete lane 'Doing' and its 5 cards" is the design's own
|
||||||
/// disagree about whether there is anything to purge.
|
/// 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(
|
public static func purgePrompt(
|
||||||
for ids: Set<ItemID>,
|
for ids: Set<ItemID>,
|
||||||
snapshot: BoardModel,
|
snapshot: BoardModel,
|
||||||
unrecoverable: Bool
|
unrecoverable: Bool
|
||||||
) -> PurgePrompt? {
|
) -> 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 }
|
guard !targets.isEmpty else { return nil }
|
||||||
|
|
||||||
let subject: String
|
let subject: String
|
||||||
if targets.count == 1, let only = targets.first {
|
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 {
|
} else {
|
||||||
subject = phrase(targets.count)
|
subject = self.subject(for: freight(of: targets, in: snapshot))
|
||||||
}
|
}
|
||||||
return PurgePrompt(
|
return PurgePrompt(
|
||||||
title: "Permanently delete \(subject)?",
|
title: "Permanently delete \(subject)?",
|
||||||
@@ -70,15 +154,23 @@ public enum TrashModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Empty Trash…'s alert — **always shown** ("Empty Trash… confirms everywhere"), and always
|
/// 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 §
|
/// naming the **true count**: every entry in `.trash/`, never the filtered view (03-board-ui.md §
|
||||||
/// Trash: "search-independent, the confirmation naming the card count").
|
/// 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
|
/// 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? {
|
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(
|
return PurgePrompt(
|
||||||
title: "Permanently delete \(phrase(snapshot.trash.count))?",
|
title: "Permanently delete \(subject(for: freight))?",
|
||||||
message: message(unrecoverable: unrecoverable),
|
message: message(unrecoverable: unrecoverable),
|
||||||
confirmTitle: "Delete"
|
confirmTitle: "Delete"
|
||||||
)
|
)
|
||||||
@@ -87,9 +179,9 @@ public enum TrashModel {
|
|||||||
/// The alert's body: whether any of it comes back.
|
/// 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" —
|
/// 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
|
/// stays gone, and now for a better reason than "no purge reaches a lane": the *title* carries
|
||||||
/// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with
|
/// the freight explicitly ("and its 5 cards"), which is 03's own phrasing and says the same
|
||||||
/// undo as its net).
|
/// thing where the user is already reading.
|
||||||
private static func message(unrecoverable: Bool) -> String {
|
private static func message(unrecoverable: Bool) -> String {
|
||||||
// m7-git: on a git board the content stays reachable in history, so the second sentence is
|
// 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
|
// 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."
|
: "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
|
/// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
|
||||||
/// the app gives.
|
/// the app gives.
|
||||||
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
|
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"
|
.cards.first { $0.id == id }?.title.value ?? "Untitled"
|
||||||
case let .trashCard(id):
|
case let .trashCard(id):
|
||||||
return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
|
return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
|
||||||
|
case let .trashLane(id):
|
||||||
|
return snapshot.trashedLanes.first { $0.id == id }?.title.value ?? "Untitled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,30 +46,34 @@ import os
|
|||||||
///
|
///
|
||||||
/// ## The trash is a container, not a level
|
/// ## The trash is a container, not a level
|
||||||
///
|
///
|
||||||
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly
|
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
|
||||||
/// (01-storage-format.md § Deletion, resettled 2026-07-28) — "same shape as a lane's children, no
|
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
|
||||||
/// `index.md` of its own". The walk therefore treats it as a second card container beside the
|
/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside
|
||||||
/// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane
|
/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the
|
||||||
/// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands
|
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
|
||||||
/// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and
|
/// warns; absent, the trash is simply empty.
|
||||||
/// 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 migration window
|
||||||
///
|
///
|
||||||
/// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on
|
/// The tombstone model is retired: no `deleted:` key is ever written again. A key found on a **card**
|
||||||
/// load is *migration input* — a card relocates into `.trash/` with the key removed, a lane
|
/// is *migration input* — the card relocates into `.trash/` with the key removed. A key on a lane or
|
||||||
/// returns live with the key removed, a board-level key stays meaningless (ignored + logged).
|
/// on the board is meaningless and stays where it is (ignored + logged, the tolerate tier: lanes
|
||||||
/// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports
|
/// stopped migrating on 2026-07-29, when they gained a real trash of their own). Detection is
|
||||||
/// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated
|
/// read-only here, the loose-file carve-out's posture exactly: this loader reports what it found
|
||||||
/// fix.
|
/// through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated fix.
|
||||||
///
|
///
|
||||||
/// **Until that write lands, such items still load through the retiring tombstone path** —
|
/// **Until that write lands, such cards still load through the retiring tombstone path** — a
|
||||||
/// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a
|
/// `deleted:`-carrying card stays filed under its lane with `isDeleted` set. That is a deliberate
|
||||||
/// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy",
|
/// intermediate, not an oversight: the migration's whole promise is "never destroy", and the safe
|
||||||
/// and the safe direction while the fix is still pending (it is deferred under any read-only lock,
|
/// direction while the fix is still pending (it is deferred under any read-only lock, and can be
|
||||||
/// and can be pending for a whole session) is for nothing to silently vanish from view before its
|
/// pending for a whole session) is for nothing to silently vanish from view before its folder has
|
||||||
/// folder has actually moved. The window closes per board on the first successful migration write,
|
/// 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`.
|
|
||||||
public enum BoardLoader: Sendable {
|
public enum BoardLoader: Sendable {
|
||||||
|
|
||||||
/// Schema version this app understands; anything higher fails fast
|
/// 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.
|
// and the key's removal are the store's, through the Writer.
|
||||||
if card.isDeleted {
|
if card.isDeleted {
|
||||||
defects.append(.legacyTombstone(LegacyTombstone(
|
defects.append(.legacyTombstone(LegacyTombstone(
|
||||||
kind: .card,
|
|
||||||
laneID: ItemID(rawValue: laneName),
|
laneID: ItemID(rawValue: laneName),
|
||||||
cardID: ItemID(rawValue: cardName),
|
cardID: ItemID(rawValue: cardName),
|
||||||
title: card.title.value
|
title: card.title.value
|
||||||
@@ -292,14 +295,14 @@ public enum BoardLoader: Sendable {
|
|||||||
cards.append(card)
|
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 {
|
if !laneDocument.deleted.isMissing {
|
||||||
defects.append(.legacyTombstone(LegacyTombstone(
|
warn(.laneLevelDeletedIgnored(path: laneName))
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
walkedLanes.append(WalkedLane(
|
walkedLanes.append(WalkedLane(
|
||||||
@@ -312,30 +315,86 @@ public enum BoardLoader: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var trash: [Card] = []
|
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] = [:]
|
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||||
for cardURL in trashCandidates(in: boardRoot) {
|
for entryURL in trashCandidates(in: boardRoot) {
|
||||||
let cardName = cardURL.lastPathComponent
|
let entryName = entryURL.lastPathComponent
|
||||||
let cardRelPath = trashFolderName + "/" + cardName
|
let entryRelPath = trashFolderName + "/" + entryName
|
||||||
guard isUUIDShaped(cardName) else {
|
guard isUUIDShaped(entryName) else {
|
||||||
warn(.nonUUIDFolderIgnored(path: cardRelPath))
|
warn(.nonUUIDFolderIgnored(path: entryRelPath))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guard hasIndex(cardURL) else {
|
guard hasIndex(entryURL) else {
|
||||||
warn(.missingIndex(path: cardRelPath))
|
warn(.missingIndex(path: entryRelPath))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let entry = try parseCard(at: cardURL, path: cardRelPath)
|
// The two kinds are validated identically — `schema` and `order` are required of both
|
||||||
noteCoercions(in: entry.document, at: cardRelPath + "/" + indexFileName)
|
// (`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**
|
// **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
|
// (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.
|
// 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(_:)`.
|
// The children are listed at most once per entry and only where an answer needs them —
|
||||||
trashKinds[entry.id] = IntegrityRules.trashKind(
|
// the shape fallback asks when `kind` did not, the lane arm asks for the count — so a
|
||||||
kindValue: entry.document.kind.value,
|
// board of trashed cards written by this app pays for no directory reads at all.
|
||||||
hasIdentityShapedChildIndex: looksLikeALaneFolder(cardURL)
|
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:
|
// **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.
|
// 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 orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
|
||||||
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue })
|
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(
|
let verdict = dedupeIdentities(
|
||||||
inBoardAt: boardRoot,
|
inBoardAt: boardRoot,
|
||||||
lanes: orderedLanes,
|
lanes: orderedLanes,
|
||||||
trash: orderedTrash,
|
trash: orderedTrashEntries,
|
||||||
historyRanker: historyRanker
|
historyRanker: historyRanker
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -386,6 +450,7 @@ public enum BoardLoader: Sendable {
|
|||||||
template: boardDocument.value(for: templateKey),
|
template: boardDocument.value(for: templateKey),
|
||||||
lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
|
lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
|
||||||
trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
|
trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
|
||||||
|
trashedLanes: orderedTrashedLanes.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
|
||||||
document: boardDocument
|
document: boardDocument
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -430,7 +495,7 @@ public enum BoardLoader: Sendable {
|
|||||||
private static func dedupeIdentities(
|
private static func dedupeIdentities(
|
||||||
inBoardAt root: URL,
|
inBoardAt root: URL,
|
||||||
lanes: [WalkedLane],
|
lanes: [WalkedLane],
|
||||||
trash: [Card],
|
trash: [(id: ItemID, title: String?, order: Double)],
|
||||||
historyRanker: IdentityHistoryRanker?
|
historyRanker: IdentityHistoryRanker?
|
||||||
) -> IntegrityRules.DedupeVerdict {
|
) -> IntegrityRules.DedupeVerdict {
|
||||||
typealias Container = IntegrityRules.IdentityOccurrence.Container
|
typealias Container = IntegrityRules.IdentityOccurrence.Container
|
||||||
@@ -458,7 +523,7 @@ public enum BoardLoader: Sendable {
|
|||||||
path: trashFolderName + "/" + entry.id.rawValue,
|
path: trashFolderName + "/" + entry.id.rawValue,
|
||||||
name: entry.id.rawValue,
|
name: entry.id.rawValue,
|
||||||
container: .trashed,
|
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
|
/// A folder's **identity-shaped children holding their own `index.md`** — the cards a trash
|
||||||
/// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with
|
/// entry would render as, which is two answers in one listing (01-storage-format.md § Deletion:
|
||||||
/// their own `index.md` → lane … else card").
|
/// "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 *holds*, not whether anything inside it
|
||||||
/// deliberately not a parse: this asks what the folder *looks like*, not whether anything inside
|
/// would load. A trashed lane's cards are never enumerated as levels — the walk stops at a trash
|
||||||
/// it would load. A trashed lane's cards are never enumerated as levels — the walk stops at a
|
/// entry exactly as it stops at a card under a lane — so the count is a fact about the freight
|
||||||
/// trash entry exactly as it stops at a card under a lane.
|
/// and the entry stays opaque.
|
||||||
private static func looksLikeALaneFolder(_ folder: URL) -> Bool {
|
private static func identityShapedChildren(of folder: URL) -> [URL] {
|
||||||
let children = (try? directoryCandidates(in: folder)) ?? []
|
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
|
/// The trash's own walk reads its entries inline instead, because the container is flat and its
|
||||||
/// function is what makes that literally true rather than a claim two code paths have to keep
|
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict
|
||||||
/// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the
|
/// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a
|
||||||
/// same verbatim document. Its callers keep what genuinely differs by container — the
|
/// function that has already decided what it is reading.
|
||||||
/// lane-keyed loose-file and legacy-tombstone channels — outside it.
|
|
||||||
///
|
///
|
||||||
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
|
/// `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.
|
/// 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 `<root>/.trash/`, or `[]` when there is no trash.
|
/// The candidate entry folders inside `<root>/.trash/`, or `[]` when there is no trash.
|
||||||
///
|
///
|
||||||
/// **Absent is empty, not an error** — the container is minted by the first delete, so most
|
/// **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.
|
/// 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.
|
/// the "window measured in one reload, not a standing state" the ruling accepts.
|
||||||
///
|
///
|
||||||
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
/// 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,
|
/// 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
|
/// **Nothing below an entry is ever enumerated as a level**: the walk stops at a trash entry
|
||||||
/// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are
|
/// exactly as it stops at a card under a lane, so a lane-shaped folder here reads as one opaque
|
||||||
/// never enumerated, because the walk stops at a card exactly as it does under a lane; the
|
/// entry (a trashed lane, by `kind` or by shape) and its cards are counted rather than walked.
|
||||||
/// outer folder either parses as a card (it has an `index.md`) or is skipped as
|
|
||||||
/// `.missingIndex`, and either way nothing below it renders.
|
|
||||||
private static func trashCandidates(in boardRoot: URL) -> [URL] {
|
private static func trashCandidates(in boardRoot: URL) -> [URL] {
|
||||||
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
|
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
|
||||||
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
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
|
/// (`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.
|
/// `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
|
/// **The reading the snapshot's own split is made of**: `BoardModel.trash` holds the entries
|
||||||
/// parse (a trashed card is "an ordinary card in a special place"), and the container is flat,
|
/// this called a card and `BoardModel.trashedLanes` the ones it called a lane. It is carried out
|
||||||
/// so this is where the answer to "which of these was a lane?" lives until the lanes-in-trash
|
/// of the load as well because it is the *verdict* rather than its consequence — a suite pins
|
||||||
/// surface consumes it. Keyed by identity, so it survives the display sort.
|
/// 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] = [:]
|
public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||||
|
|
||||||
/// The cards this walk found holding loose files — a **view over `defects`**, under the name it
|
/// 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 } }
|
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,
|
/// The legacy `deleted:` keys this walk found on **cards** — the retired tombstone model's
|
||||||
/// as a **view over `defects`** (01-storage-format.md § Deletion).
|
/// 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] {
|
public var legacyTombstones: [LegacyTombstone] {
|
||||||
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
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.
|
/// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
|
||||||
case boardLevelDeletedIgnored
|
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 —
|
/// 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*
|
/// 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
|
/// 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"
|
"\(path): folder name is not UUID-shaped, ignored as a stray"
|
||||||
case .boardLevelDeletedIgnored:
|
case .boardLevelDeletedIgnored:
|
||||||
"index.md: board-level 'deleted' key is meaningless, ignored"
|
"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):
|
case let .caseTwinIgnored(path, winner):
|
||||||
"\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
|
"\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,21 +98,29 @@ public struct BoardModel: Sendable, Equatable {
|
|||||||
|
|
||||||
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
||||||
///
|
///
|
||||||
/// **Legacy tombstones ride along flagged during the migration window.** A lane still
|
/// **A lane carrying a legacy `deleted:` key is here, live** (01-storage-format.md § Deletion,
|
||||||
/// carrying a `deleted:` key loads here with `Lane.isDeleted` set, exactly as it did under
|
/// lane clause re-ruled 2026-07-29): the key is ignored outright — no migration, no notice, no
|
||||||
/// the retired tombstone model, *and* is reported through `LoadResult.legacyTombstones` — see
|
/// write — so `Lane.isDeleted` describes the bytes and decides nothing. A *card* carrying one
|
||||||
/// `BoardLoader`'s "The migration window" note for why both happen at once and what ends it.
|
/// still rides along flagged until its migration relocates it (`LoadResult.legacyTombstones` —
|
||||||
|
/// see `BoardLoader`'s "The migration window" note).
|
||||||
public let lanes: [Lane]
|
public let lanes: [Lane]
|
||||||
|
|
||||||
/// The board's **materialized trash**: the card folders sitting directly in
|
/// The board's **materialized trash**, card side: the card folders sitting directly in
|
||||||
/// `<root>/.trash/`, in display order (01-storage-format.md § Deletion, resettled
|
/// `<root>/.trash/`, in display order (01-storage-format.md § Deletion, resettled
|
||||||
/// 2026-07-28; 03-board-ui.md § Trash).
|
/// 2026-07-28; 03-board-ui.md § Trash).
|
||||||
///
|
///
|
||||||
/// **A sibling container of `lanes`, not a lane.** `.trash/` is a reserved, app-claimed name
|
/// **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
|
/// at board root that "holds card and lane folders interleaved directly, no `index.md` of its
|
||||||
/// `index.md` of its own", so it has no identity, no title, no `order`, and no frontmatter to
|
/// own", so it has no identity, no title, no `order`, and no frontmatter to model: the
|
||||||
/// model: the container *is* the list. That is why this is `[Card]` rather than a `Lane` or a
|
/// container *is* the list. That is why this is `[Card]` rather than a `Lane` or a `Trash`
|
||||||
/// `Trash` struct — there is nothing for either to carry that this array does not.
|
/// 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`,
|
/// **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
|
/// 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.
|
/// usable from tests and future fixtures that have no trash to describe.
|
||||||
public var trash: [Card] = []
|
public var trash: [Card] = []
|
||||||
|
|
||||||
|
/// The board's materialized trash, **lane side**: the lane folders sitting directly in
|
||||||
|
/// `<root>/.trash/`, in display order (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes
|
||||||
|
/// trash too", retiring the design's sole destructive delete).
|
||||||
|
///
|
||||||
|
/// See `TrashedLane` for why a trashed lane is not a `Lane`, and `trash` for why the container's
|
||||||
|
/// two kinds are two arrays.
|
||||||
|
public var trashedLanes: [TrashedLane] = []
|
||||||
|
|
||||||
/// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
|
/// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
|
||||||
/// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer
|
/// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer
|
||||||
/// can round-trip them without this model knowing what they mean.
|
/// 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`.
|
/// The lane description / WIP policy / notes — equivalent to `document.body`.
|
||||||
public var body: String { document.body }
|
public var body: String { document.body }
|
||||||
|
|
||||||
/// A tombstoned lane: the `deleted` key is *present* (valid or malformed), not merely
|
/// Whether this lane carries a legacy `deleted:` key — presence, not validity
|
||||||
/// absent. Deliberate: a malformed timestamp still hides the lane from the board — the
|
/// (`FieldValue.isMissing` already treats an explicit `deleted: null` as absent).
|
||||||
/// 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).
|
|
||||||
///
|
///
|
||||||
/// **Retiring.** The tombstone model is retired (01-storage-format.md § Deletion, resettled
|
/// **Retired, and on a lane the key is now inert** (01-storage-format.md § Deletion, lane clause
|
||||||
/// 2026-07-28): the app never writes a `deleted:` key again, and one found on load is
|
/// re-ruled 2026-07-29): the app never writes `deleted:`, and a lane found carrying one "simply
|
||||||
/// migration input reported through `LoadResult.legacyTombstones` (a lane's migration removes
|
/// loads live with the key ignored — no migration machinery, no key-strip write, no notice",
|
||||||
/// the key and returns the lane live). This accessor survives only for the migration window,
|
/// preserved verbatim like any unhandled key and logged (`LoadWarning.laneLevelDeletedIgnored`).
|
||||||
/// during which the retiring tombstone renderers still read it; it becomes permanently
|
/// So this reads the key without meaning anything by it: nothing hides a lane, nothing rewrites
|
||||||
/// `false` for every board once the migration has run, and goes away with its last consumer.
|
/// it, and the field survives only so the value round-trips.
|
||||||
public var isDeleted: Bool { !deleted.isMissing }
|
public var isDeleted: Bool { !deleted.isMissing }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,8 +251,50 @@ public struct Card: Identifiable, Sendable, Equatable {
|
|||||||
public var body: String { document.body }
|
public var body: String { document.body }
|
||||||
|
|
||||||
/// A tombstoned card. See `Lane.isDeleted`'s doc comment — the same "presence, not
|
/// 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
|
/// validity" rule applies here. A card's key is still migration input (a card carrying it
|
||||||
/// `BoardModel.trash` and carries no `deleted:` key at all, so this reads `false` for every
|
/// relocates into `.trash/` with the key removed), which is the one half of the legacy rule
|
||||||
/// card in the container that replaced the flag.
|
/// 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 }
|
public var isDeleted: Bool { !deleted.isMissing }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A lane in the board's trash: `<root>/.trash/<guid>/index.md` — **an opaque unit**
|
||||||
|
/// (03-board-ui.md § Trash, re-ruled 2026-07-29: "A trashed lane is an opaque unit: one distinct
|
||||||
|
/// dimmed row showing its title and held-card count … never expandable; its cards are invisible to
|
||||||
|
/// search and not individually addressable — it restores whole or purges whole").
|
||||||
|
///
|
||||||
|
/// ### Why it is not a `Lane`
|
||||||
|
///
|
||||||
|
/// A `Lane` carries its cards, its styling and its width because the board renders all three. None
|
||||||
|
/// of that is true here: the row shows a title and a count, takes no styling accents, and its
|
||||||
|
/// subtree is deliberately **not walked into the snapshot** — the loader stops at the trash entry
|
||||||
|
/// exactly as it stops at a card under a lane. A `Lane` with an empty `cards` array would be a lie
|
||||||
|
/// the first consumer to read it would believe; this type can only answer what the design says the
|
||||||
|
/// row knows.
|
||||||
|
///
|
||||||
|
/// The kind itself is `kind:`'s to answer, never position's: the trash is flat, and an empty lane
|
||||||
|
/// folder is shape-identical to a card folder (01-storage-format.md § Deletion —
|
||||||
|
/// `IntegrityRules.trashKind`).
|
||||||
|
public struct TrashedLane: Identifiable, Sendable, Equatable {
|
||||||
|
public let id: ItemID
|
||||||
|
|
||||||
|
public let schema: Int
|
||||||
|
public let title: FieldValue<String>
|
||||||
|
|
||||||
|
/// Rank within the trash, ascending = top to bottom — the same required, strictly validated
|
||||||
|
/// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's
|
||||||
|
/// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost.
|
||||||
|
public let order: Double
|
||||||
|
|
||||||
|
/// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards").
|
||||||
|
///
|
||||||
|
/// Counted from disk at load, never derived from a walked subtree: the count is the one fact
|
||||||
|
/// about the freight the snapshot carries, and counting is what keeps the entry opaque. The
|
||||||
|
/// unit counted is what the loader *would* render as a card — an identity-shaped child holding
|
||||||
|
/// its own `index.md` — so the row's number and a restore's outcome agree.
|
||||||
|
public let heldCards: Int
|
||||||
|
|
||||||
|
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted, so a restore
|
||||||
|
/// (an ordinary move out) returns the lane exactly as it went in.
|
||||||
|
public let document: FrontmatterDocument
|
||||||
|
}
|
||||||
|
|||||||
+107
-352
@@ -1046,9 +1046,9 @@ public enum BoardWriter: Sendable {
|
|||||||
/// The sequence, which is the contract:
|
/// The sequence, which is the contract:
|
||||||
///
|
///
|
||||||
/// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
|
/// 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
|
/// *under* a UUID-shaped parent). A board root and a stray are refused here, and a lane takes
|
||||||
/// rather than a policy the caller has to remember — a lane, a board root and a stray are
|
/// the sibling door (`deleteLaneToTrash`) — same move, different guard and a different `kind`
|
||||||
/// all refused here, and lane deletion has its own call (`removeLane`).
|
/// 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
|
/// 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
|
/// 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.
|
/// travels. `moveItem`'s discover-before-you-write rule, for its reason.
|
||||||
@@ -1088,9 +1088,48 @@ public enum BoardWriter: Sendable {
|
|||||||
inBoard boardRoot: URL,
|
inBoard boardRoot: URL,
|
||||||
order: Double
|
order: Double
|
||||||
) throws(BoardWriteError) -> ItemID {
|
) throws(BoardWriteError) -> ItemID {
|
||||||
try moveCardIntoTrash(
|
try moveIntoTrash(
|
||||||
at: cardFolder,
|
at: cardFolder,
|
||||||
inBoard: boardRoot,
|
inBoard: boardRoot,
|
||||||
|
kind: .card,
|
||||||
|
order: order,
|
||||||
|
operation: .delete(title: nil),
|
||||||
|
removingLegacyKey: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Deleting a lane: the same physical move into `<board-root>/.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,
|
order: order,
|
||||||
operation: .delete(title: nil),
|
operation: .delete(title: nil),
|
||||||
removingLegacyKey: false
|
removingLegacyKey: false
|
||||||
@@ -1117,29 +1156,39 @@ public enum BoardWriter: Sendable {
|
|||||||
inBoard boardRoot: URL,
|
inBoard boardRoot: URL,
|
||||||
order: Double
|
order: Double
|
||||||
) throws(BoardWriteError) -> ItemID {
|
) throws(BoardWriteError) -> ItemID {
|
||||||
try moveCardIntoTrash(
|
try moveIntoTrash(
|
||||||
at: cardFolder,
|
at: cardFolder,
|
||||||
inBoard: boardRoot,
|
inBoard: boardRoot,
|
||||||
|
kind: .card,
|
||||||
order: order,
|
order: order,
|
||||||
operation: .migrateTombstone(title: nil),
|
operation: .migrateTombstone(title: nil),
|
||||||
removingLegacyKey: true
|
removingLegacyKey: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` — see the former for the
|
/// The shared body of the three moves into `.trash/` — `deleteCardToTrash`, `deleteLaneToTrash`
|
||||||
/// sequence and the latter for what `removingLegacyKey` adds.
|
/// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's
|
||||||
private static func moveCardIntoTrash(
|
/// `kind` is doing here, and the third for what `removingLegacyKey` adds.
|
||||||
at cardFolder: URL,
|
///
|
||||||
|
/// **`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,
|
inBoard boardRoot: URL,
|
||||||
|
kind: IntegrityRules.ObjectKind,
|
||||||
order: Double,
|
order: Double,
|
||||||
operation initialOperation: WriteOperation,
|
operation initialOperation: WriteOperation,
|
||||||
removingLegacyKey: Bool
|
removingLegacyKey: Bool
|
||||||
) throws(BoardWriteError) -> ItemID {
|
) throws(BoardWriteError) -> ItemID {
|
||||||
var operation = initialOperation
|
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 checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
|
||||||
try checkIsCardFolder(cardFolder, operation: operation)
|
switch kind {
|
||||||
operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation)
|
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)
|
let trash = trashFolder(inBoard: boardRoot)
|
||||||
do {
|
do {
|
||||||
@@ -1152,23 +1201,23 @@ public enum BoardWriter: Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = cardFolder.lastPathComponent
|
let name = itemFolder.lastPathComponent
|
||||||
let arrived = trash.appendingPathComponent(name, isDirectory: true)
|
let arrived = trash.appendingPathComponent(name, isDirectory: true)
|
||||||
do {
|
do {
|
||||||
try FileManager.default.moveItem(at: cardFolder, to: arrived)
|
try FileManager.default.moveItem(at: itemFolder, to: arrived)
|
||||||
} catch {
|
} catch {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
operation: operation,
|
||||||
path: cardFolder.path,
|
path: itemFolder.path,
|
||||||
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
|
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
|
// 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
|
// 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.
|
// absence where the item was, the shown-trash side sees an arrival where it went.
|
||||||
EchoLedger.current?.recordMove(from: cardFolder, to: arrived)
|
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))
|
document.set(FrontmatterKeys.order, to: .double(order))
|
||||||
if removingLegacyKey {
|
if removingLegacyKey {
|
||||||
document.remove(FrontmatterKeys.deleted)
|
document.remove(FrontmatterKeys.deleted)
|
||||||
@@ -1185,134 +1234,80 @@ public enum BoardWriter: Sendable {
|
|||||||
return ItemID(rawValue: name)
|
return ItemID(rawValue: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns
|
/// Permanently removes one **entry** from the trash — the trash's own **Delete**
|
||||||
/// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying
|
/// (03-board-ui.md § Trash: "on a trash selection, Delete (⌫/⌘⌫) is permanent … in the trash it
|
||||||
/// `deleted:` returns live with the key removed and a notice — resurrection is the safe
|
/// removes the folder").
|
||||||
/// 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").
|
|
||||||
///
|
///
|
||||||
/// `purgeItem` with the container checked: the folder must actually sit in this board's
|
/// `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.
|
/// (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.
|
/// 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)
|
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)
|
try checkIsUUIDShaped(entryFolder, operation: operation)
|
||||||
guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
|
guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
operation: operation,
|
||||||
path: cardFolder.path,
|
path: entryFolder.path,
|
||||||
reason: .unreadable(message: "folder is not in this board's trash")
|
reason: .unreadable(message: "folder is not in this board's trash")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: cardFolder)
|
try FileManager.default.removeItem(at: entryFolder)
|
||||||
} catch {
|
} catch {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
operation: operation,
|
||||||
path: cardFolder.path,
|
path: entryFolder.path,
|
||||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
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
|
/// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every entry in
|
||||||
/// `<board-root>/.trash/`. Returns what it removed, in folder-name order.
|
/// `<board-root>/.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/`",
|
/// **The entry 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
|
/// 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,
|
/// 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
|
/// 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
|
/// the loader recognizes as an entry 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
|
/// lane freight) and keeps this command from being the one place in the app that destroys a file
|
||||||
/// saw. The emptied container is left standing; the next delete would only recreate it.
|
/// 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.
|
/// **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
|
/// already removed stays removed, `importAttachments`' rule. A board with no trash at all
|
||||||
/// removes nothing and returns `[]`.
|
/// removes nothing and returns `[]`.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
|
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
|
||||||
let operation = WriteOperation.purge(title: nil)
|
let operation = WriteOperation.purge(title: nil)
|
||||||
var purged: [ItemID] = []
|
var purged: [ItemID] = []
|
||||||
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
for entry in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: card)
|
try FileManager.default.removeItem(at: entry)
|
||||||
} catch {
|
} catch {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
operation: operation,
|
||||||
path: card.path,
|
path: entry.path,
|
||||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
EchoLedger.current?.recordDeletion(at: card)
|
EchoLedger.current?.recordDeletion(at: entry)
|
||||||
purged.append(ItemID(rawValue: card.lastPathComponent))
|
purged.append(ItemID(rawValue: entry.lastPathComponent))
|
||||||
}
|
}
|
||||||
return purged
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// 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).
|
/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter).
|
||||||
public enum CopyStamps: Sendable {
|
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
|
/// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
|
||||||
/// an untitled item.
|
/// an untitled item.
|
||||||
case paste(title: String?)
|
case paste(title: String?)
|
||||||
/// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
|
/// ⌫ / ⌘⌫ — a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`.
|
||||||
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
|
/// 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
|
/// **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
|
/// 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 delete(title: String?)
|
||||||
case purge(title: String?)
|
case purge(title: String?)
|
||||||
|
|
||||||
/// A legacy `deleted:` key being migrated away — a card relocating into `.trash/` with the key
|
/// A legacy `deleted:` key being migrated away — a **card** relocating into `.trash/` with the
|
||||||
/// removed, or a lane getting the key stripped and returning live (01-storage-format.md
|
/// key removed (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half
|
||||||
/// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy").
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// delete 'Fix login'" would name a gesture they never made.
|
||||||
/// the opposite of deletion.
|
|
||||||
case migrateTombstone(title: String?)
|
case migrateTombstone(title: String?)
|
||||||
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
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)
|
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||||
|
|||||||
@@ -502,7 +502,8 @@ public enum IntegrityRules: Sendable {
|
|||||||
public enum Defect: Sendable, Equatable {
|
public enum Defect: Sendable, Equatable {
|
||||||
/// A card holding files that belong in its `attachments/` (the loose-file carve-out).
|
/// A card holding files that belong in its `attachments/` (the loose-file carve-out).
|
||||||
case looseCardFiles(LooseCardFiles)
|
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)
|
case legacyTombstone(LegacyTombstone)
|
||||||
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
|
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
|
||||||
case claimedNameSquatted(ClaimedNameSquatter)
|
case claimedNameSquatted(ClaimedNameSquatter)
|
||||||
@@ -567,7 +568,7 @@ public enum IntegrityRules: Sendable {
|
|||||||
case let .looseCardFiles(work):
|
case let .looseCardFiles(work):
|
||||||
work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" }
|
work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" }
|
||||||
case let .legacyTombstone(work):
|
case let .legacyTombstone(work):
|
||||||
["tombstone:\(work.laneID.rawValue)/\(work.cardID?.rawValue ?? "")"]
|
["tombstone:\(work.laneID.rawValue)/\(work.cardID.rawValue)"]
|
||||||
case let .claimedNameSquatted(work):
|
case let .claimedNameSquatted(work):
|
||||||
// The node *kind* is part of the picture: a squatter replaced by a different kind
|
// 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
|
// 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
|
/// One **card** found carrying a legacy `deleted:` key — everything its migration and notice need,
|
||||||
/// nothing more (01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write,
|
/// and nothing more (01-storage-format.md § Deletion: "a card carrying `deleted:` is relocated into
|
||||||
/// never destroy").
|
/// `.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`'
|
/// 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 {
|
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 card's **containing** lane — the context the relocation needs to find the folder at all.
|
||||||
|
|
||||||
/// 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.
|
|
||||||
public let laneID: ItemID
|
public let laneID: ItemID
|
||||||
|
|
||||||
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload so
|
/// The card itself.
|
||||||
/// the common "which folder is this" question is one path join at every call site.
|
public let cardID: ItemID
|
||||||
public let cardID: ItemID?
|
|
||||||
|
|
||||||
public let title: String?
|
public let title: String?
|
||||||
|
|
||||||
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
|
public init(laneID: ItemID, cardID: ItemID, title: String?) {
|
||||||
self.kind = kind
|
|
||||||
self.laneID = laneID
|
self.laneID = laneID
|
||||||
self.cardID = cardID
|
self.cardID = cardID
|
||||||
self.title = title
|
self.title = title
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class TrashConfirmations {
|
|||||||
/// reachable inside the trash now: the trash's own staged Delete, and Empty Trash (which names
|
/// 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).
|
/// the whole container and re-derives its targets at the moment it runs).
|
||||||
enum Action: Equatable {
|
enum Action: Equatable {
|
||||||
case deleteTrashCards(Set<ItemID>)
|
case deleteTrashEntries(Set<ItemID>)
|
||||||
case emptyTrash
|
case emptyTrash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,8 +43,8 @@ final class TrashConfirmations {
|
|||||||
/// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the
|
/// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the
|
||||||
/// trash side owes and the board side does not.
|
/// 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
|
/// A board selection goes straight through: a card and a lane alike move into the trash, and the
|
||||||
/// both recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither
|
/// 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
|
/// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where
|
||||||
/// the loss is real": `purgeIsUnrecoverable` decides.
|
/// 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).
|
/// is what a board that keeps history does for every permanent delete (delete-never-forgets).
|
||||||
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
|
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
|
||||||
guard store.purgeIsUnrecoverable else {
|
guard store.purgeIsUnrecoverable else {
|
||||||
store.deleteTrashCards(ids)
|
store.deleteTrashEntries(ids)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let prompt = TrashModel.purgePrompt(
|
guard let prompt = TrashModel.purgePrompt(
|
||||||
@@ -79,7 +79,7 @@ final class TrashConfirmations {
|
|||||||
snapshot: store.snapshot,
|
snapshot: store.snapshot,
|
||||||
unrecoverable: true
|
unrecoverable: true
|
||||||
) else { return }
|
) 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
|
/// 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 }
|
guard let pending else { return }
|
||||||
self.pending = nil
|
self.pending = nil
|
||||||
switch pending.action {
|
switch pending.action {
|
||||||
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
|
case let .deleteTrashEntries(ids): store.deleteTrashEntries(ids)
|
||||||
case .emptyTrash: store.emptyTrash()
|
case .emptyTrash: store.emptyTrash()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,9 +168,10 @@ struct TrashCommands: View {
|
|||||||
extension BoardStore {
|
extension BoardStore {
|
||||||
|
|
||||||
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash… row) —
|
/// **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:
|
/// where "non-empty" reads `.trash/` itself — **both kinds of entry**, and never the filtered
|
||||||
/// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides
|
/// view (03-board-ui.md § Trash: "menu validation's non-empty reads `.trash/`, not the filtered
|
||||||
/// every trash card leaves the command enabled and its confirmation still names the true count).
|
/// 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
|
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
|
||||||
/// invisible to every gesture".
|
/// invisible to every gesture".
|
||||||
@@ -179,7 +180,7 @@ extension BoardStore {
|
|||||||
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
|
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
|
||||||
var canEmptyTrash: Bool {
|
var canEmptyTrash: Bool {
|
||||||
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
|
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
|
||||||
return !snapshot.trash.isEmpty
|
return !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,30 @@ struct CardWindowFateTests {
|
|||||||
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
|
#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")
|
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")
|
||||||
func anAbsentCardDismisses() throws {
|
func anAbsentCardDismisses() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
@@ -269,7 +269,9 @@ struct FixtureTombstonesTests {
|
|||||||
let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005"
|
let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005"
|
||||||
|
|
||||||
let result = try loadFixture("Valid/tombstones.kanban")
|
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])
|
#expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead])
|
||||||
|
|
||||||
let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive })
|
let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive })
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ struct IntegrityDefectTests {
|
|||||||
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"])
|
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"])
|
||||||
).healClass == .looseCardFiles)
|
).healClass == .looseCardFiles)
|
||||||
#expect(IntegrityRules.Defect.legacyTombstone(
|
#expect(IntegrityRules.Defect.legacyTombstone(
|
||||||
LegacyTombstone(kind: .lane, laneID: lane, cardID: nil, title: nil)
|
LegacyTombstone(laneID: lane, cardID: card, title: nil)
|
||||||
).healClass == .legacyTombstone)
|
).healClass == .legacyTombstone)
|
||||||
#expect(IntegrityRules.Defect.claimedNameSquatted(
|
#expect(IntegrityRules.Defect.claimedNameSquatted(
|
||||||
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory)
|
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory)
|
||||||
@@ -318,10 +318,10 @@ struct IntegrityDefectTests {
|
|||||||
@Test("Signatures identify the work, not its display")
|
@Test("Signatures identify the work, not its display")
|
||||||
func signaturesIdentifyTheWork() {
|
func signaturesIdentifyTheWork() {
|
||||||
let one = IntegrityRules.Defect.legacyTombstone(
|
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(
|
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)
|
#expect(one.signatures == two.signatures)
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,7 @@ struct IntegrityDefectTests {
|
|||||||
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"])
|
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"])
|
||||||
)
|
)
|
||||||
let tombstone = IntegrityRules.Defect.legacyTombstone(
|
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(
|
let squatter = IntegrityRules.Defect.claimedNameSquatted(
|
||||||
ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory)
|
ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory)
|
||||||
|
|||||||
@@ -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
|
// MARK: - Menu validation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
+284
-161
@@ -140,10 +140,12 @@ struct TrashContainerLoadTests {
|
|||||||
#expect(result.warnings.count == 2)
|
#expect(result.warnings.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "A lane-shaped nesting inside `.trash` is a stray": the walk stops at a card in the trash
|
/// The walk stops at a trash entry exactly as it stops at a card under a lane, so a UUID-shaped
|
||||||
/// exactly as it does under a lane, so a UUID-shaped folder *inside* a trash card is content,
|
/// folder *inside* one is never a level. What that folder makes of its parent changed with the
|
||||||
/// never a level — invisible, preserved, and not warned about (a card's subfolders never are).
|
/// lanes-in-trash ruling: a kindless entry holding identity-shaped children with their own
|
||||||
@Test("A lane-shaped nesting inside .trash renders as one card, its children invisible")
|
/// `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 {
|
func laneShapedNestingInsideTrash() throws {
|
||||||
let fixture = try TrashFixture()
|
let fixture = try TrashFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -157,7 +159,9 @@ struct TrashContainerLoadTests {
|
|||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
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)
|
#expect(result.warnings.isEmpty)
|
||||||
// Nowhere in the snapshot, and still on disk.
|
// Nowhere in the snapshot, and still on disk.
|
||||||
#expect(!result.model.lanes.contains { $0.cards.contains { $0.id.rawValue == nested } })
|
#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)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
#expect(result.legacyTombstones == [
|
#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.
|
// 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.lanes[0].cards.map(\.isDeleted) == [true])
|
||||||
#expect(result.model.trash.isEmpty)
|
#expect(result.model.trash.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned lane is reported with no card, and keeps loading flagged")
|
/// "A lane carrying `deleted:` simply loads **live** with the key ignored — no migration
|
||||||
func tombstonedLaneIsReported() throws {
|
/// 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()
|
let fixture = try TrashFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
let lane = uuidName()
|
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("", "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)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
#expect(result.legacyTombstones == [
|
#expect(result.legacyTombstones.isEmpty, "the lane half of the migration is retired")
|
||||||
LegacyTombstone(kind: .lane, laneID: ItemID(rawValue: lane), cardID: nil, title: "Old lane")
|
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
|
||||||
])
|
#expect(result.model.lanes.map(\.title.value) == ["Old lane"], "it loads live")
|
||||||
#expect(result.model.lanes[0].isDeleted)
|
#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
|
/// 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.
|
/// still migrates rather than being left behind as the one key the pivot forgot. The lane beside
|
||||||
@Test("A malformed deleted value is still migration input")
|
/// 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 {
|
func malformedDeletedIsStillReported() throws {
|
||||||
let fixture = try TrashFixture()
|
let fixture = try TrashFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -316,8 +330,8 @@ struct LegacyTombstoneDetectionTests {
|
|||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
#expect(result.legacyTombstones.map(\.kind) == [.card, .lane])
|
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card)])
|
||||||
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card), nil])
|
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "A `deleted:` key at board level remains meaningless — ignored and logged, preserved
|
/// "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])
|
#expect(result.model.trash.map(\.id.rawValue) == [Ident.card1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "A lane carrying `deleted:` returns **live** with the key removed" — resurrection is the
|
/// The lane half of this migration is **retired** (01 § Deletion, re-ruled 2026-07-29): there is
|
||||||
/// safe direction, and the folder does not move, so it returns to its own position.
|
/// no `migrateTombstonedLane` to call, and the loader hands the store no lane work to do — the
|
||||||
@Test("A tombstoned lane keeps its place and returns live")
|
/// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`).
|
||||||
func laneMigratesInPlace() throws {
|
@Test("A tombstoned lane is no longer migration input at all")
|
||||||
|
func laneIsNotMigrationInput() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
@@ -572,74 +587,124 @@ struct TombstoneMigrationTests {
|
|||||||
Lane notes.
|
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)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
#expect(result.legacyTombstones.isEmpty)
|
#expect(result.legacyTombstones.isEmpty)
|
||||||
#expect(result.model.lanes.map(\.isDeleted) == [false])
|
#expect(result.model.lanes.map(\.title.value) == ["Old lane"])
|
||||||
}
|
|
||||||
|
|
||||||
@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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Writer: lane delete is physical
|
// MARK: - Writer: a lane delete is the same move
|
||||||
|
|
||||||
@Suite("BoardWriter ▸ removeLane")
|
@Suite("BoardWriter ▸ deleteLaneToTrash")
|
||||||
struct RemoveLaneTests {
|
struct DeleteLaneToTrashTests {
|
||||||
|
|
||||||
@Test("The lane folder and everything under it go")
|
/// "Deleting a lane moves its folder — subtree intact — into `.trash/`, exactly as a card moves"
|
||||||
func laneIsRemovedWhole() throws {
|
/// (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()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Doing"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
|
let cardBefore = Item.rich(order: "1024", title: "Kept")
|
||||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Other"))
|
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.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")
|
/// The container-changing move stamps both provenance keys (01 § Frontmatter ▸ `modified`'s
|
||||||
func absentLaneIsSuccess() throws {
|
/// 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()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
try fixture.item("", Item.board)
|
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/`
|
/// "`kind: lane` … backfilled on touch when absent … the trash move's rank mint included"
|
||||||
/// rather than a UUID — otherwise a permanent delete would be reachable through this door.
|
/// (01 § Deletion). The **empty** lane is the case that needs it: in a flat container it is
|
||||||
@Test("A card, a trash card, a board root and a stray are all refused")
|
/// shape-identical to a card, so a derived kind would answer wrongly and the row would come back
|
||||||
func onlyLanesAreRemoved() throws {
|
/// 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 `<root>/<lane>`, 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()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
@@ -650,107 +715,27 @@ struct RemoveLaneTests {
|
|||||||
try fixture.item("notes", Item.rich(order: "1024", title: "Stray"))
|
try fixture.item("notes", Item.rich(order: "1024", title: "Stray"))
|
||||||
|
|
||||||
for target in ["\(Ident.lane1)/\(Ident.card1)", ".trash/\(Ident.card2)", "", "notes"] {
|
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("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
#expect(fixture.exists(".trash/\(Ident.card2)"))
|
#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
|
// MARK: - Writer: permanent removal
|
||||||
|
|
||||||
@Suite("BoardWriter ▸ purging the trash")
|
@Suite("BoardWriter ▸ purging the trash")
|
||||||
struct TrashContainerPurgeTests {
|
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 {
|
func purgeIsScopedToTheTrash() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
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("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
|
||||||
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
|
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)"))
|
#expect(!fixture.exists(".trash/\(Ident.card2)"))
|
||||||
|
|
||||||
let error = writeFailure {
|
let error = writeFailure {
|
||||||
try BoardWriter.purgeTrashCard(
|
try BoardWriter.purgeTrashEntry(
|
||||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
inBoard: fixture.root
|
inBoard: fixture.root
|
||||||
)
|
)
|
||||||
@@ -773,7 +758,7 @@ struct TrashContainerPurgeTests {
|
|||||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
|
||||||
// Already gone is success, as everywhere else on the purge path.
|
// 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")
|
@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)
|
try fixture.item("", Item.board)
|
||||||
#expect(try BoardWriter.emptyTrash(inBoard: fixture.root).isEmpty)
|
#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
|
// MARK: - Board-wide uniqueness spans the trash
|
||||||
@@ -887,6 +910,9 @@ struct TrashKindDiscriminatorTests {
|
|||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
#expect(result.trashKinds[ItemID(rawValue: entry)] == .lane)
|
#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
|
/// 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)) }
|
) { $0.set(FrontmatterKeys.order, to: .double(4096)) }
|
||||||
|
|
||||||
let after = try BoardLoader.load(boardRoot: fixture.root)
|
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)
|
#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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,28 +267,73 @@ struct DeleteCardTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Delete: a lane is physical
|
// MARK: - Delete: a lane takes the same move
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("BoardStore ▸ delete a lane")
|
@Suite("BoardStore ▸ delete a lane")
|
||||||
struct DeleteLaneTests {
|
struct DeleteLaneTests {
|
||||||
|
|
||||||
@Test("Deleting a lane removes the folder and its contents — nothing is trashed")
|
/// 03-board-ui.md § Trash, re-ruled 2026-07-29: "deleting a lane moves its folder — subtree
|
||||||
func laneDeleteIsPhysical() throws {
|
/// 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()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.delete([lane3])
|
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(Ident.lane3))
|
||||||
#expect(!fixture.exists(".trash/\(Ident.card4)"), "its cards go with it, not into the trash")
|
#expect(fixture.exists(".trash/\(Ident.lane3)"))
|
||||||
#expect(try loaded(fixture).trash.map(\.id) == [newer, trashed], "the trash is untouched")
|
#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)
|
#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")
|
@Test("The selection moves to the successor lane")
|
||||||
func laneSuccessor() throws {
|
func laneSuccessor() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
@@ -344,7 +389,7 @@ struct StagedDeleteTests {
|
|||||||
// The column's order is [newer, trashed].
|
// The column's order is [newer, trashed].
|
||||||
store.select([newer], in: .trash)
|
store.select([newer], in: .trash)
|
||||||
|
|
||||||
store.deleteTrashCards([newer])
|
store.deleteTrashEntries([newer])
|
||||||
|
|
||||||
#expect(store.selection.ids == [trashed])
|
#expect(store.selection.ids == [trashed])
|
||||||
#expect(store.selection.container == .trash)
|
#expect(store.selection.container == .trash)
|
||||||
@@ -357,7 +402,7 @@ struct StagedDeleteTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.select([newer, trashed], in: .trash)
|
store.select([newer, trashed], in: .trash)
|
||||||
|
|
||||||
store.deleteTrashCards([newer, trashed])
|
store.deleteTrashEntries([newer, trashed])
|
||||||
|
|
||||||
#expect(try loaded(fixture).trash.isEmpty)
|
#expect(try loaded(fixture).trash.isEmpty)
|
||||||
#expect(store.selection.isEmpty)
|
#expect(store.selection.isEmpty)
|
||||||
@@ -369,7 +414,7 @@ struct StagedDeleteTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.deleteTrashCards([card1])
|
store.deleteTrashEntries([card1])
|
||||||
|
|
||||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
@@ -432,8 +477,8 @@ struct PurgeTests {
|
|||||||
#expect(store.purgeIsUnrecoverable)
|
#expect(store.purgeIsUnrecoverable)
|
||||||
|
|
||||||
store.select([trashed], in: .trash)
|
store.select([trashed], in: .trash)
|
||||||
store.deleteTrashCards([trashed])
|
store.deleteTrashEntries([trashed])
|
||||||
store.deleteTrashCards([newer])
|
store.deleteTrashEntries([newer])
|
||||||
store.emptyTrash()
|
store.emptyTrash()
|
||||||
|
|
||||||
// 13-native-undo.md ▸ Rules: "Permanently delete (the trash's Delete, Empty Trash) …
|
// 13-native-undo.md ▸ Rules: "Permanently delete (the trash's Delete, Empty Trash) …
|
||||||
@@ -472,10 +517,11 @@ struct StoreTombstoneMigrationTests {
|
|||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Cards relocate into .trash/ with the key removed; lanes return live in place")
|
@Test("Cards relocate into .trash/ with the key removed; a lane's key is never written to")
|
||||||
func migrationMovesCardsAndResurrectsLanes() throws {
|
func migrationMovesCardsAndLeavesLanesAlone() throws {
|
||||||
let fixture = try makeLegacyBoard()
|
let fixture = try makeLegacyBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
let laneBefore = try fixture.indexText(Ident.lane2)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.migrateLegacyTombstones()
|
store.migrateLegacyTombstones()
|
||||||
@@ -488,12 +534,12 @@ struct StoreTombstoneMigrationTests {
|
|||||||
#expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:")))
|
#expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:")))
|
||||||
#expect(!(try fixture.indexText(".trash/\(Ident.card3)").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,
|
// "A lane carrying `deleted:` simply loads live with the key ignored — no migration
|
||||||
// nothing is destroyed by migration".
|
// machinery, no key-strip write" (01 § Deletion, re-ruled 2026-07-29): its bytes are
|
||||||
#expect(fixture.exists(Ident.lane2))
|
// untouched, `modified` included, and it renders as an ordinary lane.
|
||||||
#expect(!(try fixture.indexText(Ident.lane2).contains("deleted:")))
|
#expect(try fixture.indexText(Ident.lane2) == laneBefore, "not one byte written")
|
||||||
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"), "its cards come back with it")
|
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
||||||
#expect(try document(fixture, Ident.lane2).order.value == 2048, "at its own position")
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Cards migrate oldest-first, so the newest deletion ends up on top")
|
@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")
|
#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 {
|
func theNotice() throws {
|
||||||
let fixture = try makeLegacyBoard()
|
let fixture = try makeLegacyBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -563,8 +609,10 @@ struct StoreTombstoneMigrationTests {
|
|||||||
store.migrateLegacyTombstones()
|
store.migrateLegacyTombstones()
|
||||||
|
|
||||||
#expect(store.banners.losses.count == 1, "one migration, one row")
|
#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
|
#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")
|
@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.
|
// read, so the channel empties and stays empty.
|
||||||
#expect(store.legacyTombstones.isEmpty)
|
#expect(store.legacyTombstones.isEmpty)
|
||||||
#expect(store.snapshot.trash.count == 2)
|
#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")
|
@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")
|
@Test("One card names it; several fold to a count")
|
||||||
func cardsFold() {
|
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")
|
== "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")
|
== "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")
|
@Test("An untitled item reads as a rendering, and nothing migrated is not news")
|
||||||
func edges() {
|
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")
|
== "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)
|
store.select([trashed], in: .trash)
|
||||||
confirmations.requestDelete(in: store)
|
confirmations.requestDelete(in: store)
|
||||||
let pending = try #require(confirmations.pending)
|
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")
|
#expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet")
|
||||||
|
|
||||||
confirmations.confirm(in: store)
|
confirmations.confirm(in: store)
|
||||||
@@ -844,7 +878,7 @@ struct TrashMenuValidationTests {
|
|||||||
confirmations.requestDelete(in: store)
|
confirmations.requestDelete(in: store)
|
||||||
// The trash staging: permanent, and behind the alert.
|
// The trash staging: permanent, and behind the alert.
|
||||||
let pending = try #require(confirmations.pending)
|
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
|
/// 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)
|
let pending = try #require(confirmations.pending)
|
||||||
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
|
#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)
|
confirmations.confirm(in: store)
|
||||||
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
||||||
|
|||||||
@@ -664,29 +664,63 @@ struct TrashUndoTests {
|
|||||||
#expect(history.canUndo == false)
|
#expect(history.canUndo == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane delete is physical, and its undo recreates the folder byte for byte")
|
/// 13 ▸ Interaction with the trash: "a lane [returns] to its strip position (subtree intact — it
|
||||||
func laneDeleteRecreatesTheSubtree() throws {
|
/// 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()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
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 cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)")
|
||||||
|
let laneRank = try #require(try document(fixture, Ident.lane2).order.value)
|
||||||
|
|
||||||
store.delete([lane2])
|
store.delete([lane2])
|
||||||
|
|
||||||
#expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically")
|
#expect(!fixture.exists(Ident.lane2))
|
||||||
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed")
|
#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")
|
#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()
|
history.undo()
|
||||||
|
|
||||||
#expect(fixture.exists(Ident.lane2))
|
#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,
|
#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()
|
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")
|
@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
|
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
|
// MARK: - The Edit session
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -820,7 +859,7 @@ struct NotUndoableTests {
|
|||||||
let armed = try #require(history.undoActionName)
|
let armed = try #require(history.undoActionName)
|
||||||
|
|
||||||
store.select([trashed], in: .trash)
|
store.select([trashed], in: .trash)
|
||||||
store.deleteTrashCards([trashed])
|
store.deleteTrashEntries([trashed])
|
||||||
|
|
||||||
#expect(fixture.exists(trashedPath) == false)
|
#expect(fixture.exists(trashedPath) == false)
|
||||||
#expect(store.purgeIsUnrecoverable)
|
#expect(store.purgeIsUnrecoverable)
|
||||||
@@ -963,7 +1002,9 @@ private enum Foreign {
|
|||||||
try fixture.move(path, toTrash: id)
|
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 {
|
static func removeLane(_ fixture: WriterFixture, _ path: String) throws {
|
||||||
try FileManager.default.removeItem(at: fixture.url(path))
|
try FileManager.default.removeItem(at: fixture.url(path))
|
||||||
}
|
}
|
||||||
@@ -1093,8 +1134,10 @@ struct StaleStepTests {
|
|||||||
#expect(store.banners.signposts.count == 1)
|
#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
|
/// The card's own path is the check: its lane's folder is gone, so the card is simply not there
|
||||||
/// there any more — no ancestor walk, which is what materializing the trash bought.
|
/// 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")
|
@Test("A card whose lane a foreign writer deleted is stale too")
|
||||||
func aCardUnderARemovedLaneIsStale() throws {
|
func aCardUnderARemovedLaneIsStale() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
@@ -179,6 +179,29 @@ struct WriteFidelityMinimalTouchTests {
|
|||||||
}
|
}
|
||||||
#expect(BoardLoader.isUUIDShaped(createdCard))
|
#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: []) {
|
try step("import attachment", targeting: []) {
|
||||||
// Dot-prefixed so it never becomes a board-root stray the loader has to warn about
|
// 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.
|
// — 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")
|
#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
|
/// 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.
|
/// import boundary's remint does not change that: the arrived file is stamped either way.
|
||||||
@Test("A cross-board arrival stamps")
|
@Test("A cross-board arrival stamps")
|
||||||
|
|||||||
Reference in New Issue
Block a user