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
251 lines
11 KiB
Swift
251 lines
11 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 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)
|
|
}
|
|
}
|
|
}
|