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 ⌫/⌘⌫ (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. public static func purgePrompt( for ids: Set, snapshot: BoardModel, unrecoverable: Bool ) -> PurgePrompt? { let targets = ItemPath.resolve(ids, in: .trash, 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 the trash's own Delete 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 } }