Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two containers: CardFaceView extracted with a role — board or trash — so stripe, tint, chip, selection stroke, cut dim, marquee registration, and drag are shared by construction, the trash side differing only in its absences: no Open, no rename, no Style, no file-hover highlight, and a Delete that goes through the confirmation host. The column rewrote around the lanes' own single-column masonry so drag reflow reads as positional slides; chrome stays the hatched header, symbol, and count — 11 gives Empty Trash to the File menu alone. Two real grammar bugs die here: plain Backspace on a trash selection purged without the confirmation the menu raises, and the context menu's Delete resolved against the standing selection, so right-clicking a trash card under a board selection silently did nothing — it now stages the clicked set explicitly. Open, Rename, Style, and Empty Trash validation became testable store seams; the column is one named accessibility container of ordinary card elements. The tombstone era is swept: deleteItem, restoreItem, stripTombstonedChildren — dead since lane copies stopped nesting trash — the restore verb, the unreachable put-back banner row, and every quasi-lane doc comment. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -52,17 +52,36 @@ final class TrashConfirmations {
|
||||
/// 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 {
|
||||
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: store.selection.ids,
|
||||
for: ids,
|
||||
in: .trash,
|
||||
snapshot: store.snapshot,
|
||||
unrecoverable: true
|
||||
) else { return }
|
||||
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids))
|
||||
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
|
||||
}
|
||||
|
||||
/// Raises Delete Immediately's alert — **or purges outright** where the loss is not real.
|
||||
@@ -70,6 +89,10 @@ final class TrashConfirmations {
|
||||
/// 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)
|
||||
@@ -177,15 +200,26 @@ struct TrashCommands: View {
|
||||
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").
|
||||
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".
|
||||
private var canEmptyTrash: Bool {
|
||||
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
|
||||
return !store.snapshot.trash.isEmpty
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user