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
This commit is contained in:
@@ -21,8 +21,8 @@ final class TrashConfirmations {
|
||||
/// 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.
|
||||
/// 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 {
|
||||
@@ -30,20 +30,46 @@ final class TrashConfirmations {
|
||||
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.
|
||||
/// 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 two call sites.
|
||||
/// expression rather than three call sites.
|
||||
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
|
||||
guard store.purgeIsUnrecoverable else {
|
||||
store.deleteImmediately(ids)
|
||||
@@ -51,7 +77,8 @@ final class TrashConfirmations {
|
||||
}
|
||||
guard let prompt = TrashModel.purgePrompt(
|
||||
for: ids,
|
||||
in: store.snapshot,
|
||||
in: store.selection.container,
|
||||
snapshot: store.snapshot,
|
||||
unrecoverable: true
|
||||
) else { return }
|
||||
pending = Pending(prompt: prompt, action: .purge(ids))
|
||||
@@ -73,6 +100,7 @@ final class TrashConfirmations {
|
||||
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()
|
||||
}
|
||||
@@ -96,22 +124,17 @@ extension FocusedValues {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Delete / Put Back / Delete Immediately / Empty Trash…
|
||||
// MARK: - File ▸ Delete / Delete Immediately / Empty Trash…
|
||||
|
||||
/// The File menu's trash rows (11-command-nexus.md).
|
||||
///
|
||||
/// ### The ⌘⌫ chord twins
|
||||
/// ### One Delete, staged by place
|
||||
///
|
||||
/// 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.
|
||||
/// **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
|
||||
@@ -119,24 +142,18 @@ struct TrashCommands: View {
|
||||
|
||||
var body: some View {
|
||||
Button("Delete") {
|
||||
store?.deleteSelection()
|
||||
guard let store, let confirmations else { return }
|
||||
confirmations.requestDelete(in: store)
|
||||
}
|
||||
.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)
|
||||
.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(!canActOnTrash || confirmations == nil)
|
||||
.disabled(!canDeleteImmediately || confirmations == nil)
|
||||
|
||||
Button("Empty Trash…") {
|
||||
guard let store, let confirmations else { return }
|
||||
@@ -146,29 +163,29 @@ struct TrashCommands: View {
|
||||
.disabled(!canEmptyTrash)
|
||||
}
|
||||
|
||||
/// A live, non-empty selection on a board that accepts writes.
|
||||
/// 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 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 {
|
||||
/// 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.canActOnTrash(selection: store.selection, in: store.snapshot)
|
||||
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 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").
|
||||
/// "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 quasi-lane: "hidden, it is
|
||||
/// 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 !TrashModel.isEmpty(store.snapshot)
|
||||
return !store.snapshot.trash.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,12 +214,10 @@ struct ShowTrashCommand: View {
|
||||
|
||||
/// 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.
|
||||
/// **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
|
||||
@@ -237,7 +252,7 @@ extension BoardStore {
|
||||
// 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.liveness == .trashed {
|
||||
if !shown, selection.container == .trash {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user