Build the trash quasi-lane

Deletion becomes a two-stage, Finder-style story. File > Delete and
plain Backspace tombstone the live selection; View > Show Trash (no
chord — Shift-Cmd-T stays with the system's tab bar) reveals the
quasi-lane: trailing, one fixed width unit consumed only while shown,
hatched dimmed header, count badge, no new-card button, exempt from
resize and reorder alike. Its contents are a pure view over the
snapshot — the deterministic sort (deleted newest first, folder-name
ties, unparseable stamps oldest) interleaves card rows with a
tombstoned lane's single entry, whose count names what Put Back
returns; the ancestor walk is absolute, so an own-flag card beneath a
tombstoned lane has no row and recovery is deliberately two steps.
Put Back twins Delete on Cmd-Backspace with validation enabling
exactly one; restore fidelity is byte-perfect because nothing ever
moved. Delete Immediately confirms exactly where loss is real (every
board is mode-none today; the predicate names the git carve-out for
m7), Empty Trash always confirms with the true whole-board count,
and dragging a tombstoned card onto a live lane restores it there —
positional drops and cross-board locality arrive with m5's machinery.
The banner's delete phrasing drops "move to the trash" per the naming
constraint: board deletion says Delete, "Move to Trash" stays
reserved for the system Trash. 47 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 15:38:34 -04:00
parent aa6aaf2a11
commit 2090d742b9
14 changed files with 2428 additions and 18 deletions
+250
View File
@@ -0,0 +1,250 @@
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<ItemID>)
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<ItemID>, 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<Bool> {
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)
}
}
}