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