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
+2 -2
View File
@@ -44,8 +44,8 @@ struct BoardWindowHost: View {
@State private var boardInfo = BoardInfoPresentation()
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
/// reason and reaching the menu bar the same way: Delete Immediately and Empty Trash are
/// menu-bar items, and a menu item cannot present anything of its own.
/// reason and reaching the menu bar the same way: File Delete (landing on a trash selection)
/// and Empty Trash are menu-bar items, and a menu item cannot present anything of its own.
@State private var trashConfirmations = TrashConfirmations()
/// How Board Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason,
+2 -2
View File
@@ -160,8 +160,8 @@ extension BoardStore {
/// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no
/// trace*, because the item was born of the gesture being undone filing it in the trash would
/// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched
/// by this: that flag is about Delete Immediately, whose loss is the user's own final gesture,
/// while this loss is one Z away.
/// by this: that flag is about the trash's own permanent delete, whose loss is the user's own
/// final gesture, while this loss is one Z away.
///
/// The honest edge, recorded rather than papered over: anything that happened *inside* the
/// created folder through an operation that registers no step of its own an attachment added by
+5 -4
View File
@@ -732,10 +732,11 @@ public final class BannerCenter {
/// does not: the enum knows an item's title, never its *kind*, so an untitled failure says
/// "the item" rather than guessing "card" and being wrong about a lane.
///
/// The trash verbs match the commands the user pressed **Delete**, Delete Immediately, Empty
/// Trash which is 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
/// 'Delete'". A banner saying a card could not be *moved to the trash* would name the wrong one
/// of the app's two trashes (the card window's attachment Remove is the other).
/// The trash verbs match the commands the user pressed **Delete**, Empty Trash which is
/// 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: "Finder's 'Move to
/// Trash' phrasing is reserved for the system Trash; board deletion says 'Delete'". A banner
/// saying a card could not be *moved to the trash* would name the wrong one of the app's two
/// trashes (the card window's attachment Remove is the other).
private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
switch operation {
case .createBoard:
+1 -44
View File
@@ -3392,7 +3392,7 @@ public final class BoardStore: HealHost {
///
/// Its own method rather than a flag on `delete(_:)` because it is a different act with a
/// different safety story: it registers **no undo step**, and `purgeIsUnrecoverable` stays `true`
/// 13-native-undo.md Rules settles this by name ("Permanently delete (Delete Immediately,
/// 13-native-undo.md Rules settles this by name ("Permanently delete (the trash's Delete,
/// Empty Trash) the confirm *is* the safety"). A stack entry here would be a promise the
/// filesystem cannot keep.
///
@@ -3427,49 +3427,6 @@ public final class BoardStore: HealHost {
}
}
/// **Delete Immediately skips the trash from anywhere** (03-board-ui.md § Trash;
/// 11-command-nexus.md: "Board window, card selection skips the trash from anywhere").
///
/// The one method whose targets can be in either container, and the reason is the command's own
/// wording: from a lane it bypasses the trash the ordinary delete would have used, and from the
/// trash it is the permanent delete the card is already one keystroke from. Cards only a lane's
/// delete is physical already, so there is nothing for "skip the trash" to mean on one.
///
/// **It registers no undo step**, `deleteTrashCards`' ruling and its wording.
///
/// The selection is cleared rather than walked to a successor: unlike , this is the command a
/// confirmation stands in front of, and what follows it is reading the board rather than pressing
/// the key again.
///
/// - Parameters:
/// - ids: the items to purge the caller's explicit set, not this store's selection.
/// - container: **which side those ids live on**, supplied rather than read off the selection
/// (added with the context menu's -alternate 11-command-nexus.md Context menus). The
/// menu-bar caller passes the selection's own container and behaves exactly as before; a
/// context-menu caller passes `.board`, because "the click names its target" and the target can
/// legitimately sit on the other side of the container boundary from a standing selection
/// (797d020's explicit-set resolution, whose whole point is that the two can disagree). Reading
/// it off the selection here would silently resolve a board card against `.trash` and purge
/// nothing a confirmed destructive command turning into a no-op, which is the one outcome a
/// confirmation must never lead to.
public func deleteImmediately(_ ids: Set<ItemID>, in container: ItemContainer) {
let paths = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
guard !paths.isEmpty else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
for path in paths {
switch path {
case .trashCard:
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
default:
try BoardWriter.purgeItem(at: path.folder(under: root))
}
}
}
clearSelection()
}
/// **Empty Trash ** purges every card in `<root>/.trash/`, in one bracket.
///
/// Not undoable, `deleteTrashCards`' ruling this is the other half of 13's "Permanently delete".
+7 -22
View File
@@ -41,25 +41,19 @@ public enum TrashModel {
public let confirmTitle: String
}
/// The alert in front of a **permanent** card delete the trash's own / and File Delete
/// Immediately alike (03-board-ui.md § Trash: "Both confirm exactly where the loss is real ...
/// the alert stands between one keystroke and unrecoverable deletion").
/// The alert in front of a **permanent** card delete the trash's own / (03-board-ui.md §
/// Trash: "confirms exactly where the loss is real: the alert stands between one keystroke and
/// unrecoverable deletion").
///
/// `container` is where the command found the cards: the trash for the trash's Delete, the board
/// for a Delete Immediately that skips the trash from a lane. The prompt reads the same either
/// way what is being asked is whether to destroy these cards, and where they happen to be
/// sitting is not the question.
///
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in that
/// container, which is also the command's own refusal so the prompt and the action can never
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in the
/// trash, which is also the command's own refusal so the prompt and the action can never
/// disagree about whether there is anything to purge.
public static func purgePrompt(
for ids: Set<ItemID>,
in container: ItemContainer,
snapshot: BoardModel,
unrecoverable: Bool
) -> PurgePrompt? {
let targets = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
let targets = ItemPath.resolve(ids, in: .trash, snapshot: snapshot).filter { !$0.isLane }
guard !targets.isEmpty else { return nil }
let subject: String
@@ -98,7 +92,7 @@ public enum TrashModel {
/// undo as its net).
private static func message(unrecoverable: Bool) -> String {
// m7-git: on a git board the content stays reachable in history, so the second sentence is
// the honest one and Delete Immediately does not confirm there at all
// the honest one and the trash's own Delete does not confirm there at all
// (`BoardStore.purgeIsUnrecoverable`).
unrecoverable
? "This can\u{2019}t be undone."
@@ -135,13 +129,4 @@ public enum TrashModel {
public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
!ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).isEmpty
}
/// Whether File Delete Immediately has something to purge **a card selection, from anywhere**
/// (11-command-nexus.md: "Board window, card selection skips the trash from anywhere").
///
/// Cards only, in either container: a lane's delete is physical already and has undo as its net,
/// so there is nothing for "skip the trash" to mean on one.
public static func canDeleteImmediately(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).contains { !$0.isLane }
}
}
+7 -7
View File
@@ -1248,12 +1248,12 @@ public enum BoardWriter: Sendable {
EchoLedger.current?.recordDeletion(at: laneFolder)
}
/// Permanently removes one card from the trash the trash's **Delete / Delete Immediately**
/// Permanently removes one card from the trash the trash's own **Delete**
/// (03-board-ui.md § Trash: "on a trash card, Delete (/) is permanent").
///
/// `purgeItem` with the container checked: the folder must actually sit in this board's
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. Delete Immediately
/// *from* the board which skips the trash is `purgeItem`, not this call.
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. `purgeItem`
/// (unconstrained) is for the create-undo's own removal, not this call.
///
/// An already-gone folder is success, `purgeItem`'s rule.
public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
@@ -1536,10 +1536,10 @@ public enum BoardWriter: Sendable {
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
}
/// Physical removal Delete Immediately / Empty Trash (03-board-ui.md): deletes the
/// folder tree from disk. Irreversible, and distinct from the ordinary delete, which is a
/// *move* into `.trash/` this call does **not** require the item to be in the trash first,
/// since Delete Immediately "skips the trash from anywhere" by design.
/// Physical removal the create-undo's own primitive (13-native-undo.md: "create remove the
/// created folder"): deletes the folder tree from disk. Irreversible, and distinct from the
/// ordinary delete, which is a *move* into `.trash/` this call does **not** require the item to
/// be in the trash first, since an undone create's folder was never trashed to begin with.
///
/// **A folder that is already gone is success, not an error** checked first, before the
/// shape guard below. A Finder deletion converges on exactly the end state a purge would
+6 -7
View File
@@ -452,8 +452,7 @@ struct BoardView: View {
slotWidth: slotWidth,
drops: dropContext,
marquee: marqueeControl,
openCard: openCard,
confirmations: confirmations
openCard: openCard
)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
@@ -679,11 +678,11 @@ struct BoardView: View {
/// Inert while an inline editor is open, like every grammar key: the field owns as backspace,
/// and a stray one reaching the board mid-edit would delete the item being renamed.
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
// **Plain , spelled out.** The modified chords belong to the menu (Delete),
// (Delete Immediately), (Empty Trash) and AppKit routes a key equivalent to the
// menu before the view sees it. But and are nobody's key equivalent, and a fall-through
// that deleted the selection on a mistyped text-editing chord would be exactly the kind of
// accident 04-interactions.md's fixed grammar is careful to avoid.
// **Plain , spelled out.** The modified chords belong to the menu (Delete),
// (Empty Trash) and AppKit routes a key equivalent to the menu before the view sees it.
// But and are nobody's key equivalent, and a fall-through that deleted the selection on
// a mistyped text-editing chord would be exactly the kind of accident 04-interactions.md's
// fixed grammar is careful to avoid.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
}
+14 -60
View File
@@ -27,16 +27,14 @@ import SwiftUI
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
/// it needs the window's confirmation host (11-command-nexus.md Context menus' Trash cards row).
///
/// **Both sides carry the confirmation host now** (settled Delete's -alternate, Delete
/// Immediately, 11-command-nexus.md Context menus' Card row): the board side's Delete stays the
/// ordinary staged move, but the alternate skips straight to the permanent purge, which needs the
/// same window-level alert the trash side's Delete already does (`TrashConfirmations`).
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
/// card opener.
enum CardFaceRole {
/// A card in a lane. Carries the board window's card opener 's pointer twin
/// (04-interactions.md Selection) and the window's purge-alert host, for Delete's
/// -alternate.
case board(openCard: (ItemID) -> Void, confirmations: TrashConfirmations)
/// (04-interactions.md Selection).
case board(openCard: (ItemID) -> Void)
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
@@ -149,7 +147,7 @@ struct CardFaceView: View {
@ViewBuilder
var body: some View {
switch role {
case let .board(openCard, confirmations):
case let .board(openCard):
face
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
@@ -164,18 +162,14 @@ struct CardFaceView: View {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
.contextMenu { boardMenu(openCard: openCard, confirmations: confirmations) }
.contextMenu { boardMenu(openCard: openCard) }
// **The menu's rows, additionally as custom actions** "where SwiftUI additionally
// surfaces menu items as custom accessibility actions, that's free improvement, not
// a separate design surface" (10-accessibility.md Actions come from the context
// menu). The menu stays the inventory and stays reachable the standard way (VO--M).
// Style is absent for `LaneView`'s reason: it opens a popover, and the quick-style
// swatch `Picker` beside it is not an action. Delete Immediately rides along too,
// its own row rather than the -alternate above VoiceOver's action rotor has no
// held-key concept, so the alternate needs a first-class custom action of its own
// (11-command-nexus.md Context menus' Card row: "surfaces as its own VO custom
// action per 10's cut").
.accessibilityActions { boardActions(openCard: openCard, confirmations: confirmations) }
// swatch `Picker` beside it is not an action.
.accessibilityActions { boardActions(openCard: openCard) }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
@@ -453,10 +447,10 @@ struct CardFaceView: View {
// MARK: - Context menus
/// Open, Rename, Style, the quick-style recents row, Delete with Delete Immediately as its
/// -alternate 11-command-nexus.md Context menus' Card row, in its order.
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order.
@ViewBuilder
private func boardMenu(openCard: @escaping (ItemID) -> Void, confirmations: TrashConfirmations) -> some View {
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
@@ -484,38 +478,19 @@ struct CardFaceView: View {
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
//
// **Delete Immediately rides as its -alternate** (Finder's own pattern hold and Delete
// becomes Delete Immediately, 11-command-nexus.md Context menus' Card row, settled
// 2026-07-29). `.modifierKeyAlternate(.option)` is SwiftUI's macOS-native mechanism for
// exactly this swap (macOS 15+); the alternate's title matches File Delete Immediately's
// own verbatim (`TrashCommands`), since menu titles are the system remapping key and the two
// rows name the same command. It widens over the same `targetIDs` Delete itself reads, so an
// -held click purges exactly what a plain click would have trashed.
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
.modifierKeyAlternate(.option) {
Button("Delete Immediately") { requestDeleteImmediately(confirmations) }
.disabled(!canDeleteImmediately)
}
}
/// `boardMenu`'s plain rows as VoiceOver custom actions every one calling the *same* private
/// method its menu row does, so the two surfaces cannot come to mean different things.
///
/// Delete Immediately gets its own row here rather than riding `modifierKeyAlternate` the
/// action rotor has no held-key concept, so the alternate needs a first-class custom action of
/// its own to be reachable at all ("surfaces as its own VO custom action per 10's cut",
/// 11-command-nexus.md Context menus' Card row).
@ViewBuilder
private func boardActions(openCard: @escaping (ItemID) -> Void, confirmations: TrashConfirmations) -> some View {
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
Button("Open") { openCard(card.id) }
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") { requestDeleteImmediately(confirmations) }
.disabled(!canDeleteImmediately)
}
/// Delete and Reveal in Finder the two rows 11-command-nexus.md gives a trash card, and no
@@ -565,15 +540,6 @@ struct CardFaceView: View {
confirmations.requestTrashDelete(of: targetIDs, in: store)
}
/// The board side's **permanent** delete Delete's -alternate and its VoiceOver custom-action
/// twin through the same window confirmation host `requestPurge` above uses, but
/// `TrashConfirmations`'s board-side entry point (`requestBoardPurge`): the alert (or the
/// git-board shrug) is what stands between this row and an unrecoverable loss, exactly as it does
/// for the trash's own Delete (03 § Trash).
private func requestDeleteImmediately(_ confirmations: TrashConfirmations) {
confirmations.requestBoardPurge(of: targetIDs, in: store)
}
private func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
@@ -604,18 +570,6 @@ struct CardFaceView: View {
return store.selection.ids
}
/// Whether Delete Immediately's -alternate has something to purge File Delete Immediately's
/// own predicate (`TrashModel.canDeleteImmediately`), read over this row's `targetIDs` rather
/// than the standing selection, `targetIDs`' own reason: a context menu names its target by where
/// it was invoked (11-command-nexus.md Context menus' Card row).
private var canDeleteImmediately: Bool {
guard store.acceptsBoardMutations else { return false }
return TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: targetIDs, container: role.container),
in: store.snapshot
)
}
/// The folders Reveal in Finder points at resolved in this face's container, so a trash row
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
private var targetFolders: [URL] {
@@ -655,7 +609,7 @@ struct CardFaceView: View {
onCommitAndOpen: {
let id = card.id
store.commitRename()
if case let .board(openCard, _) = role { openCard(id) }
if case let .board(openCard) = role { openCard(id) }
}
)
.font(.body)
+3 -50
View File
@@ -21,8 +21,8 @@ import SwiftUI
///
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Rename, Style,
/// the quick-style recents row, the Width stepper and Delete with Delete Immediately as its
/// -alternate 11-command-nexus.md Context menus' Lane row, in its order, complete as of m5.
/// the quick-style recents row, the Width stepper and Delete 11-command-nexus.md Context menus'
/// Lane row, in its order, complete as of m5.
///
/// ### The card face
///
@@ -65,11 +65,6 @@ struct LaneView: View {
/// business knowing about `WindowGroup` keys.
let openCard: (ItemID) -> Void
/// The window's purge-alert host Delete's -alternate needs it to raise the same confirmation
/// File Delete Immediately does (`TrashConfirmations`, `BoardView`'s own copy handed straight
/// down, since a lane has no business owning window-scoped state either).
let confirmations: TrashConfirmations
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
/// and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -303,27 +298,14 @@ struct LaneView: View {
// Delete: File Delete's exact store path (`store.delete`), on the same widened target set
// Style above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling below) the
// successor-selection rule is `delete(_:)`'s own, so this row gets it for free.
//
// **Delete Immediately as its -alternate** the same swap the card row wears
// (11-command-nexus.md Context menus' Lane row: "Delete with the same -alternate Delete
// Immediately. The trash needs no alternate"), `modifierKeyAlternate(.option)` again, the
// title matching File Delete Immediately's own verbatim (`TrashCommands`).
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
.modifierKeyAlternate(.option) {
Button("Delete Immediately") { requestDeleteImmediately() }
.disabled(!canDeleteImmediately)
}
}
/// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions`
/// call site). Every one of them calls the *same* private method its menu row does, so the two
/// surfaces cannot drift into meaning different things which is the only way "not a separate
/// design surface" is checkable rather than merely intended.
///
/// Delete Immediately gets its own row rather than riding `modifierKeyAlternate`, `CardFaceView
/// .boardActions`' own reason: the action rotor has no held-key concept, so the alternate needs a
/// first-class custom action to be reachable at all.
@ViewBuilder
private var laneActions: some View {
let units = LaneLayoutMath.displayUnits(of: lane)
@@ -335,8 +317,6 @@ struct LaneView: View {
.disabled(!store.acceptsBoardMutations || units <= 1)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") { requestDeleteImmediately() }
.disabled(!canDeleteImmediately)
}
/// Board Rename's store path, seeded with the lane's live title one method, two callers
@@ -351,15 +331,6 @@ struct LaneView: View {
store.delete(targetIDs)
}
/// Delete's -alternate the lane's **permanent** delete, through the window's confirmation host
/// rather than straight to the store (`CardFaceView.requestDeleteImmediately`'s own reason): the
/// alert (or the git-board shrug) is what stands between this row and an unrecoverable loss.
/// `TrashConfirmations.requestBoardPurge` is the shared entry point both rows call, over each
/// one's own `targetIDs`.
private func requestDeleteImmediately() {
confirmations.requestBoardPurge(of: targetIDs, in: store)
}
/// VO-Space's landing: the -click funnel, on this lane. `togglesOnRepeat` stays false because
/// only the *plain* branch reads it the branch is already a toggle, which is the point.
private func toggleLaneSelection() {
@@ -418,24 +389,6 @@ struct LaneView: View {
return store.selection.ids
}
/// Whether Delete Immediately's -alternate has something to purge File Delete Immediately's
/// own predicate (`TrashModel.canDeleteImmediately`), read over this row's `targetIDs`
/// (`CardFaceView.canDeleteImmediately`'s own reason: a context menu names its target by where it
/// was invoked, not by the standing selection).
///
/// **Presently always disabled on a lane-only target**: `canDeleteImmediately` is cards only
/// today "a lane's delete is physical already there is nothing for 'skip the trash' to mean
/// on one" (`TrashModel`) so this row is wired per 11-command-nexus.md's Lane row ahead of the
/// store gaining the capability, the same posture the File-menu command already takes on a
/// lane-only selection.
private var canDeleteImmediately: Bool {
guard store.acceptsBoardMutations else { return false }
return TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: targetIDs, container: .board),
in: store.snapshot
)
}
private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
@@ -716,7 +669,7 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
role: .board(openCard: openCard, confirmations: confirmations),
role: .board(openCard: openCard),
marquee: marquee,
drops: drops
)
+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
}