Files
lanework/Kanban/UI/Board/TrashCommands.swift
T
rzen c339b4cecf Implement live accessibility announcements
The board speaks when files change under the user, per DESIGN/10 § Live
board announcements. BoardDiff is the pure snapshot summarizer (identity
sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so
pro-m1's semantic commit engine can build on it; edited = rendered
content only, moved beats edited, implied events don't steal the
subject). BoardAnnouncer is the decision seam: focusOutcome computes the
vanishing-focus sentence and the walk-up-then-sideways recovery (next
lane by order, else previous, board container only when none remain,
never the trash); speech(for:) is the one-sentence precedence ladder —
raised condition > bracket completion > cleared condition > vanished
focus > digest — foreign-only for the last two rungs, so app-mediated
echoes stay silent.

BoardStore.land assembles ReloadFacts and posts exactly one sentence per
reload through the injectable announce outlet (AccessibilityAnnouncer,
medium priority, never interrupting). Selection recovery layers on top
of ItemReferenceSet re-resolution — survivors veto, the emptied
selection lands on the vanished item's lane and re-arms ⌘N's active-lane
memory. performWholesale(announcing:) arms a completion phrase consumed
by the closing reload — nil on every base bracket today; pro-m1 fills
git phrasings. Locks raised outside the reload path (vanished root,
unwritable location) announce through the same ladder, and the banner
strip is a labeled "Board status" container whose row labels are the
announced sentences (AccessibilityPhrases.bannerLabel — one string for
eye and ear).

Announcements classify at reload granularity (WatchOrigin) as a
deliberate interim: DESIGN/02's EchoLedger (per-file classification, the
announcer's specified input, git-free) was scheduled with the
auto-committer that the edition split moved to pro-m1 — filed on the
Redesign board for a ruling. 1533 unit tests green, both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 08:15:53 -04:00

338 lines
16 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.** 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<ItemID>)
case purge(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, 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 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 the same shrug Delete Immediately gives on a board that keeps history.
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteTrashCards(ids)
return
}
guard let prompt = TrashModel.purgePrompt(
for: ids,
in: .trash,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .deleteTrashCards(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.
///
/// Its one caller is File ▸ Delete Immediately, which passes the selection's own ids — which is
/// what makes reading `store.selection.container` for the prompt correct here and wrong for a
/// context menu (`requestTrashDelete` above exists for exactly that difference).
func requestPurge(of ids: Set<ItemID>, 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)
}
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)
}
}
}