Delete Immediately is removed entirely (rulingae1dd96, Redesign card d40bfac1): the delete vocabulary is purely staged — board → trash, trash → permanent (confirmed on no-git boards), Empty Trash for bulk. Gone: File ▸ Delete Immediately (⌥⌘⌫) and its validation, both ⌥-alternate context rows (card + the permanently-disabled lane row), the VO custom action, BoardStore.deleteImmediately, TrashModel.canDeleteImmediately, TrashConfirmations' .purge action (zero surviving callers — trash-side Delete always used .deleteTrashCards), and the pinning tests. purgePrompt drops its now-single-purpose container parameter (.trash is the only surviving caller). BoardWriter.purgeItem survives — create-undo rollback still needs it — with its comment rewritten. README's trash paragraph drops the ⌥⌘⌫ sentence. The other 2026-07-30 rulings (2ec2c95registry freshness stamp,c741b02unified-log-as-coerce-consumer) required no code changes — already conformant. Both schemes 1844 tests / 318 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
298 lines
14 KiB
Swift
298 lines
14 KiB
Swift
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.** File ▸ Delete (landing on a trash
|
|
/// selection) 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. Two cases, because permanence is only
|
|
/// reachable inside the trash now: the trash's own staged Delete, and Empty Trash (which names
|
|
/// the whole container and re-derives its targets at the moment it runs).
|
|
enum Action: Equatable {
|
|
case deleteTrashCards(Set<ItemID>)
|
|
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.
|
|
///
|
|
/// 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 else {
|
|
store.deleteSelection()
|
|
return
|
|
}
|
|
requestTrashDelete(of: store.selection.ids, in: store)
|
|
}
|
|
|
|
/// The **permanent** half of that staging, aimed at an explicit set — the trash card's
|
|
/// context-menu Delete (11-command-nexus.md ▸ Context menus' Trash cards row).
|
|
///
|
|
/// Its own entry point because a context menu names its target by where it was invoked, not by
|
|
/// what is selected: right-clicking a trash card while a *board* selection stands must purge the
|
|
/// clicked card, and a path that re-read `selection` would resolve those ids in the wrong
|
|
/// container and silently do nothing.
|
|
///
|
|
/// Same alert, same rule: it "confirms exactly where the loss is real", so
|
|
/// `purgeIsUnrecoverable` decides — and where it does not, the purge runs straight through, which
|
|
/// is what a board that keeps history does for every permanent delete (delete-never-forgets).
|
|
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
|
|
guard store.purgeIsUnrecoverable else {
|
|
store.deleteTrashCards(ids)
|
|
return
|
|
}
|
|
guard let prompt = TrashModel.purgePrompt(
|
|
for: ids,
|
|
snapshot: store.snapshot,
|
|
unrecoverable: true
|
|
) else { return }
|
|
pending = Pending(prompt: prompt, action: .deleteTrashCards(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 .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 / 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("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)
|
|
}
|
|
|
|
private var canEmptyTrash: Bool {
|
|
store?.canEmptyTrash == true
|
|
}
|
|
}
|
|
|
|
extension BoardStore {
|
|
|
|
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash… 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", so a search that hides
|
|
/// every trash card leaves the command enabled and its confirmation still names the true count).
|
|
///
|
|
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
|
|
/// invisible to every gesture".
|
|
///
|
|
/// On the store rather than private to the menu row so the validation can be pinned without a
|
|
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
|
|
var canEmptyTrash: Bool {
|
|
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
|
|
return !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<Bool> {
|
|
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()
|
|
}
|
|
}
|
|
// **"Toggling visibility is announced"** (10-accessibility.md ▸ Trash lane). A whole
|
|
// container joins or leaves the accessibility tree here and nothing else marks it: the
|
|
// VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
|
|
// silent by nature. Announced from the store rather than from either caller for
|
|
// `setTrashVisible`'s own reason — the View menu row and the toolbar item are one command
|
|
// with two faces, and a consequence written at one of them would be missing from the other.
|
|
//
|
|
// Deliberately **not** on the reload's one-announcement-per-debounce ladder
|
|
// (`BoardAnnouncer`): this is a view state the user just toggled, not a change to the board's
|
|
// files, so there is no origin to classify and nothing for it to compete with.
|
|
announce(AccessibilityPhrases.trashVisibility(shown: shown))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|