Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1,409 +1,147 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - TrashEntry
|
||||
|
||||
/// One row of the trash quasi-lane (03-board-ui.md § Trash).
|
||||
///
|
||||
/// **Two cases, not one**, because a tombstoned lane is not a tombstoned card wearing a different
|
||||
/// symbol: it is "a single restorable entry" that *subsumes* everything beneath it, and Put Back on
|
||||
/// it "returns [the lane] whole, cards and all" (04-interactions.md ▸ The trash). The card count it
|
||||
/// carries is part of the entry rather than something the row re-derives, because the rule for what
|
||||
/// that number means is subtle enough to want one home — see `TrashModel.entries(of:)`.
|
||||
///
|
||||
/// The card case carries its lane's id for the same reason `BoardStore.liveItem` returns two
|
||||
/// components rather than a URL: the row's folder is `<root>/<lane>/<card>`, and the root is the
|
||||
/// store's to supply — a mid-session folder rename may have moved it.
|
||||
public enum TrashEntry: Identifiable, Sendable, Equatable {
|
||||
|
||||
/// A card carrying its own `deleted:` under a **live** lane. A card whose lane is tombstoned
|
||||
/// never becomes one of these — see `TrashModel.entries(of:)`'s ancestor walk.
|
||||
case card(Card, laneID: ItemID)
|
||||
|
||||
/// A tombstoned lane, and how many of its cards Put Back would return to the board.
|
||||
case lane(Lane, returningCardCount: Int)
|
||||
|
||||
public var id: ItemID {
|
||||
switch self {
|
||||
case let .card(card, _): card.id
|
||||
case let .lane(lane, _): lane.id
|
||||
}
|
||||
}
|
||||
|
||||
/// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a
|
||||
/// value (03-board-ui.md § Card face).
|
||||
public var title: String? {
|
||||
switch self {
|
||||
case let .card(card, _): card.title.value
|
||||
case let .lane(lane, _): lane.title.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Which kind of row this is — the axis 04-interactions.md ▸ The trash makes a selection
|
||||
/// homogeneous over ("a selection never mixes card entries and lane entries").
|
||||
public var isLaneEntry: Bool {
|
||||
if case .lane = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// The `icon` field this row renders, so the row's symbol obeys the same lenient rule the board
|
||||
/// face does (`ItemSymbol`).
|
||||
public var icon: FieldValue<String> {
|
||||
switch self {
|
||||
case let .card(card, _): card.icon
|
||||
case let .lane(lane, _): lane.icon
|
||||
}
|
||||
}
|
||||
|
||||
/// Where this row's folder sits under the board root.
|
||||
public var path: TrashModel.ItemPath {
|
||||
switch self {
|
||||
case let .card(card, laneID): TrashModel.ItemPath(laneID: laneID, cardID: card.id)
|
||||
case let .lane(lane, _): TrashModel.ItemPath(laneID: lane.id, cardID: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TrashModel
|
||||
|
||||
/// The trash quasi-lane's contents, as a pure function of a snapshot (`TrashModelTests`) —
|
||||
/// 03-board-ui.md § Trash's Contents rules with nothing else mixed in.
|
||||
/// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash's
|
||||
/// **materialized** container (resettled 2026-07-28).
|
||||
///
|
||||
/// **Pure because the trash is a pure view.** "Tombstoned cards keep their `deleted:` key and stay
|
||||
/// exactly where they are on disk; nothing about the storage schema is trash-specific", so there is
|
||||
/// no trash *state* anywhere — only this derivation, re-run against whatever snapshot is current.
|
||||
/// A reload therefore rebuilds the rows for free, exactly as it rebuilds the lanes.
|
||||
/// ### Almost nothing, and that is the point of the pivot
|
||||
///
|
||||
/// The three rules it owns, each of which the design settles explicitly:
|
||||
/// 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.
|
||||
///
|
||||
/// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it — "a
|
||||
/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There
|
||||
/// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled
|
||||
/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is
|
||||
/// held to *is* this row set — "universe and rows are one function" (02-architecture.md § Changes
|
||||
/// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the
|
||||
/// rows a user sees and the ids a selection may hold cannot drift apart.
|
||||
/// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board —
|
||||
/// cards without their own flag; individually tombstoned descendants aren't in that number, since
|
||||
/// they come back to the *trash*".
|
||||
/// 3. **The deterministic sort.** Newest `deleted` first; ties by folder name ascending; an
|
||||
/// unparseable stamp sorts as *oldest*, after every dated entry, folder-name-ordered among its
|
||||
/// kind; lane entries interleave in the same single ordering by their own stamp. The order is
|
||||
/// load-bearing for input — "arrow walks, ⇧-ranges, and the rubber band all read it" — so it is
|
||||
/// total, not merely stable.
|
||||
/// 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.
|
||||
public enum TrashModel {
|
||||
|
||||
// MARK: - Where a row lives
|
||||
|
||||
/// An item's folder, as its identity components rather than as a URL.
|
||||
///
|
||||
/// Same shape and same reasoning as `BoardStore.liveItem`'s return: the caller 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).
|
||||
public struct ItemPath: Sendable, Equatable {
|
||||
public let laneID: ItemID
|
||||
/// `nil` for a lane — the path is then the lane folder itself.
|
||||
public let cardID: ItemID?
|
||||
|
||||
public init(laneID: ItemID, cardID: ItemID?) {
|
||||
self.laneID = laneID
|
||||
self.cardID = cardID
|
||||
}
|
||||
|
||||
public var isLane: Bool { cardID == nil }
|
||||
|
||||
/// This path resolved under a board root.
|
||||
public func folder(under root: URL) -> URL {
|
||||
var url = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
if let cardID {
|
||||
url.append(component: cardID.rawValue, directoryHint: .isDirectory)
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entries
|
||||
|
||||
/// The trash's rows, in the order the quasi-lane shows them.
|
||||
///
|
||||
/// **Which items are rows is not decided here** — it is `Liveness.trashed.walk`, the same walk
|
||||
/// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are
|
||||
/// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane
|
||||
/// contributes exactly one entry and its cards contribute none, whatever their own flags say.
|
||||
///
|
||||
/// What is left for this function is what a row *carries* — the returning count and the sort
|
||||
/// inputs — and the order it shows in, neither of which any other caller of the walk wants.
|
||||
public static func entries(of snapshot: BoardModel) -> [TrashEntry] {
|
||||
var rows: [Row] = []
|
||||
Liveness.trashed.walk(snapshot) { lane, card in
|
||||
if let card {
|
||||
rows.append(Row(
|
||||
entry: .card(card, laneID: lane.id),
|
||||
deleted: card.deleted.value,
|
||||
name: card.id.rawValue
|
||||
))
|
||||
} else {
|
||||
// Rule 2: only the cards *without* their own flag come back with the lane. The ones
|
||||
// that carry a flag stay tombstoned and get their rows back in the trash — which is
|
||||
// why Put Back on such a card is deliberately two steps.
|
||||
let returning = lane.cards.filter { !$0.isDeleted }.count
|
||||
rows.append(Row(
|
||||
entry: .lane(lane, returningCardCount: returning),
|
||||
deleted: lane.deleted.value,
|
||||
name: lane.id.rawValue
|
||||
))
|
||||
}
|
||||
}
|
||||
return rows.sorted(by: isOrdered).map(\.entry)
|
||||
}
|
||||
|
||||
/// Whether the board has anything in its trash at all — the "non-empty" half of Empty Trash…'s
|
||||
/// menu validation, which "reads the board's tombstones, not the filtered view" (03 ▸ Trash).
|
||||
///
|
||||
/// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is
|
||||
/// restated and only because stopping early is the whole point: a tombstoned lane is a row
|
||||
/// outright, and under a live lane any own-flagged card is one. There is deliberately no third
|
||||
/// clause for a card beneath a tombstoned lane — the lane has already answered `true` for it.
|
||||
/// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift.
|
||||
public static func isEmpty(_ snapshot: BoardModel) -> Bool {
|
||||
!snapshot.lanes.contains { lane in
|
||||
lane.isDeleted || lane.cards.contains(where: \.isDeleted)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry's sort inputs, kept beside it so the comparator never re-reads the model.
|
||||
private struct Row {
|
||||
let entry: TrashEntry
|
||||
/// The parsed `deleted` stamp, or `nil` when the value is present but unparseable —
|
||||
/// 01-storage-format.md's unusable-timestamp rule, which still deletes (presence, not
|
||||
/// validity) but supplies no position in time.
|
||||
let deleted: Date?
|
||||
/// The folder name, byte-for-byte — the tie-break the loader's display order already uses
|
||||
/// (`Ranks.sortedForDisplay`, `name: { $0.id.rawValue }`), so the trash breaks ties the same
|
||||
/// way the board does.
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// The sort, stated once: newest first among dated entries, then every undated entry.
|
||||
///
|
||||
/// **Undated sorts oldest, not first.** "A corrupt stamp must not outrank fresh deletions for the
|
||||
/// trash's most prominent rows" — so an unparseable value loses to every real timestamp, however
|
||||
/// old, and orders by folder name among its own kind.
|
||||
private static func isOrdered(_ lhs: Row, _ rhs: Row) -> Bool {
|
||||
switch (lhs.deleted, rhs.deleted) {
|
||||
case let (left?, right?):
|
||||
return left == right ? lhs.name < rhs.name : left > right
|
||||
case (.some, nil):
|
||||
return true
|
||||
case (nil, .some):
|
||||
return false
|
||||
case (nil, nil):
|
||||
return lhs.name < rhs.name
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paths for the trash's writes
|
||||
|
||||
/// The folders `ids` names, in display order, restricted to one liveness side.
|
||||
///
|
||||
/// **The membership rule is not restated here** — it is `Liveness.walk`'s, the same one
|
||||
/// `ItemReferenceSet.idUniverse` and `entries(of:)` read — spelled in *paths* rather than ids
|
||||
/// because a write needs to know where the item is. So the trashed side is the trash's rows and
|
||||
/// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs
|
||||
/// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee
|
||||
/// that every path this hands a writer names something the board would draw.
|
||||
///
|
||||
/// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than
|
||||
/// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put
|
||||
/// Back on it restores the lane and every card that rode along; Delete Immediately on it purges
|
||||
/// the whole tree, own-flag cards included — the outcome the lane entry's confirmation sentence
|
||||
/// exists to warn about (`message(lanes:unrecoverable:)`).
|
||||
///
|
||||
/// Display order — lanes left to right, each lane then its cards — rather than the caller's set
|
||||
/// iteration order, which is not an order at all: a batch that fails partway must fail the same
|
||||
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). The walk
|
||||
/// visits in exactly that order, so this is a filter over it and never a sort.
|
||||
public static func paths(of ids: Set<ItemID>, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] {
|
||||
guard !ids.isEmpty else { return [] }
|
||||
var result: [ItemPath] = []
|
||||
side.walk(snapshot) { lane, card in
|
||||
guard ids.contains(card?.id ?? lane.id) else { return }
|
||||
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Every folder Empty Trash removes — "emptying purges every tombstone on the board, filter or
|
||||
/// no filter" (03 ▸ Trash).
|
||||
///
|
||||
/// `paths(of:on:in:)` on the trashed side with **no id filter at all**, which is the strongest
|
||||
/// form of that guarantee: the command's scope is the trashed universe itself, so it cannot
|
||||
/// narrow to a selection any more than it can narrow to the search.
|
||||
///
|
||||
/// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane
|
||||
/// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would
|
||||
/// be redundant purges of paths the first removal already took (harmless — `purgeItem` treats a
|
||||
/// folder that is already gone as success — but noise) and would require a second, broader
|
||||
/// definition of "in the trash" than the one every other caller reads.
|
||||
public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] {
|
||||
var result: [ItemPath] = []
|
||||
Liveness.trashed.walk(snapshot) { lane, card in
|
||||
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Counts and phrasing
|
||||
|
||||
/// How many lane entries and card entries a set of entries holds — the confirmation dialogs'
|
||||
/// only input beyond the item titles.
|
||||
public struct EntryCounts: Sendable, Equatable {
|
||||
public var lanes: Int = 0
|
||||
public var cards: Int = 0
|
||||
|
||||
public var total: Int { lanes + cards }
|
||||
public var isEmpty: Bool { total == 0 }
|
||||
}
|
||||
|
||||
public static func counts(of entries: [TrashEntry]) -> EntryCounts {
|
||||
var counts = EntryCounts()
|
||||
for entry in entries {
|
||||
if entry.isLaneEntry { counts.lanes += 1 } else { counts.cards += 1 }
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/// The counts a set of `ItemPath`s describes — the same two numbers, from the shape the write
|
||||
/// path actually carries.
|
||||
public static func counts(of paths: [ItemPath]) -> EntryCounts {
|
||||
var counts = EntryCounts()
|
||||
for path in paths {
|
||||
if path.isLane { counts.lanes += 1 } else { counts.cards += 1 }
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/// "2 lanes and 3 cards", "41 cards", "1 lane" — 06-history-undo.md's **plural folding** applied
|
||||
/// to a mixed trash selection.
|
||||
///
|
||||
/// An empty count reads "nothing", which no caller renders: both confirmations refuse to open on
|
||||
/// an empty scope. It is spelled anyway so the function is total.
|
||||
public static func phrase(_ counts: EntryCounts) -> String {
|
||||
switch (counts.lanes, counts.cards) {
|
||||
case (0, 0): "nothing"
|
||||
case let (0, cards): plural(cards, "card")
|
||||
case let (lanes, 0): plural(lanes, "lane")
|
||||
case let (lanes, cards): "\(plural(lanes, "lane")) and \(plural(cards, "card"))"
|
||||
}
|
||||
}
|
||||
|
||||
private static func plural(_ count: Int, _ noun: String) -> String {
|
||||
"\(count) \(noun)\(count == 1 ? "" : "s")"
|
||||
/// "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).
|
||||
public static func phrase(_ count: Int) -> String {
|
||||
"\(count) card\(count == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
// MARK: - Confirmations
|
||||
|
||||
/// A purge confirmation's three strings, built once and rendered by the window's alert.
|
||||
///
|
||||
/// A value rather than a view so the phrasing rules — plural folding, naming a sole item, the
|
||||
/// lane caveat, and whether the loss is actually irreversible — are testable without an alert on
|
||||
/// screen (`TrashModelTests`).
|
||||
/// A value rather than a view so the phrasing rules — plural folding, naming a sole item, and
|
||||
/// whether the loss is actually irreversible — are testable without an alert on screen.
|
||||
public struct PurgePrompt: Sendable, Equatable {
|
||||
public let title: String
|
||||
public let message: String
|
||||
public let confirmTitle: String
|
||||
}
|
||||
|
||||
/// Delete Immediately's alert — "the alert stands between one keystroke and unrecoverable
|
||||
/// deletion" (03-board-ui.md § Trash).
|
||||
/// The alert in front of a **permanent** card delete — the trash's own ⌫/⌘⌫ and File ▸ Delete
|
||||
/// Immediately alike (03-board-ui.md § Trash: "Both confirm exactly where the loss is real ...
|
||||
/// the alert stands between one keystroke and unrecoverable deletion").
|
||||
///
|
||||
/// A sole item is **named**; several fold into counts. `nil` when the ids name nothing
|
||||
/// tombstoned, which is also the command's own refusal — so the prompt and the action can never
|
||||
/// `container` is where the command found the cards: the trash for the trash's Delete, the board
|
||||
/// for a Delete Immediately that skips the trash from a lane. The prompt reads the same either
|
||||
/// way — what is being asked is whether to destroy these cards, and where they happen to be
|
||||
/// sitting is not the question.
|
||||
///
|
||||
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in that
|
||||
/// container, 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>,
|
||||
in snapshot: BoardModel,
|
||||
in container: ItemContainer,
|
||||
snapshot: BoardModel,
|
||||
unrecoverable: Bool
|
||||
) -> PurgePrompt? {
|
||||
let targets = paths(of: ids, on: .trashed, in: snapshot)
|
||||
let targets = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
|
||||
guard !targets.isEmpty else { return nil }
|
||||
|
||||
let counts = counts(of: targets)
|
||||
let subject: String
|
||||
if targets.count == 1, let only = targets.first {
|
||||
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
|
||||
} else {
|
||||
subject = phrase(counts)
|
||||
subject = phrase(targets.count)
|
||||
}
|
||||
return PurgePrompt(
|
||||
title: "Permanently delete \(subject)?",
|
||||
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
|
||||
message: message(unrecoverable: unrecoverable),
|
||||
confirmTitle: "Delete"
|
||||
)
|
||||
}
|
||||
|
||||
/// Empty Trash…'s alert — **always shown** ("bulk scope, not per-item recoverability, is what it
|
||||
/// guards"), and always naming the **true count**: every tombstone on the board, never the
|
||||
/// filtered view.
|
||||
/// 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").
|
||||
///
|
||||
/// Counts rather than names even for a single entry, because the command is about the trash
|
||||
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing
|
||||
/// (06-history-undo.md's plural folding).
|
||||
/// 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.
|
||||
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
|
||||
let counts = counts(of: emptyTrashTargets(in: snapshot))
|
||||
guard !counts.isEmpty else { return nil }
|
||||
guard !snapshot.trash.isEmpty else { return nil }
|
||||
return PurgePrompt(
|
||||
title: "Permanently delete \(phrase(counts))?",
|
||||
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
|
||||
title: "Permanently delete \(phrase(snapshot.trash.count))?",
|
||||
message: message(unrecoverable: unrecoverable),
|
||||
confirmTitle: "Delete"
|
||||
)
|
||||
}
|
||||
|
||||
/// The alert's body: what a lane takes with it, and whether any of it comes back.
|
||||
/// The alert's body: whether any of it comes back.
|
||||
///
|
||||
/// The lane sentence is not decoration — a lane entry's row says "3 cards" (what Put Back would
|
||||
/// return), while purging the lane folder takes *every* card inside it, individually tombstoned
|
||||
/// ones included. That gap is exactly what a confirmation is for.
|
||||
private static func message(lanes: Int, unrecoverable: Bool) -> String {
|
||||
var parts: [String] = []
|
||||
if lanes > 0 {
|
||||
parts.append("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
|
||||
/// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with
|
||||
/// undo as its net).
|
||||
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 Delete Immediately does not confirm there at all
|
||||
// (`BoardStore.purgeIsUnrecoverable`).
|
||||
parts.append(unrecoverable
|
||||
unrecoverable
|
||||
? "This can\u{2019}t be undone."
|
||||
: "The board\u{2019}s history still has them.")
|
||||
return parts.joined(separator: " ")
|
||||
: "The board\u{2019}s history still has them."
|
||||
}
|
||||
|
||||
/// What to call an item in a prompt — its title, or the "Untitled" rendering.
|
||||
/// What to call a card in a prompt — its title, or the "Untitled" rendering.
|
||||
///
|
||||
/// Total by construction: a path whose item has gone since the prompt was asked for reads
|
||||
/// Total by construction: a path whose card 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 {
|
||||
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return "Untitled" }
|
||||
guard let cardID = path.cardID else { return lane.title.value ?? "Untitled" }
|
||||
return lane.cards.first { $0.id == cardID }?.title.value ?? "Untitled"
|
||||
switch path {
|
||||
case let .lane(id):
|
||||
return snapshot.lanes.first { $0.id == id }?.title.value ?? "Untitled"
|
||||
case let .card(lane, id):
|
||||
return snapshot.lanes.first { $0.id == lane }?
|
||||
.cards.first { $0.id == id }?.title.value ?? "Untitled"
|
||||
case let .trashCard(id):
|
||||
return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu validation
|
||||
|
||||
/// Whether File ▸ Delete has something to tombstone — a **live**, non-empty selection that still
|
||||
/// names something the board renders.
|
||||
/// Whether File ▸ Delete has something to act on — **staged by place, but validated once**
|
||||
/// (04-interactions.md ▸ The map, resettled 2026-07-28: "File ▸ Delete is the chord's only
|
||||
/// owner — no twin menu items, no shared-equivalent routing").
|
||||
///
|
||||
/// The liveness side is the whole of the binary: "menu validation stays binary — Delete for live
|
||||
/// selections, Put Back / Delete Immediately for tombstoned ones" (04 ▸ The trash). The
|
||||
/// resolution against the snapshot is what keeps a selection the next reload will drop from
|
||||
/// enabling an item that would write nothing.
|
||||
/// One predicate for both stagings, because there is only one item now: a board selection moves
|
||||
/// into the trash, a trash selection deletes permanently, and the command is enabled whenever
|
||||
/// either names something the board still holds. The old mirror-image pair
|
||||
/// (which existed to make two ⌘⌫ twins enable exactly one of themselves) retired with Put Back.
|
||||
public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
||||
selection.liveness == .live && !paths(of: selection.ids, on: .live, in: snapshot).isEmpty
|
||||
!ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).isEmpty
|
||||
}
|
||||
|
||||
/// Whether File ▸ Put Back and File ▸ Delete Immediately have something to act on — the exact
|
||||
/// mirror of `canDelete`, which is what makes the two ⌘⌫ twins enable exactly one of themselves.
|
||||
public static func canActOnTrash(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
||||
selection.liveness == .trashed && !paths(of: selection.ids, on: .trashed, in: snapshot).isEmpty
|
||||
/// Whether File ▸ Delete Immediately has something to purge — **a card selection, from anywhere**
|
||||
/// (11-command-nexus.md: "Board window, card selection — skips the trash from anywhere").
|
||||
///
|
||||
/// Cards only, in either container: a lane's delete is physical already and has undo as its net,
|
||||
/// so there is nothing for "skip the trash" to mean on one.
|
||||
public static func canDeleteImmediately(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
||||
ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).contains { !$0.isLane }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user