Realign code with the 2026-07-30 findings-resolution rulings
Delete Immediately is removed entirely (rulingae1dd96, 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 (2ec2c95registry freshness stamp,c741b02unified-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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1705,10 +1705,11 @@ struct BoardWriterCopyTests {
|
||||
|
||||
// MARK: - Purge
|
||||
|
||||
/// `BoardWriter.purgeItem`: physical removal — Delete Immediately / Empty Trash
|
||||
/// (01-storage-format.md § Deletion) — irreversible, and distinct from the ordinary delete, which
|
||||
/// is a *move* into `.trash/`. Does not require the item to be in the trash first: Delete
|
||||
/// Immediately "skips the trash from anywhere" by design.
|
||||
/// `BoardWriter.purgeItem`: physical removal — the create-undo's own primitive
|
||||
/// (01-storage-format.md § Deletion; 13-native-undo.md: "create → remove the created folder") —
|
||||
/// irreversible, and distinct from the ordinary delete, which is a *move* into `.trash/`. Does not
|
||||
/// require the item to be in the trash first: an undone create's folder was never trashed to begin
|
||||
/// with.
|
||||
struct BoardWriterPurgeTests {
|
||||
@Test func purgeRemovesTheFolderTreeIncludingNestedContentFromDisk() throws {
|
||||
let fixture = try WriterFixture()
|
||||
|
||||
@@ -224,42 +224,18 @@ struct TrashPhrasingTests {
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
let sole = try #require(TrashModel.purgePrompt(
|
||||
for: [cardF], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||
for: [cardF], snapshot: snapshot, unrecoverable: true
|
||||
))
|
||||
#expect(sole.title == "Permanently delete \u{201C}Newest\u{201D}?")
|
||||
#expect(sole.message == "This can\u{2019}t be undone.")
|
||||
#expect(sole.confirmTitle == "Delete")
|
||||
|
||||
let several = try #require(TrashModel.purgePrompt(
|
||||
for: [cardE, cardF], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||
for: [cardE, cardF], snapshot: snapshot, unrecoverable: true
|
||||
))
|
||||
#expect(several.title == "Permanently delete 2 cards?")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("Delete Immediately reads the same either side of the boundary")
|
||||
func purgePromptSpansContainers() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
let fromBoard = try #require(TrashModel.purgePrompt(
|
||||
for: [card1], in: .board, snapshot: snapshot, unrecoverable: true
|
||||
))
|
||||
#expect(fromBoard.title == "Permanently delete \u{201C}First\u{201D}?",
|
||||
"11 ▸ Delete Immediately skips the trash from anywhere")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("A lane in the set contributes nothing — no purge path reaches one")
|
||||
func lanesAreNeverPurged() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
#expect(TrashModel.purgePrompt(for: [laneA], in: .board, snapshot: snapshot, unrecoverable: true) == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("An untitled card reads as the untitled rendering, never as an empty pair of quotes")
|
||||
func untitledReadsAsARendering() throws {
|
||||
@@ -271,7 +247,7 @@ struct TrashPhrasingTests {
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
let prompt = try #require(TrashModel.purgePrompt(
|
||||
for: [cardD], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||
for: [cardD], snapshot: snapshot, unrecoverable: true
|
||||
))
|
||||
#expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?")
|
||||
}
|
||||
@@ -284,9 +260,9 @@ struct TrashPhrasingTests {
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
#expect(TrashModel.purgePrompt(
|
||||
for: [ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||
for: [ItemID(rawValue: Ident.indexless)], snapshot: snapshot, unrecoverable: true
|
||||
) == nil)
|
||||
#expect(TrashModel.purgePrompt(for: [], in: .board, snapshot: snapshot, unrecoverable: true) == nil)
|
||||
#expect(TrashModel.purgePrompt(for: [], snapshot: snapshot, unrecoverable: true) == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -344,22 +320,4 @@ struct TrashValidationTests {
|
||||
selection: ItemReferenceSet(ids: [cardD], container: .board), in: snapshot
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Delete Immediately is cards only, in either container")
|
||||
func canDeleteImmediatelyIsCardsOnly() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try load(fixture)
|
||||
|
||||
#expect(TrashModel.canDeleteImmediately(
|
||||
selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot
|
||||
))
|
||||
#expect(TrashModel.canDeleteImmediately(
|
||||
selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot
|
||||
))
|
||||
#expect(!TrashModel.canDeleteImmediately(
|
||||
selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot
|
||||
), "a lane's delete is physical already — there is nothing for 'skip the trash' to mean")
|
||||
#expect(!TrashModel.canDeleteImmediately(selection: .empty, in: snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `BoardStore`'s trash operations — Delete (staged by place), Delete Immediately, Empty Trash, and
|
||||
/// the legacy tombstone migration (03-board-ui.md § Trash, resettled 2026-07-28; 01-storage-format.md
|
||||
/// § Deletion).
|
||||
/// `BoardStore`'s trash operations — Delete (staged by place), Empty Trash, and the legacy tombstone
|
||||
/// migration (03-board-ui.md § Trash, resettled 2026-07-28; 01-storage-format.md § Deletion).
|
||||
///
|
||||
/// These drive a **real store over a real temp board** and then read the **raw bytes** back, never
|
||||
/// the app's own read path, like every other write suite here: the interesting claims are about the
|
||||
@@ -377,51 +376,12 @@ struct StagedDeleteTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Immediately and Empty Trash
|
||||
// MARK: - Empty Trash
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardStore ▸ purge")
|
||||
struct PurgeTests {
|
||||
|
||||
@Test("Delete Immediately skips the trash from a lane")
|
||||
func skipsTheTrashFromTheBoard() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([card1], in: .board)
|
||||
|
||||
store.deleteImmediately([card1], in: .board)
|
||||
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
#expect(!fixture.exists(".trash/\(Ident.card1)"), "03 ▸ Trash: ⌥⌘⌫ skips the trash from anywhere")
|
||||
#expect(store.selection.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Delete Immediately purges a card already in the trash")
|
||||
func purgesFromTheTrash() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([trashed], in: .trash)
|
||||
|
||||
store.deleteImmediately([trashed], in: .trash)
|
||||
|
||||
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
||||
#expect(fixture.exists(".trash/\(More.newer)"), "and only what it named")
|
||||
}
|
||||
|
||||
@Test("A lane in the set is never purged — cards only")
|
||||
func lanesAreNotPurged() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.select([lane3], in: .board)
|
||||
|
||||
store.deleteImmediately([lane3], in: .board)
|
||||
|
||||
#expect(fixture.exists(Ident.lane3))
|
||||
}
|
||||
|
||||
@Test("Empty Trash removes every card in the container, and leaves strays verbatim")
|
||||
func emptyTrashIsWholeScope() throws {
|
||||
let fixture = try makeBoard()
|
||||
@@ -473,10 +433,10 @@ struct PurgeTests {
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
store.deleteTrashCards([trashed])
|
||||
store.deleteImmediately([newer], in: .trash)
|
||||
store.deleteTrashCards([newer])
|
||||
store.emptyTrash()
|
||||
|
||||
// 13-native-undo.md ▸ Rules: "Permanently delete (Delete Immediately, Empty Trash) …
|
||||
// 13-native-undo.md ▸ Rules: "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.
|
||||
#expect(!history.canUndo)
|
||||
@@ -733,95 +693,6 @@ struct MigrationNoticeTests {
|
||||
@Suite("TrashConfirmations")
|
||||
struct TrashConfirmationsTests {
|
||||
|
||||
@Test("Delete Immediately raises the alert where the loss is real, and purges on confirm")
|
||||
func purgeConfirmsThenActs() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
store.select([trashed], in: .trash)
|
||||
|
||||
confirmations.requestPurge(of: [trashed], in: store)
|
||||
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
|
||||
#expect(pending.action == .purge([trashed], .trash))
|
||||
// Nothing has happened yet — the alert is what stands between the keystroke and the loss.
|
||||
#expect(fixture.exists(".trash/\(Ident.indexless)"))
|
||||
|
||||
confirmations.confirm(in: store)
|
||||
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
|
||||
#expect(confirmations.pending == nil)
|
||||
// Idempotent: the binding's own dismissal fires an instant after the button.
|
||||
confirmations.confirm(in: store)
|
||||
}
|
||||
|
||||
/// **The card and lane context menus' ⌥-alternate** — Delete Immediately, routed through
|
||||
/// `requestBoardPurge` rather than through `requestPurge` (11-command-nexus.md ▸ Context menus'
|
||||
/// Card and Lane rows: "Delete — with Delete Immediately as its ⌥-alternate").
|
||||
/// `purgeConfirmsThenActs`'s twin for the board side: same alert, same rule, a board card as the
|
||||
/// target instead of a trash one.
|
||||
@Test("The board-side ⌥-alternate raises the same alert, and purges the board card on confirm")
|
||||
func boardPurgeConfirmsThenActs() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
confirmations.requestBoardPurge(of: [card1], in: store)
|
||||
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.prompt.title == "Permanently delete \u{201C}First\u{201D}?")
|
||||
#expect(pending.action == .purge([card1], .board))
|
||||
// Nothing has happened yet — same alert, same rule.
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
|
||||
confirmations.confirm(in: store)
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||
#expect(!fixture.exists(".trash/\(Ident.card1)"), "skips the trash — purged, not moved")
|
||||
#expect(confirmations.pending == nil)
|
||||
}
|
||||
|
||||
/// A context menu names its target by where it was invoked, so a card row's Delete Immediately
|
||||
/// must purge the *clicked* card even while a different card is selected — `TrashMenuValidation
|
||||
/// Tests.contextMenuDeleteIgnoresTheSelection`'s claim, mirrored onto the board side.
|
||||
@Test("The board-side ⌥-alternate acts on its own target, not the standing selection")
|
||||
func boardPurgeIgnoresTheSelection() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
// A right-click on `card1` without first selecting it must still purge `card1`, never the
|
||||
// card the standing selection happens to hold (`CardFaceView.targetIDs`'s targeting rule).
|
||||
store.select([card2], in: .board)
|
||||
|
||||
confirmations.requestBoardPurge(of: [card1], in: store)
|
||||
let pending = try #require(confirmations.pending)
|
||||
#expect(pending.action == .purge([card1], .board))
|
||||
|
||||
confirmations.confirm(in: store)
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"), "the clicked card is gone")
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "the selected card was never the subject")
|
||||
}
|
||||
|
||||
/// `TrashModel.canDeleteImmediately` is cards only (`TrashValidationTests
|
||||
/// .canDeleteImmediatelyIsCardsOnly`: "a lane's delete is physical already … there is nothing for
|
||||
/// 'skip the trash' to mean on one"), and the lane row's alternate inherits that unchanged: it is
|
||||
/// wired per 11-command-nexus.md's Lane row, but presently inert on a lane-only target — the same
|
||||
/// posture File ▸ Delete Immediately already takes on a lane-only selection.
|
||||
@Test("A lane-only target raises no prompt — the alternate is still cards only")
|
||||
func boardPurgeIsStillCardsOnlyForALaneTarget() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
confirmations.requestBoardPurge(of: [lane1], in: store)
|
||||
|
||||
#expect(confirmations.pending == nil)
|
||||
#expect(fixture.exists(Ident.lane1))
|
||||
}
|
||||
|
||||
/// 03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent … Both confirm exactly
|
||||
/// where the loss is real."
|
||||
@Test("The trash's own Delete confirms; the board's goes straight through")
|
||||
@@ -856,7 +727,7 @@ struct TrashConfirmationsTests {
|
||||
let confirmations = TrashConfirmations()
|
||||
store.select([trashed], in: .trash)
|
||||
|
||||
confirmations.requestPurge(of: [trashed], in: store)
|
||||
confirmations.requestTrashDelete(of: [trashed], in: store)
|
||||
confirmations.cancel()
|
||||
|
||||
#expect(confirmations.pending == nil)
|
||||
@@ -892,20 +763,21 @@ struct TrashConfirmationsTests {
|
||||
confirmations.requestEmptyTrash(in: store)
|
||||
#expect(confirmations.pending == nil)
|
||||
|
||||
confirmations.requestPurge(of: [lane1], in: store)
|
||||
// A board lane names nothing in the trash — the trash-side Delete's own refusal.
|
||||
confirmations.requestTrashDelete(of: [lane1], in: store)
|
||||
#expect(confirmations.pending == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The menu-validation seams
|
||||
|
||||
/// The trash's three File-menu rows, validated as predicates rather than as menu items — 11-command
|
||||
/// The trash's two File-menu rows, validated as predicates rather than as menu items — 11-command
|
||||
/// -nexus.md's inventory, and 03-board-ui.md § Trash's rulings about scope.
|
||||
///
|
||||
/// The rows themselves are `TrashCommands`, whose whole body is one `disabled(…)` per row over these
|
||||
/// answers; what is worth pinning is the answers. `TrashModel.canDelete`/`canDeleteImmediately` are
|
||||
/// pinned as pure functions in `TrashModelTests`; this suite covers the two seams that need a live
|
||||
/// store — Empty Trash's scope, and the staging a ⌘⌫ actually performs.
|
||||
/// answers; what is worth pinning is the answers. `TrashModel.canDelete` is pinned as a pure function
|
||||
/// in `TrashModelTests`; this suite covers the two seams that need a live store — Empty Trash's scope,
|
||||
/// and the staging a ⌘⌫ actually performs.
|
||||
@MainActor
|
||||
@Suite("The trash's menu validation")
|
||||
struct TrashMenuValidationTests {
|
||||
@@ -1076,7 +948,6 @@ struct TrashGrammarExclusionTests {
|
||||
store.select([trashed], in: .trash)
|
||||
|
||||
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
|
||||
#expect(TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot))
|
||||
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
|
||||
.map { $0.folder(under: store.rootURL).lastPathComponent } == [Ident.indexless])
|
||||
}
|
||||
|
||||
@@ -811,7 +811,7 @@ struct BodyUndoTests {
|
||||
@Suite("Undo ▸ the operations that register nothing")
|
||||
struct NotUndoableTests {
|
||||
|
||||
@Test("Delete Immediately registers nothing — the confirm is the safety")
|
||||
@Test("The trash's permanent delete registers nothing — the confirm is the safety")
|
||||
func purgeRegistersNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -820,7 +820,7 @@ struct NotUndoableTests {
|
||||
let armed = try #require(history.undoActionName)
|
||||
|
||||
store.select([trashed], in: .trash)
|
||||
store.deleteImmediately([trashed], in: .trash)
|
||||
store.deleteTrashCards([trashed])
|
||||
|
||||
#expect(fixture.exists(trashedPath) == false)
|
||||
#expect(store.purgeIsUnrecoverable)
|
||||
|
||||
@@ -25,7 +25,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
||||
|
||||
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. Lanes are never trashed: deleting a lane deletes it, folder and contents, with undo as the net. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, ⌥⌘⌫ Delete Immediately skips the trash from anywhere, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
||||
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. Lanes are never trashed: deleting a lane deletes it, folder and contents, with undo as the net. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
||||
|
||||
- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and and the shown trash filters like any other lane. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user