import Observation import SwiftUI // MARK: - The window's confirmation host /// The board window's purge alert, as a piece of window-local state (03-board-ui.md § Trash). /// /// **It exists because a menu item cannot present anything.** Delete Immediately and Empty Trash… /// live in the menu bar, the trash row's context menu carries a twin of the first, and all three must /// raise *the same* alert on *the window in front* — so the request travels through the focus system /// exactly as `BoardInfoPresentation` does, and the alert itself is hosted once by `BoardView`. /// /// `@State` in `BoardWindowHost`, therefore one per window and dying with it: a half-answered /// confirmation is not something to carry across a window's life. @MainActor @Observable final class TrashConfirmations { /// The alert waiting to be answered, or `nil` when none is. /// /// The **phrasing is captured when the request is made**, not recomputed at render time: the /// user is being asked about the trash as it was when they invoked the command, and a foreign /// reload landing mid-alert must not silently change the sentence they are reading. The *action* /// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on a /// card that has since gone — the writer treats an absent folder as success. private(set) var pending: Pending? struct Pending: Identifiable, Equatable { let id = UUID() let prompt: TrashModel.PurgePrompt let action: Action /// What the confirmation is standing in front of. Three cases, because the three commands /// have genuinely different scopes and two different writes: the trash's own staged Delete, /// Delete Immediately (which skips the trash from either container), and Empty Trash (which /// names the whole container and re-derives its targets at the moment it runs). enum Action: Equatable { case deleteTrashCards(Set) case purge(Set) case emptyTrash } } /// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the /// trash side owes and the board side does not. /// /// A board selection goes straight through: moving a card into the trash and deleting a lane are /// both recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither /// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where /// the loss is real": `purgeIsUnrecoverable` decides, exactly as it does for Delete Immediately. /// /// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and /// nothing else — the two can never disagree about which write a ⌘⌫ performs. func requestDelete(in store: BoardStore) { guard store.selection.container == .trash, store.purgeIsUnrecoverable else { store.deleteSelection() return } guard let prompt = TrashModel.purgePrompt( for: store.selection.ids, in: .trash, snapshot: store.snapshot, unrecoverable: true ) else { return } pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids)) } /// Raises Delete Immediately's alert — **or purges outright** where the loss is not real. /// /// The mode check is the one thing that decides between the two, and it lives on the store as a /// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one /// expression rather than three call sites. func requestPurge(of ids: Set, in store: BoardStore) { guard store.purgeIsUnrecoverable else { store.deleteImmediately(ids) return } guard let prompt = TrashModel.purgePrompt( for: ids, in: store.selection.container, snapshot: store.snapshot, unrecoverable: true ) else { return } pending = Pending(prompt: prompt, action: .purge(ids)) } /// Raises Empty Trash…'s alert. **Always** — it guards bulk scope rather than per-item /// recoverability, so no board skips it. func requestEmptyTrash(in store: BoardStore) { guard let prompt = TrashModel.emptyTrashPrompt( in: store.snapshot, unrecoverable: store.purgeIsUnrecoverable ) else { return } pending = Pending(prompt: prompt, action: .emptyTrash) } /// Runs the pending action and dismisses. Idempotent: an alert answered twice (the button, then /// the dismissal SwiftUI drives from the binding) acts once. func confirm(in store: BoardStore) { guard let pending else { return } self.pending = nil switch pending.action { case let .deleteTrashCards(ids): store.deleteTrashCards(ids) case let .purge(ids): store.deleteImmediately(ids) case .emptyTrash: store.emptyTrash() } } func cancel() { pending = nil } } /// The focused board window's confirmation host — beside `FocusedValues.boardStore` and /// `.boardInfo`, and reached the same way by the same kinds of caller. struct FocusedTrashConfirmationsKey: FocusedValueKey { typealias Value = TrashConfirmations } extension FocusedValues { var trashConfirmations: TrashConfirmations? { get { self[FocusedTrashConfirmationsKey.self] } set { self[FocusedTrashConfirmationsKey.self] = newValue } } } // MARK: - File ▸ Delete / Delete Immediately / Empty Trash… /// The File menu's trash rows (11-command-nexus.md). /// /// ### One Delete, staged by place /// /// **Put Back is retired with the tombstone model** (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 ⌘⌫ chord therefore has exactly one owner, its validation is one predicate /// (`TrashModel.canDelete`), and which write it performs is decided by the selection's *container* /// inside the store rather than by AppKit picking whichever of two items happened to be enabled. struct TrashCommands: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.trashConfirmations) private var confirmations var body: some View { Button("Delete") { guard let store, let confirmations else { return } confirmations.requestDelete(in: store) } .keyboardShortcut(.delete, modifiers: .command) .disabled(!canDelete || confirmations == nil) Button("Delete Immediately") { guard let store, let confirmations else { return } confirmations.requestPurge(of: store.selection.ids, in: store) } .keyboardShortcut(.delete, modifiers: [.option, .command]) .disabled(!canDeleteImmediately || confirmations == nil) Button("Empty Trash…") { guard let store, let confirmations else { return } confirmations.requestEmptyTrash(in: store) } .keyboardShortcut(.delete, modifiers: [.shift, .command]) .disabled(!canEmptyTrash) } /// A non-empty selection that still names something, on a board that accepts writes — both /// stagings at once, which is what having one item means. private var canDelete: Bool { guard let store, store.acceptsBoardMutations else { return false } return TrashModel.canDelete(selection: store.selection, in: store.snapshot) } /// A **card** selection, in either container — "skips the trash from anywhere" /// (11-command-nexus.md). private var canDeleteImmediately: Bool { guard let store, store.acceptsBoardMutations else { return false } return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot) } /// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) — where /// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu /// validation's non-empty reads `.trash/`, not the filtered view"). /// /// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is /// invisible to every gesture". private var canEmptyTrash: Bool { guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false } return !store.snapshot.trash.isEmpty } } // MARK: - View ▸ Show Trash /// View ▸ Show Trash — a checkmark toggle with **no default chord** (11-command-nexus.md). /// /// ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled, so the chord /// is the system's, and a user who wants one here assigns it through the remapping mechanism. /// /// **One stable title with a checkmark state** — "Show Trash" stays "Show Trash" when checked, never /// becomes "Hide Trash" (04-interactions.md ▸ Configurable bindings, since the title is the key a /// custom binding is stored under). /// /// Neither the read-only lock nor the focused-editor rule closes it: showing the trash is a view /// change, not a mutation, and a locked board is exactly when a user wants to look at what is in /// there. struct ShowTrashCommand: View { @FocusedValue(\.boardStore) private var store var body: some View { Toggle("Show Trash", isOn: isVisible) .disabled(store == nil) } /// The toggle's binding — and the one place hiding the trash has a consequence beyond layout. /// /// **Hiding drops a trash selection** (04-interactions.md ▸ The trash: "hiding it clears a /// selection of trash cards — nothing invisible stays selected, so the toggle-off drops the /// selection rather than leave commands enabled against rows nobody can see"). A *board* /// selection is untouched — the board it names is still right there. /// /// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's /// Show Trash item is this same command with a different face (03-board-ui.md ▸ Toolbar: "toggle /// state matching the View menu checkmark"), and the consequence above has to be true of both. private var isVisible: Binding { Binding( get: { store?.transient.isTrashVisible ?? false }, set: { shown in store?.setTrashVisible(shown) } ) } } extension BoardStore { /// Show/Hide Trash, with its one consequence — **the whole of the View-menu row's behaviour**, /// shared with the toolbar item that mirrors it. func setTrashVisible(_ shown: Bool) { // The re-divide is a *user-initiated structural change* — every lane compresses or relaxes // as the trash's one unit joins or leaves the division — so it animates in the structural // voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a re-divide trigger", a // lane add's behaviour exactly). It is also one of the few structural changes that never // touches disk, which is why it wears its own transaction here instead of arriving through // the reload seam like the rest. // // Reduce Motion read from AppKit rather than from `@Environment`: a menu command's content // is built outside any rendered hierarchy, where the environment's accessibility values are // not reliably populated (`Motion.prefersReducedMotion`) — and a toolbar item has no // environment at all. withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) { transient.isTrashVisible = shown // Dropped inside the same transaction: the rows it pointed at are leaving under this // very animation, and a selection that cleared outside it would be the highlight easing // on its own — which 03 § Motion rules out ("the selection highlight rides whatever // transaction is active"). if !shown, selection.container == .trash { clearSelection() } } } } // MARK: - The alert extension View { /// Hosts the board window's purge alert — one surface for every path that asks for one. /// /// An **alert** rather than a confirmation dialog: this is a modal, destructive yes/no about /// named items, which is what macOS's alert is for, and what Finder puts in front of the same /// gesture. func trashPurgeAlert(store: BoardStore, confirmations: TrashConfirmations) -> some View { alert( confirmations.pending?.prompt.title ?? "", isPresented: Binding( get: { confirmations.pending != nil }, // Any dismissal that is not the confirm button is a cancel — Escape, a click // outside, the sheet being torn down. set: { presented in if !presented { confirmations.cancel() } } ), presenting: confirmations.pending ) { pending in Button(pending.prompt.confirmTitle, role: .destructive) { confirmations.confirm(in: store) } Button("Cancel", role: .cancel) { confirmations.cancel() } } message: { pending in Text(pending.prompt.message) } } }