Realign code with the 2026-07-30 findings-resolution rulings

Delete Immediately is removed entirely (ruling ae1dd96, 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 (2ec2c95 registry freshness stamp,
c741b02 unified-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
This commit is contained in:
2026-07-30 15:13:31 -04:00
parent 9ca8ed84de
commit 785ef5fe14
15 changed files with 83 additions and 483 deletions
+11 -90
View File
@@ -5,10 +5,11 @@ import SwiftUI
/// 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`.
/// **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.
@@ -30,18 +31,11 @@ final class TrashConfirmations {
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).
/// 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>)
/// **The container travels with the ids**, because a purge can be aimed at either side and
/// the two entry points below disagree about which: the menu-bar command means the
/// selection's container, a context menu means `.board` whatever is selected. Re-reading
/// the selection when the alert is answered would resolve a board card against `.trash` and
/// purge nothing a confirmed destructive command becoming a silent no-op.
case purge(Set<ItemID>, ItemContainer)
case emptyTrash
}
}
@@ -52,7 +46,7 @@ final class TrashConfirmations {
/// 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 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.
@@ -74,7 +68,7 @@ final class TrashConfirmations {
///
/// 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.
/// 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)
@@ -82,70 +76,12 @@ final class TrashConfirmations {
}
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` correct here and wrong for a context menu
/// (`requestTrashDelete` above exists for exactly that difference).
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
let container = store.selection.container
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids, in: container)
return
}
guard let prompt = TrashModel.purgePrompt(
for: ids,
in: container,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .purge(ids, container))
}
/// **Delete's -alternate, aimed at an explicit set** the board-side card and lane
/// context-menu rows' Delete Immediately (11-command-nexus.md Context menus' Card and Lane
/// rows: "Delete with Delete Immediately as its -alternate Finder's pattern: hold and
/// Delete becomes Delete Immediately").
///
/// A third entry point beside `requestPurge` and `requestTrashDelete`, for `requestTrashDelete`'s
/// own reason mirrored onto the other container: `requestPurge(of:in:)` reads
/// `store.selection.container` for the prompt, which is correct for its one caller (File
/// Delete Immediately, whose ids *are* the selection) and wrong for a context menu, which names
/// its target by where the -held click landed right-clicking a card or lane while a *trash*
/// selection stands must still purge the clicked item.
///
/// The container is always `.board`: this alternate exists only on the board-side rows
/// "the trash needs no alternate: its Delete is already permanent" (11-command-nexus.md's Lane
/// row).
///
/// **The container is supplied end to end**, prompt and write alike: `store.deleteImmediately`
/// takes it as a parameter rather than reading the selection, so a confirmed purge aimed at a board
/// item cannot silently find nothing because a trash selection happened to be standing.
func requestBoardPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids, in: .board)
return
}
guard let prompt = TrashModel.purgePrompt(
for: ids,
in: .board,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .purge(ids, .board))
}
/// 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) {
@@ -163,7 +99,6 @@ final class TrashConfirmations {
self.pending = nil
switch pending.action {
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
case let .purge(ids, container): store.deleteImmediately(ids, in: container)
case .emptyTrash: store.emptyTrash()
}
}
@@ -186,7 +121,7 @@ extension FocusedValues {
}
}
// MARK: - File Delete / Delete Immediately / Empty Trash
// MARK: - File Delete / Empty Trash
/// The File menu's trash rows (11-command-nexus.md).
///
@@ -210,13 +145,6 @@ struct TrashCommands: View {
.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)
@@ -232,13 +160,6 @@ struct TrashCommands: View {
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
}