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
148 lines
7.6 KiB
Swift
148 lines
7.6 KiB
Swift
import Foundation
|
|
|
|
// MARK: - TrashModel
|
|
|
|
/// 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).
|
|
///
|
|
/// ### Almost nothing, and that is the point of the pivot
|
|
///
|
|
/// 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.
|
|
///
|
|
/// 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: - 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).
|
|
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, 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
|
|
}
|
|
|
|
/// 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").
|
|
///
|
|
/// `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 container: ItemContainer,
|
|
snapshot: BoardModel,
|
|
unrecoverable: Bool
|
|
) -> PurgePrompt? {
|
|
let targets = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
|
|
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}"
|
|
} else {
|
|
subject = phrase(targets.count)
|
|
}
|
|
return PurgePrompt(
|
|
title: "Permanently delete \(subject)?",
|
|
message: message(unrecoverable: unrecoverable),
|
|
confirmTitle: "Delete"
|
|
)
|
|
}
|
|
|
|
/// 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 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? {
|
|
guard !snapshot.trash.isEmpty else { return nil }
|
|
return PurgePrompt(
|
|
title: "Permanently delete \(phrase(snapshot.trash.count))?",
|
|
message: message(unrecoverable: unrecoverable),
|
|
confirmTitle: "Delete"
|
|
)
|
|
}
|
|
|
|
/// 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).
|
|
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`).
|
|
unrecoverable
|
|
? "This can\u{2019}t be undone."
|
|
: "The board\u{2019}s history still has them."
|
|
}
|
|
|
|
/// What to call a card 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
|
|
/// "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 {
|
|
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 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").
|
|
///
|
|
/// 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 {
|
|
!ItemPath.resolve(selection.ids, in: selection.container, snapshot: 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 }
|
|
}
|
|
}
|