Files
lanework/Kanban/UI/Board/TrashCommands.swift
T
rzen 53bc71f7fb Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 17:47:56 -04:00

293 lines
13 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, 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<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)
}
/// **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<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()
}
}
}
}
// 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)
}
}
}