diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 7392e3f..5d96014 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -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, diff --git a/Kanban/History/BoardStoreHistory.swift b/Kanban/History/BoardStoreHistory.swift index 80c3589..9ee53e8 100644 --- a/Kanban/History/BoardStoreHistory.swift +++ b/Kanban/History/BoardStoreHistory.swift @@ -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 diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index ffeff57..4f8d6ed 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -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: diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 5dd5bec..eef80b5 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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, 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 `/.trash/`, in one bracket. /// /// Not undoable, `deleteTrashCards`' ruling — this is the other half of 13's "Permanently delete". diff --git a/Kanban/LiveStore/TrashModel.swift b/Kanban/LiveStore/TrashModel.swift index 498ba69..54c9421 100644 --- a/Kanban/LiveStore/TrashModel.swift +++ b/Kanban/LiveStore/TrashModel.swift @@ -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, - 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 } - } } diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 057f0bd..c6c73c3 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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 diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 79c809d..d825a68 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -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 } diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 582b35b..52314ea 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -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 `/.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 `/.trash/` 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) diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 8720d7e..47a754b 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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` 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 ) diff --git a/Kanban/UI/Board/TrashCommands.swift b/Kanban/UI/Board/TrashCommands.swift index 577f44b..9393b39 100644 --- a/Kanban/UI/Board/TrashCommands.swift +++ b/Kanban/UI/Board/TrashCommands.swift @@ -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) - /// **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, 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, 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, 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, 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 } diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 82c2560..7c09651 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -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() diff --git a/KanbanTests/TrashModelTests.swift b/KanbanTests/TrashModelTests.swift index dbabe8b..e1154f7 100644 --- a/KanbanTests/TrashModelTests.swift +++ b/KanbanTests/TrashModelTests.swift @@ -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)) - } } diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index 6c4c138..fbaf83e 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -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]) } diff --git a/KanbanTests/UndoWriteTests.swift b/KanbanTests/UndoWriteTests.swift index 94e856b..a43e89e 100644 --- a/KanbanTests/UndoWriteTests.swift +++ b/KanbanTests/UndoWriteTests.swift @@ -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) diff --git a/README.md b/README.md index 9b63c66..8c2b6fb 100644 --- a/README.md +++ b/README.md @@ -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.