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 an /// item that has since gone — `BoardWriter.purgeItem` 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. Two cases, because the two commands have /// genuinely different scopes: one names a selection, the other names the whole trash and /// re-derives its targets at the moment it runs. enum Action: Equatable { case purge(Set) case emptyTrash } } /// 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 two 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.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 .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 / Put Back / Delete Immediately / Empty Trash… /// The File menu's trash rows (11-command-nexus.md). /// /// ### The ⌘⌫ chord twins /// /// Delete and Put Back are **two items sharing one key equivalent**, and validation enables exactly /// one of them: "AppKit routes a shared key equivalent to the enabled item" (04-interactions.md ▸ /// The map, which names Finder's own Move to Trash/Put Back pair as the precedent). The two /// predicates are mirror images over the selection's liveness side /// (`TrashModel.canDelete`/`canActOnTrash`), so they can neither both enable nor both disable while /// something is selected — and a selection can never be mixed, because /// `ItemReferenceSet.resolved(against:)` treats a liveness flip as a vanish. /// /// **Both titles stay stable** (titles-are-API): each remaps independently through the system /// mechanism, and remapping one never moves the other's role. struct TrashCommands: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.trashConfirmations) private var confirmations var body: some View { Button("Delete") { store?.deleteSelection() } .keyboardShortcut(.delete, modifiers: .command) .disabled(!canDelete) Button("Put Back") { guard let store else { return } store.putBack(store.selection.ids) } .keyboardShortcut(.delete, modifiers: .command) .disabled(!canActOnTrash) Button("Delete Immediately") { guard let store, let confirmations else { return } confirmations.requestPurge(of: store.selection.ids, in: store) } .keyboardShortcut(.delete, modifiers: [.option, .command]) .disabled(!canActOnTrash || confirmations == nil) Button("Empty Trash…") { guard let store, let confirmations else { return } confirmations.requestEmptyTrash(in: store) } .keyboardShortcut(.delete, modifiers: [.shift, .command]) .disabled(!canEmptyTrash) } /// A live, non-empty selection on a board that accepts writes. private var canDelete: Bool { guard let store, store.acceptsBoardMutations else { return false } return TrashModel.canDelete(selection: store.selection, in: store.snapshot) } /// A tombstoned, non-empty selection — Put Back's condition and Delete Immediately's alike, the /// two being the trash side's pair (04-interactions.md ▸ The trash: "menu validation stays /// binary"). private var canActOnTrash: Bool { guard let store, store.acceptsBoardMutations else { return false } return TrashModel.canActOnTrash(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 the *board's* tombstones and never the filtered view (03-board-ui.md § /// Trash: "a bulk command about the trash itself never silently narrows to the visible subset"). /// /// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is /// invisible to every gesture". private var canEmptyTrash: Bool { guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false } return !TrashModel.isEmpty(store.snapshot) } } // 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 tombstoned selection.** The rows it pointed at are no longer on screen, and /// "nothing invisible may stay selected" is the invariant every item-referencing set in this app /// already obeys (`ItemReferenceSet`); leaving one behind would also leave Put Back and Delete /// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out /// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched — the /// board it names is still right there. private var isVisible: Binding { Binding( get: { store?.transient.isTrashVisible ?? false }, set: { shown in guard let store else { return } store.transient.isTrashVisible = shown if !shown, store.selection.liveness == .trashed { store.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) } } }