diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 97bb9e4..ca61650 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -39,6 +39,11 @@ struct BoardWindowHost: View { /// menu item through the focus system, like the store. @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. + @State private var trashConfirmations = TrashConfirmations() + @State private var phase: Phase = .opening private enum Phase { @@ -80,15 +85,18 @@ struct BoardWindowHost: View { BoardView( store: store, window: { windowController.window }, + confirmations: trashConfirmations, openCard: { cardID in openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID)) } ) } // "The board in front", for the menu items that act on it (`LaneWidthCommands`), and - // beside it the window's own popover flag, which is what File ▸ Board Info toggles. + // beside it the window's own popover flag, which is what File ▸ Board Info toggles, and + // its purge-alert host, which the trash's two confirmed commands raise. .focusedSceneValue(\.boardStore, store) .focusedSceneValue(\.boardInfo, boardInfo) + .focusedSceneValue(\.trashConfirmations, trashConfirmations) } } diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 06a98fa..3b24ac2 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -108,8 +108,9 @@ struct KanbanApp: App { @CommandsBuilder private var menuCommands: some Commands { // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board…, - // still owed), Open…, (Open Recent, still owed), Board Info. The creation pair and Board - // Info all validate against the frontmost board through the focus system, so each is simply + // still owed), Open…, (Open Recent, still owed), Board Info, (Duplicate / Save as Template / + // Reveal in Finder, still owed), then the trash trio and Empty Trash…. Every one of them + // validates against the frontmost board through the focus system, so each is simply // absent-of-effect when no board is in front. CommandGroup(after: .newItem) { BoardCreationCommands() @@ -124,6 +125,17 @@ struct KanbanApp: App { Divider() BoardInfoCommand() + + Divider() + + TrashCommands() + } + + // The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own + // items live in — which is where 11-command-nexus.md files Show Trash, alongside the card + // window's Edit Body / Raw Source / History still to come. + CommandGroup(after: .toolbar) { + ShowTrashCommand() } // The Board menu (11-command-nexus.md), in its inventoried order — Rename, then Style…, diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index c55975d..babcf15 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -422,9 +422,13 @@ public final class BannerCenter { /// /// Titles are quoted where the operation carries one and the phrasing stays graceful where it /// 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 are - /// Finder's, matching the commands the user pressed (03-board-ui.md § Trash): Move to Trash, - /// Put Back, Delete Immediately. + /// "the item" rather than guessing "card" and being wrong about a lane. + /// + /// The trash verbs match the commands the user pressed — **Delete**, Put Back, Delete + /// Immediately — 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: @@ -440,7 +444,7 @@ public final class BannerCenter { case let .copy(title): if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" } case let .delete(title): - if let title { "Couldn't move '\(title)' to the trash" } else { "Couldn't move the item to the trash" } + if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" } case let .restore(title): if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" } case let .purge(title): diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index be327bd..9f3fac4 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1070,6 +1070,188 @@ public final class BoardStore { } } + // MARK: - The trash + + /// Whether physically removing an item on this board destroys the only copy of it — and + /// therefore whether Delete Immediately stands an alert between one keystroke and unrecoverable + /// deletion (03-board-ui.md § Trash, "Delete Immediately confirms exactly where the loss is + /// real"). + /// + /// **Every board is `true` today**, because every board is history mode *none*: nothing in the + /// app keeps a second copy, so a purge is final everywhere. + /// + // m7-git: git boards answer `false` here — "on git boards it acts immediately, since the content + // remains reachable in history" (06-history-undo.md's delete-never-forgets). Repo-nested boards + // stay `true` alongside mode none: the app manages no history for them either. The named + // predicate exists now so the committer card changes one expression rather than hunting the + // confirmation logic out of two menu items and an alert. + public var purgeIsUnrecoverable: Bool { true } + + /// Tombstones the current selection — File ▸ Delete ⌘⌫ and its plain-⌫ grammar twin + /// (04-interactions.md ▸ The map, 11-command-nexus.md). + /// + /// A convenience over `delete(_:)` so the two call sites cannot disagree about *what* the + /// command acts on. + public func deleteSelection() { + delete(selection.ids) + } + + /// Tombstones every live item in `ids` — cards or lanes, in one bracket. + /// + /// **One `performWrite` whatever the set's size**, matching the style batch's rule and for its + /// reason: one gesture, one app-mediated reload, and (on git boards) one commit rather than N. + /// A lane's tombstone rewrites only the lane's own `index.md` — hiding the subtree is the + /// renderer's ancestor walk, not a stored flag (`BoardWriter.deleteItem`). + /// + /// **Tombstoned ids are silently skipped**, not refused: the paths are resolved on the live side + /// only, so a selection the next reload will drop writes nothing rather than re-stamping a + /// `deleted:` that is already there. An empty resolution never opens the bracket at all. + /// + /// The selection is **cleared**, not moved to a successor. 04-interactions.md ▸ The map asks for + /// the Finder-style successor sibling ("repeated ⌫ walks down a lane"), which needs the + /// navigation order the keyboard grammar defines — that is m5's card. Clearing is the honest + /// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the + /// set a moment later anyway. + public func delete(_ ids: Set) { + let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) } + guard !folders.isEmpty else { return } + + try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + try BoardWriter.deleteItem(at: folder) + } + } + // m5-keyboard: the successor-selection grammar replaces this line. + clearSelection() + } + + /// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket + /// (03-board-ui.md § Trash). + /// + /// **Restore fidelity is perfect because nothing ever moved** — the item re-enters the visible + /// set at its recorded `order` among its current siblings, and the folder is exactly where it + /// has been all along (`BoardWriter.restoreItem`). + /// + /// **Putting back a lane splits its contents by flag for free.** The write is the lane's own + /// `index.md` and nothing else, so cards hidden *with* the lane return with it while cards + /// carrying their own `deleted:` stay tombstoned — and their rows reappear in the trash, which + /// is exactly the two-step recovery the design settled on. + /// + /// A card whose lane is itself tombstoned cannot be reached this way through the UI (it has no + /// row — `TrashModel.entries`), but the path resolution admits it, and the outcome is the pure + /// view's honest one: the key is removed, and the card still renders nowhere because its lane + /// is still tombstoned. Putting the lane back then shows it. + /// + /// The selection is deliberately left alone: the restored items flip liveness, and the reload's + /// resolve rule ejects them from a `.trashed` set as a vanish — the same silent shrink an + /// external restore would produce. + public func putBack(_ ids: Set) { + let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) } + guard !folders.isEmpty else { return } + + try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + try BoardWriter.restoreItem(at: folder) + } + } + } + + /// Delete Immediately ⌥⌘⌫: physically removes every tombstoned item in `ids` (03-board-ui.md § + /// Trash), in one bracket. + /// + /// **The confirmation is not here.** Whether the loss is real is `purgeIsUnrecoverable`'s + /// question and the alert is the window's; a store method that put up its own dialog could not + /// be driven from a test, and the same purge is reached by two surfaces (the menu item and the + /// trash row's context menu) that must not each grow their own copy of the rule. + /// + /// Purging a **lane** takes its whole folder — every card inside it, tombstoned or not. That is + /// what the lane entry subsuming its subtree means on disk. + public func deleteImmediately(_ ids: Set) { + let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) } + guard !folders.isEmpty else { return } + + try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + try BoardWriter.purgeItem(at: folder) + } + } + // Nothing the set named exists any more, on either side of the boundary — unlike Put Back, + // where the items merely changed sides, there is no vanish for the reload to notice on the + // trashed side that would not equally be a vanish here. + clearSelection() + } + + /// Empty Trash… ⇧⌘⌫: purges **every** tombstone on the board, in one bracket. + /// + /// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the targets come + /// from the snapshot, never from the filtered view — "a bulk command about the trash itself never + /// silently narrows to the visible subset". The filter does not reach this method at all, which + /// is the strongest form of that guarantee. + /// + /// Cards carrying their own `deleted:` under a tombstoned lane go too, without being listed: + /// they live inside the lane folder this removes (`TrashModel.emptyTrashTargets`). + public func emptyTrash() { + let folders = TrashModel.emptyTrashTargets(in: snapshot).map { $0.folder(under: rootURL) } + guard !folders.isEmpty else { return } + + try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + try BoardWriter.purgeItem(at: folder) + } + } + clearSelection() + } + + /// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane** + /// (03-board-ui.md § Trash, 04-interactions.md ▸ The trash). + /// + /// Two writes in **one bracket**, and the order is load-bearing: `restoreItem` first — the folder + /// is still where the trash row said it was — then, only when the destination differs, the move. + /// Doing it the other way round would have the second call chasing a folder the first one had + /// already relocated. + /// + /// **Same lane is a plain Put Back**: the key is removed and nothing else is touched, so the card + /// returns at its recorded `order` rather than at the bottom. "Folder moved only if the + /// destination lane differs" is the design's own wording, and the position-perfect restore is the + /// point of the trash being a pure view. + /// + // m5-drag: two things arrive with the drag card's `DropSlot` port. (1) The **positional** drop — + // the design restores "at the drop position", and the append below is the interim; the rank comes + // from `Ranks.insertionRank` over the destination's visible cards, exactly as `commitPlaceholder` + // computes it. (2) **Cross-board locality** — a drop on another board is a live *copy* by + // default with the tombstoned original staying put, and ⌘-drag forces the true restore-move. + // Both need the drag controller's target vocabulary; this method is deliberately within-board. + /// + /// Silent no-ops, all of them the reload being the authority rather than this gesture: a + /// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag + /// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing. + public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID) { + guard snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }), + let source = snapshot.lanes.first(where: { lane in + !lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted } + }) + else { return } + + let root = rootURL + let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root) + let destination = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + let crossesLanes = source.id != laneID + + try? performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.restoreItem(at: cardFolder) + guard crossesLanes else { return } + // `order: nil` is the Writer's own append — computed over the destination's *visible* + // siblings, which the arriving card is not yet among. + _ = try BoardWriter.moveItem( + at: cardFolder, + toParent: destination, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: nil + ) + } + } + // MARK: - Selection (delegated) // The thin pass-throughs to `transient`, and the only ones. diff --git a/Kanban/LiveStore/TrashModel.swift b/Kanban/LiveStore/TrashModel.swift new file mode 100644 index 0000000..a286f2d --- /dev/null +++ b/Kanban/LiveStore/TrashModel.swift @@ -0,0 +1,396 @@ +import Foundation + +// MARK: - TrashEntry + +/// One row of the trash quasi-lane (03-board-ui.md § Trash). +/// +/// **Two cases, not one**, because a tombstoned lane is not a tombstoned card wearing a different +/// symbol: it is "a single restorable entry" that *subsumes* everything beneath it, and Put Back on +/// it "returns [the lane] whole, cards and all" (04-interactions.md ▸ The trash). The card count it +/// carries is part of the entry rather than something the row re-derives, because the rule for what +/// that number means is subtle enough to want one home — see `TrashModel.entries(of:)`. +/// +/// The card case carries its lane's id for the same reason `BoardStore.liveItem` returns two +/// components rather than a URL: the row's folder is `//`, and the root is the +/// store's to supply — a mid-session folder rename may have moved it. +public enum TrashEntry: Identifiable, Sendable, Equatable { + + /// A card carrying its own `deleted:` under a **live** lane. A card whose lane is tombstoned + /// never becomes one of these — see `TrashModel.entries(of:)`'s ancestor walk. + case card(Card, laneID: ItemID) + + /// A tombstoned lane, and how many of its cards Put Back would return to the board. + case lane(Lane, returningCardCount: Int) + + public var id: ItemID { + switch self { + case let .card(card, _): card.id + case let .lane(lane, _): lane.id + } + } + + /// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a + /// value (03-board-ui.md § Card face). + public var title: String? { + switch self { + case let .card(card, _): card.title.value + case let .lane(lane, _): lane.title.value + } + } + + /// Which kind of row this is — the axis 04-interactions.md ▸ The trash makes a selection + /// homogeneous over ("a selection never mixes card entries and lane entries"). + public var isLaneEntry: Bool { + if case .lane = self { return true } + return false + } + + /// The `icon` field this row renders, so the row's symbol obeys the same lenient rule the board + /// face does (`ItemSymbol`). + public var icon: FieldValue { + switch self { + case let .card(card, _): card.icon + case let .lane(lane, _): lane.icon + } + } + + /// Where this row's folder sits under the board root. + public var path: TrashModel.ItemPath { + switch self { + case let .card(card, laneID): TrashModel.ItemPath(laneID: laneID, cardID: card.id) + case let .lane(lane, _): TrashModel.ItemPath(laneID: lane.id, cardID: nil) + } + } +} + +// MARK: - TrashModel + +/// The trash quasi-lane's contents, as a pure function of a snapshot (`TrashModelTests`) — +/// 03-board-ui.md § Trash's Contents rules with nothing else mixed in. +/// +/// **Pure because the trash is a pure view.** "Tombstoned cards keep their `deleted:` key and stay +/// exactly where they are on disk; nothing about the storage schema is trash-specific", so there is +/// no trash *state* anywhere — only this derivation, re-run against whatever snapshot is current. +/// A reload therefore rebuilds the rows for free, exactly as it rebuilds the lanes. +/// +/// The three rules it owns, each of which the design settles explicitly: +/// +/// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it — "a +/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There +/// is no trash carve-out from 01-storage-format.md's consumer rule. +/// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board — +/// cards without their own flag; individually tombstoned descendants aren't in that number, since +/// they come back to the *trash*". +/// 3. **The deterministic sort.** Newest `deleted` first; ties by folder name ascending; an +/// unparseable stamp sorts as *oldest*, after every dated entry, folder-name-ordered among its +/// kind; lane entries interleave in the same single ordering by their own stamp. The order is +/// load-bearing for input — "arrow walks, ⇧-ranges, and the rubber band all read it" — so it is +/// total, not merely stable. +public enum TrashModel { + + // MARK: - Where a row lives + + /// An item's folder, as its identity components rather than as a URL. + /// + /// Same shape and same reasoning as `BoardStore.liveItem`'s return: the caller builds the URL off + /// the store's *current* `rootURL`, so a board renamed or moved mid-session writes at the new + /// location (02-architecture.md § Write-failure surfacing). + public struct ItemPath: Sendable, Equatable { + public let laneID: ItemID + /// `nil` for a lane — the path is then the lane folder itself. + public let cardID: ItemID? + + public init(laneID: ItemID, cardID: ItemID?) { + self.laneID = laneID + self.cardID = cardID + } + + public var isLane: Bool { cardID == nil } + + /// This path resolved under a board root. + public func folder(under root: URL) -> URL { + var url = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + if let cardID { + url.append(component: cardID.rawValue, directoryHint: .isDirectory) + } + return url + } + } + + // MARK: - Entries + + /// The trash's rows, in the order the quasi-lane shows them. + /// + /// The walk is one level deep because the tree is (01-storage-format.md § Fractal layout), and + /// the branch on `lane.isDeleted` *is* the ancestor walk: a tombstoned lane contributes exactly + /// one entry and its cards contribute none, whatever their own flags say. + public static func entries(of snapshot: BoardModel) -> [TrashEntry] { + var rows: [Row] = [] + for lane in snapshot.lanes { + if lane.isDeleted { + // Rule 2: only the cards *without* their own flag come back with the lane. The ones + // that carry a flag stay tombstoned and get their rows back in the trash — which is + // why Put Back on such a card is deliberately two steps. + let returning = lane.cards.filter { !$0.isDeleted }.count + rows.append(Row( + entry: .lane(lane, returningCardCount: returning), + deleted: lane.deleted.value, + name: lane.id.rawValue + )) + continue + } + for card in lane.cards where card.isDeleted { + rows.append(Row( + entry: .card(card, laneID: lane.id), + deleted: card.deleted.value, + name: card.id.rawValue + )) + } + } + return rows.sorted(by: isOrdered).map(\.entry) + } + + /// Whether the board has anything in its trash at all — the "non-empty" half of Empty Trash…'s + /// menu validation, which "reads the board's tombstones, not the filtered view" (03 ▸ Trash). + /// + /// Cheaper than building the entries and used where only the answer matters. + public static func isEmpty(_ snapshot: BoardModel) -> Bool { + !snapshot.lanes.contains { lane in + lane.isDeleted || lane.cards.contains(where: \.isDeleted) + } + } + + /// One entry's sort inputs, kept beside it so the comparator never re-reads the model. + private struct Row { + let entry: TrashEntry + /// The parsed `deleted` stamp, or `nil` when the value is present but unparseable — + /// 01-storage-format.md's unusable-timestamp rule, which still deletes (presence, not + /// validity) but supplies no position in time. + let deleted: Date? + /// The folder name, byte-for-byte — the tie-break the loader's display order already uses + /// (`Ranks.sortedForDisplay`, `name: { $0.id.rawValue }`), so the trash breaks ties the same + /// way the board does. + let name: String + } + + /// The sort, stated once: newest first among dated entries, then every undated entry. + /// + /// **Undated sorts oldest, not first.** "A corrupt stamp must not outrank fresh deletions for the + /// trash's most prominent rows" — so an unparseable value loses to every real timestamp, however + /// old, and orders by folder name among its own kind. + private static func isOrdered(_ lhs: Row, _ rhs: Row) -> Bool { + switch (lhs.deleted, rhs.deleted) { + case let (left?, right?): + return left == right ? lhs.name < rhs.name : left > right + case (.some, nil): + return true + case (nil, .some): + return false + case (nil, nil): + return lhs.name < rhs.name + } + } + + // MARK: - Paths for the trash's writes + + /// The folders `ids` names, in display order, restricted to one **effective** liveness side. + /// + /// The universe rule is `ItemReferenceSet.idUniverse`'s, spelled here in terms of paths rather + /// than ids because a write needs to know *where* the item is: a lane contributes itself on the + /// side its own flag names, and a card on the side `lane.isDeleted || card.isDeleted` names. + /// + /// Display order — lanes left to right, each lane then its cards — rather than the caller's set + /// iteration order, which is not an order at all: a batch that fails partway must fail the same + /// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). + public static func paths(of ids: Set, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] { + guard !ids.isEmpty else { return [] } + var result: [ItemPath] = [] + for lane in snapshot.lanes { + let laneSide = Liveness(isDeleted: lane.isDeleted) + if laneSide == side, ids.contains(lane.id) { + result.append(ItemPath(laneID: lane.id, cardID: nil)) + } + for card in lane.cards + where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side && ids.contains(card.id) { + result.append(ItemPath(laneID: lane.id, cardID: card.id)) + } + } + return result + } + + /// Every folder Empty Trash removes — "emptying purges every tombstone on the board, filter or + /// no filter" (03 ▸ Trash). + /// + /// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane + /// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would + /// be redundant purges of paths the first removal already took (harmless — `purgeItem` treats a + /// folder that is already gone as success — but noise). + public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] { + var result: [ItemPath] = [] + for lane in snapshot.lanes { + if lane.isDeleted { + result.append(ItemPath(laneID: lane.id, cardID: nil)) + continue + } + for card in lane.cards where card.isDeleted { + result.append(ItemPath(laneID: lane.id, cardID: card.id)) + } + } + return result + } + + // MARK: - Counts and phrasing + + /// How many lane entries and card entries a set of entries holds — the confirmation dialogs' + /// only input beyond the item titles. + public struct EntryCounts: Sendable, Equatable { + public var lanes: Int = 0 + public var cards: Int = 0 + + public var total: Int { lanes + cards } + public var isEmpty: Bool { total == 0 } + } + + public static func counts(of entries: [TrashEntry]) -> EntryCounts { + var counts = EntryCounts() + for entry in entries { + if entry.isLaneEntry { counts.lanes += 1 } else { counts.cards += 1 } + } + return counts + } + + /// The counts a set of `ItemPath`s describes — the same two numbers, from the shape the write + /// path actually carries. + public static func counts(of paths: [ItemPath]) -> EntryCounts { + var counts = EntryCounts() + for path in paths { + if path.isLane { counts.lanes += 1 } else { counts.cards += 1 } + } + return counts + } + + /// "2 lanes and 3 cards", "41 cards", "1 lane" — 06-history-undo.md's **plural folding** applied + /// to a mixed trash selection. + /// + /// An empty count reads "nothing", which no caller renders: both confirmations refuse to open on + /// an empty scope. It is spelled anyway so the function is total. + public static func phrase(_ counts: EntryCounts) -> String { + switch (counts.lanes, counts.cards) { + case (0, 0): "nothing" + case let (0, cards): plural(cards, "card") + case let (lanes, 0): plural(lanes, "lane") + case let (lanes, cards): "\(plural(lanes, "lane")) and \(plural(cards, "card"))" + } + } + + private static func plural(_ count: Int, _ noun: String) -> String { + "\(count) \(noun)\(count == 1 ? "" : "s")" + } + + // MARK: - Confirmations + + /// A purge confirmation's three strings, built once and rendered by the window's alert. + /// + /// A value rather than a view so the phrasing rules — plural folding, naming a sole item, the + /// lane caveat, and whether the loss is actually irreversible — are testable without an alert on + /// screen (`TrashModelTests`). + public struct PurgePrompt: Sendable, Equatable { + public let title: String + public let message: String + public let confirmTitle: String + } + + /// Delete Immediately's alert — "the alert stands between one keystroke and unrecoverable + /// deletion" (03-board-ui.md § Trash). + /// + /// A sole item is **named**; several fold into counts. `nil` when the ids name nothing + /// tombstoned, 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 snapshot: BoardModel, + unrecoverable: Bool + ) -> PurgePrompt? { + let targets = paths(of: ids, on: .trashed, in: snapshot) + guard !targets.isEmpty else { return nil } + + let counts = counts(of: targets) + let subject: String + if targets.count == 1, let only = targets.first { + subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}" + } else { + subject = phrase(counts) + } + return PurgePrompt( + title: "Permanently delete \(subject)?", + message: message(lanes: counts.lanes, unrecoverable: unrecoverable), + confirmTitle: "Delete" + ) + } + + /// Empty Trash…'s alert — **always shown** ("bulk scope, not per-item recoverability, is what it + /// guards"), and always naming the **true count**: every tombstone on the board, never the + /// filtered view. + /// + /// Counts rather than names even for a single entry, because the command is about the trash + /// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing + /// (06-history-undo.md's plural folding). + public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? { + let counts = counts(of: emptyTrashTargets(in: snapshot)) + guard !counts.isEmpty else { return nil } + return PurgePrompt( + title: "Permanently delete \(phrase(counts))?", + message: message(lanes: counts.lanes, unrecoverable: unrecoverable), + confirmTitle: "Delete" + ) + } + + /// The alert's body: what a lane takes with it, and whether any of it comes back. + /// + /// The lane sentence is not decoration — a lane entry's row says "3 cards" (what Put Back would + /// return), while purging the lane folder takes *every* card inside it, individually tombstoned + /// ones included. That gap is exactly what a confirmation is for. + private static func message(lanes: Int, unrecoverable: Bool) -> String { + var parts: [String] = [] + if lanes > 0 { + parts.append("Deleting a lane also deletes every card inside it.") + } + // 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 + // (`BoardStore.purgeIsUnrecoverable`). + parts.append(unrecoverable + ? "This can\u{2019}t be undone." + : "The board\u{2019}s history still has them.") + return parts.joined(separator: " ") + } + + /// What to call an item in a prompt — its title, or the "Untitled" rendering. + /// + /// Total by construction: a path whose item has gone since the prompt was asked for reads + /// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in + /// the app gives. + private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String { + guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return "Untitled" } + guard let cardID = path.cardID else { return lane.title.value ?? "Untitled" } + return lane.cards.first { $0.id == cardID }?.title.value ?? "Untitled" + } + + // MARK: - Menu validation + + /// Whether File ▸ Delete has something to tombstone — a **live**, non-empty selection that still + /// names something the board renders. + /// + /// The liveness side is the whole of the binary: "menu validation stays binary — Delete for live + /// selections, Put Back / Delete Immediately for tombstoned ones" (04 ▸ The trash). The + /// resolution against the snapshot is what keeps a selection the next reload will drop from + /// enabling an item that would write nothing. + public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { + selection.liveness == .live && !paths(of: selection.ids, on: .live, in: snapshot).isEmpty + } + + /// Whether File ▸ Put Back and File ▸ Delete Immediately have something to act on — the exact + /// mirror of `canDelete`, which is what makes the two ⌘⌫ twins enable exactly one of themselves. + public static func canActOnTrash(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { + selection.liveness == .trashed && !paths(of: selection.ids, on: .trashed, in: snapshot).isEmpty + } +} diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 8fad2bd..eb52066 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -23,14 +23,15 @@ import SwiftUI /// order. /// - **The keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward. /// +/// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as +/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash). +/// /// ### What is deliberately not here yet /// -/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real -/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later -/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The -/// **selection grammar** here is likewise minimal — a click replaces the selection, and that is all: -/// ⌘-click toggling, ⇧-click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are -/// m5's selection-model card. +/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the +/// shadow's hold rule) all belong to later milestone cards. The **selection grammar** here is +/// likewise minimal — a click replaces the selection, and that is all: ⌘-click toggling, ⇧-click +/// ranges, the rubber band and the cards-XOR-lanes homogeneity rule are m5's selection-model card. struct BoardView: View { let store: BoardStore @@ -40,6 +41,10 @@ struct BoardView: View { /// after the first body evaluation. let window: @MainActor () -> NSWindow? + /// The window's purge-alert host — see `TrashConfirmations` for why a menu item's confirmation + /// has to be presented from here. + let confirmations: TrashConfirmations + /// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from /// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef` /// needs the board's own window ref, which is the host's identity and not the board's. @@ -56,6 +61,14 @@ struct BoardView: View { /// One reorder at a time, per window — same lifetime, same reasoning. @State private var reorder = LaneReorderSession() + /// One drag out of the trash at a time, per window — same lifetime again. + @State private var trashDrag = TrashDragSession() + + /// The name of the strip's coordinate space, which is what a drop out of the trash is resolved + /// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin + /// included, and no global or lane-local space is that. + static let stripSpace = "board-strip" + /// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored /// deliberately whenever an inline editor closes: the field that had focus is gone, and Return /// must go back to meaning create/rename rather than nothing at all. @@ -77,7 +90,11 @@ struct BoardView: View { ? resize.standard : LaneLayoutMath.standardWidth( stripWidth: viewport.size.width, - totalUnits: LaneLayoutMath.totalUnits(of: lanes), + // The trash's one fixed unit joins the division **only while shown**, which is + // the whole of "Show/Hide Trash is a re-divide trigger" (03-board-ui.md § Trash): + // the window is never touched, the existing width simply divides across one more + // unit and every lane compresses — a lane add's behaviour, exactly. + totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0), gap: spacing) // The lanes in the order the strip should *show* them: their snapshot order at rest, and // the drag's would-be order while a reorder is in flight — which is how the siblings @@ -89,11 +106,29 @@ struct BoardView: View { ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in laneSlot(lane, at: position, among: shown, standard: standard) } + if isTrashVisible { + // Trailing, always — the quasi-lane has no position of its own to lose, which is + // also why it never appears in the reorder proposal's inputs (those are built + // from `liveLanes`). + TrashLaneView( + store: store, + confirmations: confirmations, + drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) }, + dragSession: trashDrag + ) + .frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing)) + .frame(maxHeight: .infinity, alignment: .top) + } } .padding(spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + // The space a drop out of the trash is resolved in — see `BoardView.stripSpace`. It goes + // on the padded container so x = 0 is the strip's leading edge with the outer margin + // included, which is the origin `LaneLayoutMath`'s arithmetic assumes. + .coordinateSpace(.named(Self.stripSpace)) } .background(boardBackground) + .trashPurgeAlert(store: store, confirmations: confirmations) // The board's own anchor for the Style… popover — the surface a board-targeted session hangs // off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor). .popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) { @@ -113,6 +148,7 @@ struct BoardView: View { } .onKeyPress(.return) { handleReturn() } .onKeyPress(.escape) { handleEscape() } + .onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) } } // MARK: - Styling @@ -186,6 +222,16 @@ struct BoardView: View { ) .frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading) } + // The drop highlight for a drag out of the trash: the lane the pointer is currently over. + // Feedback lives on the *target* rather than on a travelling replica, because the replica — + // its lift, its settle, the copy/move badge — is m5's drag card (03-board-ui.md § Motion). + .overlay { + if trashDrag.isTarget(lane.id) { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.accentColor, lineWidth: 2) + .allowsHitTesting(false) + } + } .frame(width: slotWidth, alignment: .topLeading) .offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0) .opacity(dragging ? 0.9 : 1) @@ -218,6 +264,32 @@ struct BoardView: View { store.snapshot.lanes.filter { !$0.isDeleted } } + // MARK: - Trash + + /// Whether the trash quasi-lane is on screen — transient, board-scoped, hidden on every open + /// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it + /// gets a name rather than being spelled twice. + private var isTrashVisible: Bool { + store.transient.isTrashVisible + } + + /// The live lane under `x` in strip coordinates, or `nil` — the strip's half of drag-to-restore. + /// + /// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is + /// 04-interactions.md ▸ Drag and drop's re-grounding rule: a foreign reload that adds or + /// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it + /// now is. A tombstoned lane is never a drop target because it is never in this list. + private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? { + let lanes = liveLanes + guard let index = LaneLayoutMath.laneIndex( + atX: x, + unitCounts: unitCounts(of: lanes), + standard: standard, + gap: spacing + ) else { return nil } + return lanes.indices.contains(index) ? lanes[index].id : nil + } + // MARK: - Reorder /// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in @@ -320,6 +392,32 @@ struct BoardView: View { return .handled } + /// **Plain ⌫ tombstones the live selection** — "a plain-key synonym of File ▸ Delete, kept + /// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The + /// map). + /// + /// Deliberately **live-only**: the nexus scopes this key to a live selection, and ⌫'s trash-side + /// role belongs to the ⌘⌫ twins, not to the bare key. A tombstoned selection is therefore inert + /// here — Put Back is a chord. + /// + /// 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 / Put Back), + // ⌥⌘⌫ (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 tombstoned 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 + } + guard !store.isEditingInline, !store.isReadOnly else { return .ignored } + let selection = store.selection + guard selection.liveness == .live, !selection.isEmpty else { return .ignored } + store.deleteSelection() + return .handled + } + /// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else /// clear the selection. /// diff --git a/Kanban/UI/Board/LaneLayoutMath.swift b/Kanban/UI/Board/LaneLayoutMath.swift index 320e5bb..b279b5b 100644 --- a/Kanban/UI/Board/LaneLayoutMath.swift +++ b/Kanban/UI/Board/LaneLayoutMath.swift @@ -68,10 +68,42 @@ enum LaneLayoutMath { /// /// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because /// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a - /// single trash entry) and so consumes none of the window's width. When the trash quasi-lane - /// arrives it joins this total as one fixed unit — "Show/Hide Trash is a re-divide trigger". - static func totalUnits(of lanes: [Lane]) -> Int { - max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) }) + /// single trash entry) and so consumes none of the window's width. + /// + /// **`trashUnits` is the quasi-lane's fixed one unit, and it is *only* consumed while shown** + /// (03-board-ui.md § Trash): the trash "spans a fixed one width unit — no `width` frontmatter, + /// and neither the stepper nor the edge drag applies — consumed only while shown". Passing it + /// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane + /// value anywhere that a reorder, a resize or a width write could reach. + /// + /// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is + /// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a + /// lane add does (§ Layout — full visibility). + static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int { + max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits)) + } + + // MARK: - Hit testing + + /// Which lane sits under `x` in strip coordinates (0 at the strip's leading edge, outer margin + /// included) — an index into `unitCounts`, or `nil` when `x` is not over a lane at all. + /// + /// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last + /// lane — which is where the trash quasi-lane sits. That is the whole of drag-to-restore's + /// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land + /// squarely on a live lane writes nothing rather than guessing at the nearest one. + /// + /// Same analytic geometry as `LaneReorderMath.proposedIndex` — resting positions computed from + /// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back + /// into logic"). + static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? { + var left = gap + for (index, units) in unitCounts.enumerated() { + let width = slotWidth(units: units, standard: standard, gap: gap) + if x >= left, x < left + width { return index } + left += width + gap + } + return nil } // MARK: - The drag's snap diff --git a/Kanban/UI/Board/TrashCommands.swift b/Kanban/UI/Board/TrashCommands.swift new file mode 100644 index 0000000..b7b4e50 --- /dev/null +++ b/Kanban/UI/Board/TrashCommands.swift @@ -0,0 +1,250 @@ +import Observation +import SwiftUI + +// MARK: - The window's confirmation host + +/// The board window's purge alert, as a piece of window-local state (03-board-ui.md § Trash). +/// +/// **It exists because a menu item cannot present anything.** Delete Immediately and Empty Trash… +/// live in the menu bar, the trash row's context menu carries a twin of the first, and all three must +/// raise *the same* alert on *the window in front* — so the request travels through the focus system +/// exactly as `BoardInfoPresentation` does, and the alert itself is hosted once by `BoardView`. +/// +/// `@State` in `BoardWindowHost`, therefore one per window and dying with it: a half-answered +/// confirmation is not something to carry across a window's life. +@MainActor +@Observable +final class TrashConfirmations { + + /// The alert waiting to be answered, or `nil` when none is. + /// + /// The **phrasing is captured when the request is made**, not recomputed at render time: the + /// user is being asked about the trash as it was when they invoked the command, and a foreign + /// reload landing mid-alert must not silently change the sentence they are reading. The *action* + /// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on an + /// item that has since gone — `BoardWriter.purgeItem` treats an absent folder as success. + private(set) var pending: Pending? + + struct Pending: Identifiable, Equatable { + let id = UUID() + let prompt: TrashModel.PurgePrompt + let action: Action + + /// What the confirmation is standing in front of. Two cases, because the two commands have + /// genuinely different scopes: one names a selection, the other names the whole trash and + /// re-derives its targets at the moment it runs. + enum Action: Equatable { + case purge(Set) + case emptyTrash + } + } + + /// Raises Delete Immediately's alert — **or purges outright** where the loss is not real. + /// + /// The mode check is the one thing that decides between the two, and it lives on the store as a + /// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one + /// expression rather than two call sites. + func requestPurge(of ids: Set, in store: BoardStore) { + guard store.purgeIsUnrecoverable else { + store.deleteImmediately(ids) + return + } + guard let prompt = TrashModel.purgePrompt( + for: ids, + in: store.snapshot, + unrecoverable: true + ) else { return } + pending = Pending(prompt: prompt, action: .purge(ids)) + } + + /// Raises Empty Trash…'s alert. **Always** — it guards bulk scope rather than per-item + /// recoverability, so no board skips it. + func requestEmptyTrash(in store: BoardStore) { + guard let prompt = TrashModel.emptyTrashPrompt( + in: store.snapshot, + unrecoverable: store.purgeIsUnrecoverable + ) else { return } + pending = Pending(prompt: prompt, action: .emptyTrash) + } + + /// Runs the pending action and dismisses. Idempotent: an alert answered twice (the button, then + /// the dismissal SwiftUI drives from the binding) acts once. + func confirm(in store: BoardStore) { + guard let pending else { return } + self.pending = nil + switch pending.action { + case let .purge(ids): store.deleteImmediately(ids) + case .emptyTrash: store.emptyTrash() + } + } + + func cancel() { + pending = nil + } +} + +/// The focused board window's confirmation host — beside `FocusedValues.boardStore` and +/// `.boardInfo`, and reached the same way by the same kinds of caller. +struct FocusedTrashConfirmationsKey: FocusedValueKey { + typealias Value = TrashConfirmations +} + +extension FocusedValues { + var trashConfirmations: TrashConfirmations? { + get { self[FocusedTrashConfirmationsKey.self] } + set { self[FocusedTrashConfirmationsKey.self] = newValue } + } +} + +// MARK: - File ▸ Delete / Put Back / Delete Immediately / Empty Trash… + +/// The File menu's trash rows (11-command-nexus.md). +/// +/// ### The ⌘⌫ chord twins +/// +/// Delete and Put Back are **two items sharing one key equivalent**, and validation enables exactly +/// one of them: "AppKit routes a shared key equivalent to the enabled item" (04-interactions.md ▸ +/// The map, which names Finder's own Move to Trash/Put Back pair as the precedent). The two +/// predicates are mirror images over the selection's liveness side +/// (`TrashModel.canDelete`/`canActOnTrash`), so they can neither both enable nor both disable while +/// something is selected — and a selection can never be mixed, because +/// `ItemReferenceSet.resolved(against:)` treats a liveness flip as a vanish. +/// +/// **Both titles stay stable** (titles-are-API): each remaps independently through the system +/// mechanism, and remapping one never moves the other's role. +struct TrashCommands: View { + + @FocusedValue(\.boardStore) private var store + @FocusedValue(\.trashConfirmations) private var confirmations + + var body: some View { + Button("Delete") { + store?.deleteSelection() + } + .keyboardShortcut(.delete, modifiers: .command) + .disabled(!canDelete) + + Button("Put Back") { + guard let store else { return } + store.putBack(store.selection.ids) + } + .keyboardShortcut(.delete, modifiers: .command) + .disabled(!canActOnTrash) + + Button("Delete Immediately") { + guard let store, let confirmations else { return } + confirmations.requestPurge(of: store.selection.ids, in: store) + } + .keyboardShortcut(.delete, modifiers: [.option, .command]) + .disabled(!canActOnTrash || confirmations == nil) + + Button("Empty Trash…") { + guard let store, let confirmations else { return } + confirmations.requestEmptyTrash(in: store) + } + .keyboardShortcut(.delete, modifiers: [.shift, .command]) + .disabled(!canEmptyTrash) + } + + /// A live, non-empty selection on a board that accepts writes. + private var canDelete: Bool { + guard let store, store.acceptsBoardMutations else { return false } + return TrashModel.canDelete(selection: store.selection, in: store.snapshot) + } + + /// A tombstoned, non-empty selection — Put Back's condition and Delete Immediately's alike, the + /// two being the trash side's pair (04-interactions.md ▸ The trash: "menu validation stays + /// binary"). + private var canActOnTrash: Bool { + guard let store, store.acceptsBoardMutations else { return false } + return TrashModel.canActOnTrash(selection: store.selection, in: store.snapshot) + } + + /// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) — where + /// "non-empty" reads the *board's* tombstones and never the filtered view (03-board-ui.md § + /// Trash: "a bulk command about the trash itself never silently narrows to the visible subset"). + /// + /// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is + /// invisible to every gesture". + private var canEmptyTrash: Bool { + guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false } + return !TrashModel.isEmpty(store.snapshot) + } +} + +// MARK: - View ▸ Show Trash + +/// View ▸ Show Trash — a checkmark toggle with **no default chord** (11-command-nexus.md). +/// +/// ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled, so the chord +/// is the system's, and a user who wants one here assigns it through the remapping mechanism. +/// +/// **One stable title with a checkmark state** — "Show Trash" stays "Show Trash" when checked, never +/// becomes "Hide Trash" (04-interactions.md ▸ Configurable bindings, since the title is the key a +/// custom binding is stored under). +/// +/// Neither the read-only lock nor the focused-editor rule closes it: showing the trash is a view +/// change, not a mutation, and a locked board is exactly when a user wants to look at what is in +/// there. +struct ShowTrashCommand: View { + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Toggle("Show Trash", isOn: isVisible) + .disabled(store == nil) + } + + /// The toggle's binding — and the one place hiding the trash has a consequence beyond layout. + /// + /// **Hiding drops a tombstoned selection.** The rows it pointed at are no longer on screen, and + /// "nothing invisible may stay selected" is the invariant every item-referencing set in this app + /// already obeys (`ItemReferenceSet`); leaving one behind would also leave Put Back and Delete + /// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out + /// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched — the + /// board it names is still right there. + private var isVisible: Binding { + Binding( + get: { store?.transient.isTrashVisible ?? false }, + set: { shown in + guard let store else { return } + store.transient.isTrashVisible = shown + if !shown, store.selection.liveness == .trashed { + store.clearSelection() + } + } + ) + } +} + +// MARK: - The alert + +extension View { + + /// Hosts the board window's purge alert — one surface for every path that asks for one. + /// + /// An **alert** rather than a confirmation dialog: this is a modal, destructive yes/no about + /// named items, which is what macOS's alert is for, and what Finder puts in front of the same + /// gesture. + func trashPurgeAlert(store: BoardStore, confirmations: TrashConfirmations) -> some View { + alert( + confirmations.pending?.prompt.title ?? "", + isPresented: Binding( + get: { confirmations.pending != nil }, + // Any dismissal that is not the confirm button is a cancel — Escape, a click + // outside, the sheet being torn down. + set: { presented in if !presented { confirmations.cancel() } } + ), + presenting: confirmations.pending + ) { pending in + Button(pending.prompt.confirmTitle, role: .destructive) { + confirmations.confirm(in: store) + } + Button("Cancel", role: .cancel) { + confirmations.cancel() + } + } message: { pending in + Text(pending.prompt.message) + } + } +} diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift new file mode 100644 index 0000000..18a081a --- /dev/null +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -0,0 +1,394 @@ +import AppKit +import Observation +import SwiftUI + +// MARK: - The strip's half of a trash row's drag + +/// What the strip lends the trash so a row can be dragged back onto the board (03-board-ui.md § +/// Trash ▸ Drag-to-restore). +/// +/// `LaneHeaderDrag`'s sibling, and for its reason: the gesture lives on the row, but the one thing it +/// needs — *which lane is under the cursor* — is the strip's geometry, and it must be read at gesture +/// time rather than at body-evaluation time. +@MainActor +struct TrashRowDrag { + /// The live lane under an x in strip coordinates, or `nil` when the point is not over one — a + /// gap, the outer margin, or the trash itself (`LaneLayoutMath.laneIndex`). + let laneUnder: (CGFloat) -> ItemID? +} + +// MARK: - TrashDragSession + +/// Window-local state for an in-flight drag out of the trash — `LaneReorderSession`'s sibling, and +/// deliberately as small. +/// +/// It holds only what the pointer contributes: which card, and which lane is currently under it. +/// Everything else — the strip's geometry, the lanes themselves — is read fresh at render time, so a +/// foreign reload mid-drag cannot leave this holding a stale board (04-interactions.md ▸ Drag and +/// drop's re-grounding rule). +@MainActor +@Observable +final class TrashDragSession { + + /// The card being dragged out of the trash; `nil` when idle. + private(set) var cardID: ItemID? + + /// The live lane the pointer is over, or `nil` when it is over nothing droppable. Observed: the + /// lanes read it to draw the drop highlight, which is this milestone's whole visual feedback. + private(set) var targetLaneID: ItemID? + + /// How far the pointer must travel before a click on a row becomes a drag — the same threshold + /// the lane header uses, so the two gestures feel alike. + static let threshold: CGFloat = 4 + + var isActive: Bool { cardID != nil } + + func isDragging(_ id: ItemID) -> Bool { cardID == id } + + func isTarget(_ id: ItemID) -> Bool { targetLaneID == id } + + func begin(cardID: ItemID) { + self.cardID = cardID + targetLaneID = nil + } + + func update(targetLaneID: ItemID?) { + guard isActive else { return } + self.targetLaneID = targetLaneID + } + + /// Ends the drag, handing the caller nothing — the *commit* needs the current snapshot, which + /// the view has and this session deliberately does not. Idempotent, because a gesture can end + /// after the card it was carrying has already vanished. + func end() { + cardID = nil + targetLaneID = nil + } +} + +// MARK: - TrashLaneView + +/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in +/// (03-board-ui.md § Trash). +/// +/// ### A pure view, and a quasi-lane +/// +/// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay +/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more. +/// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery: +/// +/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and +/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit, +/// so there is no `Lane` value for any of those to act on); +/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent +/// from the reorder proposal's `unitCounts` by construction, since `BoardView` builds that from +/// the snapshot's live lanes; +/// - it has **no new-card button**: nothing is created in the trash. +/// +/// ### No editing in the trash +/// +/// "Tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out +/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row: the +/// trash is for restoring or purging, not working. +/// +/// ### What is still a later card's +/// +/// The **search filter** ("shown, it participates in the filter like any lane") and the full +/// **keyboard grammar** — arrow walks into and out of the column, ⇧-ranges that stop at both the +/// liveness and the kind boundary, the rubber band, ⌘C copy-out — are m5's. So is the drag's replica: +/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place. +struct TrashLaneView: View { + + let store: BoardStore + + /// The window's purge-alert host, threaded down rather than read from the focus system: a + /// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the + /// board window's, and the row's Delete Immediately must raise the *same* alert the menu bar's + /// does. + let confirmations: TrashConfirmations + + /// The strip's drop resolution — see `TrashRowDrag`. + let drag: TrashRowDrag + + /// The window's one drag-out session, owned by `BoardView` for the same lifetime the reorder + /// session has. + let dragSession: TrashDragSession + + /// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of + /// the lanes rather than as a different kind of object. + private let cornerRadius: CGFloat = 10 + + private let rowSpacing: CGFloat = 6 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + rows + } + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.quaternary.opacity(0.35)) + ) + } + + /// The rows the column shows. + /// + // m5-search: the shown trash "participates in the filter like any lane", so the search predicate + // narrows this collection exactly as it narrows `LaneView.renderedCards` — and the count badge + // follows for free, because it reads this same value. + private var entries: [TrashEntry] { + TrashModel.entries(of: store.snapshot) + } + + // MARK: - Header + + /// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge + /// (03-board-ui.md § Trash ▸ Rendering). + /// + /// The hatching is what makes the column read as *not a lane* at a glance — the design asks for + /// "visually distinct", and a lane's header is the surface this must not be mistaken for. It + /// carries no gesture at all: no selection (the quasi-lane "is never selectable as a lane"), no + /// reorder drag, no context menu. + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "trash") + .foregroundStyle(.secondary) + .imageScale(.medium) + Text("Trash") + .font(.headline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + countBadge + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background { + UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius) + .fill(.quaternary.opacity(0.5)) + .overlay { + DiagonalHatch() + .stroke(.quaternary, lineWidth: 1) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: cornerRadius, + topTrailingRadius: cornerRadius + )) + } + } + .accessibilityElement(children: .combine) + } + + /// The entry count — the same collection the body renders, so the badge cannot disagree with + /// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second + /// change here). + private var countBadge: some View { + Text("\(entries.count)") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(.quaternary)) + } + + // MARK: - Rows + + private var rows: some View { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: rowSpacing) { + ForEach(entries) { entry in + TrashEntryRow( + store: store, + entry: entry, + confirmations: confirmations, + drag: drag, + dragSession: dragSession + ) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(6) + } + } +} + +// MARK: - The hatch + +/// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for, +/// drawn rather than imaged so it takes whatever width the division gives the column. +/// +/// The lines start a full header-height to the left of the leading edge so the first stroke reaches +/// the top-left corner instead of beginning partway across. +private struct DiagonalHatch: Shape { + var spacing: CGFloat = 7 + + func path(in rect: CGRect) -> Path { + var path = Path() + guard spacing > 0, rect.height > 0 else { return path } + var x = rect.minX - rect.height + while x < rect.maxX { + path.move(to: CGPoint(x: x, y: rect.maxY)) + path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY)) + x += spacing + } + return path + } +} + +// MARK: - Rows + +/// One trash row: a compact, dimmed plate carrying the item's symbol and title — and, for a lane +/// entry, the count of cards Put Back would return with it. +/// +/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it +/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no +/// Style…, no attachment carousel. That is 03-board-ui.md's no-editing-in-the-trash rule expressed +/// as an absence rather than as a pile of `disabled` modifiers. +private struct TrashEntryRow: View { + + let store: BoardStore + let entry: TrashEntry + let confirmations: TrashConfirmations + let drag: TrashRowDrag + let dragSession: TrashDragSession + + private let cornerRadius: CGFloat = 6 + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback)) + .foregroundStyle(.secondary) + .imageScale(.small) + VStack(alignment: .leading, spacing: 2) { + Text(entry.title ?? "Untitled") + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + if case let .lane(_, returning) = entry { + // "N cards" — what Put Back brings back with the lane, not how many folders sit + // inside it (`TrashModel.entries`' returning-count rule). + Text("\(returning) card\(returning == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6))) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5) + ) + // The row being dragged out dims further, so the gesture reads even without a replica. + .opacity(dragSession.isDragging(entry.id) ? 0.45 : 1) + .contentShape(Rectangle()) + .gesture(rowGesture) + .contextMenu { menu } + } + + private var symbolFallback: String { + entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card + } + + // MARK: - Selection + + private var isSelected: Bool { + store.selection.liveness == .trashed && store.selection.ids.contains(entry.id) + } + + /// A click replaces the selection with this one row, on the **trashed** side. + /// + /// Replace-only is what enforces both invariants at once here: a selection that is always exactly + /// one row can never mix live with tombstoned, nor card entries with lane entries + /// (04-interactions.md ▸ The trash). The extension grammar — ⌘-click, ⇧-ranges that go inert at + /// both boundaries, the rubber band that stays on the side it started on — is **m5's + /// selection-model card**, and nothing here should pre-empt it. + /// + /// **A double click is two of these and nothing more**: no editor, no card window, no timer. + private func select() { + store.select([entry.id], liveness: .trashed) + } + + // MARK: - Drag out + + /// One gesture recognising the same click-versus-drag split the lane header uses: a plain click + /// selects, and only movement past the threshold begins a drag out of the trash. + /// + /// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable — + /// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is + /// simply absent for them and the release still selects. + /// + /// The coordinate space is the strip's, because what the release needs is a *position* over the + /// board, not a translation. + private var rowGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .named(BoardView.stripSpace)) + .onChanged { value in + guard isDraggable, !store.isReadOnly, !store.isEditingInline else { return } + if !dragSession.isDragging(entry.id) { + let travelled = max(abs(value.translation.width), abs(value.translation.height)) + guard travelled > TrashDragSession.threshold else { return } + dragSession.begin(cardID: entry.id) + } + dragSession.update(targetLaneID: drag.laneUnder(value.location.x)) + } + .onEnded { value in + guard dragSession.isDragging(entry.id) else { + select() + return + } + dragSession.end() + // A drop over anything but a live lane — the trash itself, a gap, the outer margin — + // writes nothing. There is no replica to snap back; the row never left. + guard let lane = drag.laneUnder(value.location.x) else { return } + store.restoreByDrag(cardID: entry.id, intoLane: lane) + } + } + + private var isDraggable: Bool { !entry.isLaneEntry } + + // MARK: - The trash entry's context menu + + /// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a + /// trash entry, and no others. + /// + /// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays + /// enabled on tombstoned selections", read-only lock included — inspecting a folder before a + /// purge is exactly the errand it exists for. + @ViewBuilder + private var menu: some View { + Button("Put Back") { + store.putBack(targetIDs) + } + .disabled(!store.acceptsBoardMutations) + + Button("Delete Immediately") { + confirmations.requestPurge(of: targetIDs, in: store) + } + .disabled(!store.acceptsBoardMutations) + + Divider() + + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting(targetFolders) + } + } + + /// What this row's menu acts on: the whole selection when this row is part of it, else this row + /// alone — standard macOS context-menu targeting, and the same rule the card face and the lane + /// header apply to Style…. Right-clicking something outside the selection acts on what was + /// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set. + private var targetIDs: Set { + guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else { + return [entry.id] + } + return store.selection.ids + } + + private var targetFolders: [URL] { + TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot) + .map { $0.folder(under: store.rootURL) } + } +} diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index a6b7ef7..534651d 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -375,6 +375,24 @@ struct BannerCenterPhrasingTests { != BannerCenter.headline(for: error(.style(title: "Fix login")))) } + @Test("The trash trio speaks the board's vocabulary, never the system Trash's") + func trashVerbsFollowTheNamingConstraint() { + // 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: two "Trash" + // concepts coexist, and "Finder's 'Move to Trash' phrasing is reserved for the system Trash; + // board deletion says 'Delete'". A banner is UI copy like any other. + #expect(BannerCenter.headline(for: error(.delete(title: "Fix login"), .io(message: "the disk is full"))) + == "Couldn't delete 'Fix login' — the disk is full") + #expect(BannerCenter.headline(for: error(.restore(title: "Fix login"), .io(message: "the disk is full"))) + == "Couldn't put 'Fix login' back — the disk is full") + #expect(BannerCenter.headline(for: error(.purge(title: "Fix login"), .io(message: "the disk is full"))) + == "Couldn't permanently delete 'Fix login' — the disk is full") + + for operation in [WriteOperation.delete(title: "Fix login"), .delete(title: nil)] { + #expect(!BannerCenter.headline(for: error(operation)).contains("Trash")) + #expect(!BannerCenter.headline(for: error(operation)).contains("trash")) + } + } + @Test("The cause tail comes from the error's reason and nowhere else") func causeTailCarriesTheDiagnosis() { #expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full"))) diff --git a/KanbanTests/LaneLayoutMathTests.swift b/KanbanTests/LaneLayoutMathTests.swift index 2411fd4..79b0323 100644 --- a/KanbanTests/LaneLayoutMathTests.swift +++ b/KanbanTests/LaneLayoutMathTests.swift @@ -173,6 +173,67 @@ struct LaneDisplayUnitsTests { #expect(LaneLayoutMath.displayUnits(of: wide) == 40) #expect(LaneLayoutMath.totalUnits(of: [wide]) == 40) } + + @Test("The trash's one fixed unit joins the total only while it is shown") + func trashUnitJoinsTheDivision() throws { + // 03-board-ui.md § Trash: the quasi-lane "spans a fixed one width unit … consumed only + // while shown", and Show/Hide Trash is therefore a re-divide trigger — the window is never + // touched, the same width simply divides across one more unit. + let loaded = try lanes(widths: ["2", nil, "3"]) + #expect(LaneLayoutMath.totalUnits(of: loaded) == 6) + #expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 7) + + // Shown on a zero-lane board it is the whole division, not a second unit alongside the + // empty board's floor of one. + #expect(LaneLayoutMath.totalUnits(of: [], trashUnits: 1) == 1) + } +} + +// MARK: - Hit testing + +/// `laneIndex(atX:…)` — the strip's half of drag-to-restore (03-board-ui.md § Trash). Same lane +/// geometry as the layout above: standard 100, gap 12, so with lanes of 1× and 2× units the slots +/// run [12, 112), [124, 336) and everything past 348 is the trash's side of the strip. +@Suite("LaneLayoutMath ▸ hit testing") +struct LaneHitTestingTests { + + private let units = [1, 2, 1] + private func hit(_ x: CGFloat) -> Int? { + LaneLayoutMath.laneIndex(atX: x, unitCounts: units, standard: 100, gap: 12) + } + + @Test("A point inside a lane's slot names that lane, width counted in units") + func insideALaneSlot() { + #expect(hit(12) == 0) + #expect(hit(111.9) == 0) + #expect(hit(124) == 1) + // The 2× lane swallows the interior gap it spans, so its slot runs 212pt, not 200. + #expect(hit(335.9) == 1) + #expect(hit(348) == 2) + #expect(hit(447.9) == 2) + } + + @Test("The margins, the gaps, and everything past the last lane name nothing") + func gapsAndMarginsAreNotLanes() { + // The outer margin, before the first lane. + #expect(hit(0) == nil) + #expect(hit(11.9) == nil) + // The inter-lane gaps. + #expect(hit(112) == nil) + #expect(hit(123.9) == nil) + #expect(hit(336) == nil) + // Past the last lane — which is exactly where the trash quasi-lane sits, so a row dropped + // back into the trash writes nothing. + #expect(hit(448) == nil) + #expect(hit(10_000) == nil) + // A negative x (the pointer dragged off the leading edge) is not a lane either. + #expect(hit(-5) == nil) + } + + @Test("An empty strip has no lane under any point") + func emptyStrip() { + #expect(LaneLayoutMath.laneIndex(atX: 50, unitCounts: [], standard: 100, gap: 12) == nil) + } } // MARK: - The snap diff --git a/KanbanTests/TrashModelTests.swift b/KanbanTests/TrashModelTests.swift new file mode 100644 index 0000000..9a46673 --- /dev/null +++ b/KanbanTests/TrashModelTests.swift @@ -0,0 +1,435 @@ +import Foundation +import Testing +@testable import Kanban + +/// `TrashModel` is the trash quasi-lane's whole heart, and it is a pure function — so this suite is +/// the executable form of 03-board-ui.md § Trash ▸ Contents: the deterministic sort, the absolute +/// ancestor walk, and what a lane entry's card count actually counts. +/// +/// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every +/// other model suite here does it: the interesting inputs are `FieldValue` shapes — a valid +/// stamp, an unparseable one, an absent key — and only the loader produces those the way production +/// does. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +// MARK: - Fixtures + +/// A few more literal identities than `Ident` offers: the sort tests need enough rows to prove an +/// *ordering* rather than a comparison, and folder name is the tie-break, so the names matter. +private enum More { + static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + static let laneB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" + static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff" +} + +/// An item with a `deleted:` key — `stamp` goes in verbatim, so a test can write an unparseable one. +private func tombstoned(order: String, title: String, deleted: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + deleted: \(deleted) + --- + \(title) body. + + """ +} + +private func live(order: String, title: String) -> String { + "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" +} + +private func load(_ fixture: WriterFixture) throws -> BoardModel { + try BoardLoader.load(boardRoot: fixture.root).model +} + +private func ids(_ entries: [TrashEntry]) -> [String] { + entries.map(\.id.rawValue) +} + +// MARK: - The sort + +@Suite("TrashModel ▸ sort") +struct TrashModelSortTests { + + @Test("Dated entries sort newest first, and lane entries interleave by their own stamp") + func newestFirstWithLanesInterleaved() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + tombstoned(order: "1024", title: "Oldest card", deleted: "2026-03-01T09:00:00Z")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", + tombstoned(order: "2048", title: "Newest card", deleted: "2026-03-03T09:00:00Z")) + // A tombstoned lane whose own stamp falls between the two cards': the sort is one ordering + // over both kinds, not cards-then-lanes. + try fixture.item(Ident.lane2, + tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z")) + + let entries = TrashModel.entries(of: try load(fixture)) + #expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1]) + #expect(entries[1].isLaneEntry) + } + + @Test("Ties on the second break by folder name, ascending") + func tiesBreakByFolderName() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + // One multi-card ⌫ stamps one second onto N cards. The three are written in an order that + // is neither their folder-name order nor their `order` order, so only the rule can produce + // the expectation below. + let stamp = "2026-03-03T09:00:00Z" + try fixture.item("\(Ident.lane1)/\(More.cardF)", tombstoned(order: "1024", title: "F", deleted: stamp)) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "2048", title: "5", deleted: stamp)) + try fixture.item("\(Ident.lane1)/\(More.cardD)", tombstoned(order: "3072", title: "D", deleted: stamp)) + + let entries = TrashModel.entries(of: try load(fixture)) + #expect(ids(entries) == [Ident.card1, More.cardD, More.cardF]) + } + + @Test("An unparseable stamp sorts oldest — after every dated entry, however old") + func unparseableSortsOldest() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + tombstoned(order: "1024", title: "Corrupt", deleted: "whenever")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", + tombstoned(order: "2048", title: "Ancient", deleted: "1999-01-01T00:00:00Z")) + + let model = try load(fixture) + // The premise: the key is present but has no date reading — still a tombstone (presence, + // not validity), and still with no position in time. + let corrupt = try #require(model.lanes.first?.cards.first { $0.id.rawValue == Ident.card1 }) + #expect(corrupt.isDeleted) + #expect(corrupt.deleted.value == nil) + + // A corrupt stamp must not outrank a fresh deletion for the trash's most prominent rows — + // and here it does not even outrank a 1999 one. + #expect(ids(TrashModel.entries(of: model)) == [Ident.card2, Ident.card1]) + } + + @Test("Undated entries order by folder name among themselves, lanes included") + func undatedOrderByFolderName() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(More.laneB, tombstoned(order: "3072", title: "B lane", deleted: "not-a-date")) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(More.cardE)", tombstoned(order: "1024", title: "E", deleted: "corrupt")) + try fixture.item(More.laneA, tombstoned(order: "2048", title: "A lane", deleted: "later")) + + // `a… < b… < e…`: one folder-name ordering across both kinds, exactly as the dated half has + // one date ordering across both kinds. + #expect(ids(TrashModel.entries(of: try load(fixture))) == [More.laneA, More.laneB, More.cardE]) + } +} + +// MARK: - The ancestor walk and the returning count + +@Suite("TrashModel ▸ contents") +struct TrashModelContentsTests { + + /// The board every test below reads: one live lane holding a tombstoned card and a live one, and + /// one tombstoned lane holding a live card, a card with its own flag, and another live card. + private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + tombstoned(order: "1024", title: "Trashed card", deleted: "2026-03-03T09:00:00Z")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", live(order: "2048", title: "Live card")) + try fixture.item(Ident.lane2, + tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z")) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", live(order: "1024", title: "Rides along")) + try fixture.item("\(Ident.lane2)/\(More.cardD)", + tombstoned(order: "2048", title: "Own flag", deleted: "2026-03-04T09:00:00Z")) + try fixture.item("\(Ident.lane2)/\(More.cardE)", live(order: "3072", title: "Rides along too")) + return fixture + } + + @Test("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute") + func ownFlagUnderTombstonedLaneHasNoRow() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + let entries = TrashModel.entries(of: try load(fixture)) + // `cardD`'s stamp is the newest on the board, so if it had a row at all it would be the + // first one. The lane's single entry subsumes it instead. + #expect(ids(entries) == [Ident.card1, Ident.lane2]) + #expect(!ids(entries).contains(More.cardD)) + } + + @Test("A lane entry counts what Put Back returns — cards without their own flag") + func returningCountExcludesOwnFlaggedCards() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + let entries = TrashModel.entries(of: try load(fixture)) + let lane = try #require(entries.first { $0.id.rawValue == Ident.lane2 }) + guard case let .lane(_, returning) = lane else { + Issue.record("expected a lane entry") + return + } + // Three cards sit in the folder; two come back with the lane. The third comes back to the + // *trash*, which is why it is not in this number. + #expect(returning == 2) + } + + @Test("A lane entry with nothing to return counts zero rather than being suppressed") + func emptyLaneEntryCountsZero() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Empty", deleted: "2026-03-03T09:00:00Z")) + + let entries = TrashModel.entries(of: try load(fixture)) + #expect(entries.count == 1) + guard case let .lane(_, returning) = entries[0] else { + Issue.record("expected a lane entry") + return + } + #expect(returning == 0) + } + + @Test("An empty trash is empty, and a board with any tombstone is not") + func emptiness() throws { + let clean = try WriterFixture() + defer { clean.tearDown() } + try clean.item("", Item.board) + try clean.item(Ident.lane1, live(order: "1024", title: "Todo")) + try clean.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Card")) + #expect(TrashModel.isEmpty(try load(clean))) + #expect(TrashModel.entries(of: try load(clean)).isEmpty) + + let dirty = try makeBoard() + defer { dirty.tearDown() } + #expect(!TrashModel.isEmpty(try load(dirty))) + } + + @Test("Paths resolve on the effective liveness side, in display order") + func pathsResolveByEffectiveLiveness() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + let everything: Set = [ + ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2), + ItemID(rawValue: Ident.lane2), ItemID(rawValue: Ident.card3), ItemID(rawValue: More.cardD), + ] + + // Live: the live lane and its live card. `card3` is live by its own flag but its lane is + // tombstoned, so the ancestor walk puts it on the other side. + let liveSide = TrashModel.paths(of: everything, on: .live, in: model) + #expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" } + == ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"]) + + // Trashed: the tombstoned card, the tombstoned lane, and everything the lane hides — in + // display order, lanes left to right and each lane before its cards. + let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model) + #expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" } + == ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-", + "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(More.cardD)"]) + + #expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty) + #expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty) + } + + @Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder") + func emptyTrashTargets() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + let targets = TrashModel.emptyTrashTargets(in: try load(fixture)) + // The own-flag card inside the tombstoned lane is not listed — removing the lane folder + // takes it, and a second purge of a path that is already gone would be noise. + #expect(targets.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" } + == ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"]) + #expect(TrashModel.counts(of: targets) == TrashModel.EntryCounts(lanes: 1, cards: 1)) + } + + @Test("A path resolves under whichever root it is given") + func pathsResolveUnderTheGivenRoot() { + let root = URL(fileURLWithPath: "/tmp/Board.kanban") + let card = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: ItemID(rawValue: Ident.card1)) + #expect(card.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)/\(Ident.card1)") + #expect(!card.isLane) + + let lane = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: nil) + #expect(lane.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)") + #expect(lane.isLane) + } +} + +// MARK: - Counts, phrasing, and the confirmations + +@Suite("TrashModel ▸ phrasing") +struct TrashModelPhrasingTests { + + @Test("Plural folding reads counts, singular and plural, cards and lanes and both") + func pluralFolding() { + #expect(TrashModel.phrase(.init(lanes: 0, cards: 41)) == "41 cards") + #expect(TrashModel.phrase(.init(lanes: 0, cards: 1)) == "1 card") + #expect(TrashModel.phrase(.init(lanes: 1, cards: 0)) == "1 lane") + #expect(TrashModel.phrase(.init(lanes: 3, cards: 0)) == "3 lanes") + #expect(TrashModel.phrase(.init(lanes: 2, cards: 3)) == "2 lanes and 3 cards") + #expect(TrashModel.phrase(.init(lanes: 1, cards: 1)) == "1 lane and 1 card") + #expect(TrashModel.phrase(.init()) == "nothing") + } + + @Test("Entry counts split lane entries from card entries") + func entryCounts() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", + tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z")) + try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Gone", deleted: "2026-03-03T09:00:00Z")) + + let counts = TrashModel.counts(of: TrashModel.entries(of: try load(fixture))) + #expect(counts == TrashModel.EntryCounts(lanes: 1, cards: 2)) + #expect(counts.total == 3) + #expect(!counts.isEmpty) + } + + @Test("Delete Immediately names a sole item and folds several into counts") + func purgePrompt() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + tombstoned(order: "1024", title: "Fix login", deleted: "2026-03-01T09:00:00Z")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", + tombstoned(order: "2048", title: "Ship it", deleted: "2026-03-02T09:00:00Z")) + try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-03T09:00:00Z")) + let model = try load(fixture) + + let sole = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: true)) + #expect(sole.title == "Permanently delete \u{201C}Fix login\u{201D}?") + #expect(sole.message == "This can\u{2019}t be undone.") + #expect(sole.confirmTitle == "Delete") + + let several = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], + in: model, unrecoverable: true)) + #expect(several.title == "Permanently delete 2 cards?") + + // A lane in the set earns the sentence that matters most: the row says how many cards come + // *back*, while a purge takes every card in the folder. + let withLane = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.lane2)], + in: model, unrecoverable: true)) + #expect(withLane.title == "Permanently delete 1 lane and 1 card?") + #expect(withLane.message.hasPrefix("Deleting a lane also deletes every card inside it.")) + + // m7-git: a board whose history keeps the content says so instead. + let recoverable = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: false)) + #expect(recoverable.message == "The board\u{2019}s history still has them.") + + // Nothing tombstoned in the set: no prompt, which is also the command's own refusal. + #expect(TrashModel.purgePrompt(for: [ItemID(rawValue: Ident.lane1)], in: model, unrecoverable: true) == nil) + #expect(TrashModel.purgePrompt(for: [], in: model, unrecoverable: true) == nil) + } + + @Test("An untitled item is named by its rendering, never by an empty string") + func untitledPrompt() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + "---\nschema: 1\norder: 1024\ndeleted: 2026-03-01T09:00:00Z\n---\nbody\n") + + let prompt = try #require(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.card1)], in: try load(fixture), unrecoverable: true)) + #expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?") + } + + @Test("Empty Trash names the whole trash's count and refuses on an empty one") + func emptyTrashPrompt() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + #expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", + tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", + tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z")) + let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true)) + // Counts even for a small trash: the command is about the trash, not about an item. + #expect(prompt.title == "Permanently delete 2 cards?") + } +} + +// MARK: - Menu validation + +@Suite("TrashModel ▸ validation") +struct TrashModelValidationTests { + + private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Live")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", + tombstoned(order: "2048", title: "Trashed", deleted: "2026-03-01T09:00:00Z")) + return fixture + } + + @Test("The ⌘⌫ twins enable exactly one of themselves, by the selection's liveness side") + func chordTwinsAreBinary() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + let liveSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .live) + #expect(TrashModel.canDelete(selection: liveSelection, in: model)) + #expect(!TrashModel.canActOnTrash(selection: liveSelection, in: model)) + + let trashedSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card2)], liveness: .trashed) + #expect(!TrashModel.canDelete(selection: trashedSelection, in: model)) + #expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model)) + } + + @Test("Neither enables on an empty selection, or on one whose members have gone") + func nothingToActOn() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + #expect(!TrashModel.canDelete(selection: .empty, in: model)) + #expect(!TrashModel.canActOnTrash(selection: ItemReferenceSet(ids: [], liveness: .trashed), in: model)) + + // A selection the next reload will drop: the id names nothing, so the item that would write + // nothing does not look available. + let stale = ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live) + #expect(!TrashModel.canDelete(selection: stale, in: model)) + } + + @Test("A selection whose side disagrees with the item's own flag enables neither") + func sideMustMatch() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + // The homogeneous-by-liveness invariant makes this unreachable through the UI, and the + // reload rule ejects it if a foreign edit ever produces it — but the predicates must not + // trust that, since they are what stands between a stale set and a write. + let wrongSide = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .trashed) + #expect(!TrashModel.canActOnTrash(selection: wrongSide, in: model)) + #expect(!TrashModel.canDelete(selection: wrongSide, in: model)) + } +} diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift new file mode 100644 index 0000000..3a3d43b --- /dev/null +++ b/KanbanTests/TrashWriteTests.swift @@ -0,0 +1,518 @@ +import Foundation +import Testing +@testable import Kanban + +/// `BoardStore`'s trash operations — Delete, Put Back, Delete Immediately, Empty Trash, and +/// drag-to-restore (03-board-ui.md § Trash). +/// +/// 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 +/// files — which key lands, which stamps follow it, which folders survive, and what comes through +/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +// MARK: - Fixtures + +private func tombstoned(order: String, title: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + project: lanework # agent overlay + created: 2026-01-01T09:00:00Z + deleted: 2026-03-03T09:00:00Z + --- + \(title) body. + + """ +} + +/// Two live lanes with two cards each, plus one already-tombstoned card and one already-tombstoned +/// lane holding a live card and an own-flagged one — enough for every rule in this file. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Trashed")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) + try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) + try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Rides along")) + try fixture.item("\(Ident.lane3)/\(Ident.indexless)", tombstoned(order: "2048", title: "Own flag")) + return fixture +} + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) +private let lane3 = ItemID(rawValue: Ident.lane3) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let card3 = ItemID(rawValue: Ident.card3) +private let card4 = ItemID(rawValue: Ident.card4) +private let ownFlag = ItemID(rawValue: Ident.indexless) + +/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come +/// through a tombstone or a Put Back byte-for-byte, in order. +private func untouchedLines(_ text: String) -> [Substring] { + text.split(separator: "\n", omittingEmptySubsequences: false).filter { + !$0.hasPrefix("modified") && !$0.hasPrefix("deleted:") + } +} + +/// A file's bytes and mtime — "minimal touch" stated the way `WriteFidelityTests` states it. +private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) { + let indexURL = fixture.url(relativePath).appendingPathComponent("index.md") + let data = try Data(contentsOf: indexURL) + let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path) + guard let modified = attributes[.modificationDate] as? Date else { + Issue.record("no modification date for \(relativePath)") + return (data, .distantPast) + } + return (data, modified) +} + +@MainActor +private func reload(_ store: BoardStore) async { + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() +} + +// MARK: - Delete + +@MainActor +@Suite("BoardStore ▸ delete") +struct TrashDeleteTests { + + @Test("Delete stamps deleted and modified, clears modified-by, and touches nothing else") + func tombstonesAndStamps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + + store.delete([card1]) + + let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + let document = try FrontmatterDocument.parse(after) + #expect(document.deleted.value != nil) + #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution") + // Everything the write does not own survives exactly, in order — the unknown key with its + // inline comment, the reserved `labels`, the original `created`, and the body. + #expect(untouchedLines(after) == untouchedLines(before)) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A multi-item delete is one bracket, and a lane's tombstone rewrites only the lane") + func batchAndLaneMinimalTouch() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let card = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)") + + store.delete([lane2, card1]) + + // Both landed. + #expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).deleted.value != nil) + #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) + .deleted.value != nil) + // Hiding the subtree is the renderer's ancestor walk, not a stored flag: the lane's card is + // untouched, bytes *and* mtime. + let cardAfter = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)") + #expect(cardAfter.data == card.data) + #expect(cardAfter.modified == card.modified) + } + + @Test("Delete clears the selection") + func clearsSelection() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.select([card1], liveness: .live) + + store.deleteSelection() + + // m5's successor-selection grammar replaces this; until then, what was selected renders + // nowhere and the selection says so. + #expect(store.selection.isEmpty) + } + + @Test("Already-tombstoned ids are skipped rather than re-stamped, and an empty set writes nothing") + func liveOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") + let underTombstonedLane = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)") + + // `card2` carries its own flag; `card4` is live by its own flag but its lane is tombstoned, + // so effective liveness puts it on the trashed side too. + store.delete([card2, card4]) + store.delete([]) + store.delete([ItemID(rawValue: "00000000-0000-4000-8000-000000000000")]) + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) + #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == underTombstonedLane.modified) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A read-only board refuses the delete without a second banner") + func readOnlyRefusesQuietly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + + store.delete([card1]) + + #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) + #expect(store.banners.oneShots.isEmpty, "the lock row is already standing") + #expect(store.bannerRows.contains { $0.id == "read-only-lock" }) + } + + @Test("A readable-but-uneditable item refuses the write, banners it, and keeps its bytes") + func uneditableBanners() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.uneditable) + let store = try BoardStore(rootURL: fixture.root) + + store.delete([card1]) + + #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == Item.uneditable) + let posted = try #require(store.banners.oneShots.first) + #expect(posted.error.operation == .delete(title: "Odd")) + #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't delete 'Odd' — ")) + } +} + +// MARK: - Put Back + +@MainActor +@Suite("BoardStore ▸ put back") +struct TrashPutBackTests { + + @Test("A delete→Put Back round trip differs from the original only in the modified timestamp") + func roundTripFidelity() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let path = "\(Ident.lane1)/\(Ident.card1)" + let original = try fixture.indexText(path) + + store.delete([card1]) + await reload(store) + store.putBack([card1]) + + let after = try fixture.indexText(path) + // Restore fidelity is perfect because nothing ever moved: no residue of the key that made + // this a tombstone, and every other line — the inline comment included — as written. + #expect(!after.contains("deleted")) + #expect(untouchedLines(after) == untouchedLines(original)) + #expect(try FrontmatterDocument.parse(after).order == .valid(1024)) + + // Position among siblings survives byte-for-byte, which is what "at its old order" means. + let cards = try #require(BoardLoader.load(boardRoot: fixture.root).model.lanes.first?.cards) + #expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2]) + #expect(cards[0].isDeleted == false) + } + + @Test("Putting back a lane splits its contents by flag — own-flag cards stay tombstoned") + func laneSplitsByFlag() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let ridesAlong = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)") + let ownFlagged = try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)") + + store.putBack([lane3]) + + // Only the lane's own file was rewritten. The card that rides along was never flagged, so + // it simply reappears; the own-flagged one keeps its key and its row moves back to the + // trash — recovering it is deliberately a second Put Back. + #expect(!(try fixture.indexText(Ident.lane3).contains("deleted"))) + #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == ridesAlong.modified) + #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)").modified == ownFlagged.modified) + + let model = try BoardLoader.load(boardRoot: fixture.root).model + let lane = try #require(model.lanes.first { $0.id == lane3 }) + #expect(!lane.isDeleted) + #expect(lane.cards.first { $0.id == card4 }?.isDeleted == false) + #expect(lane.cards.first { $0.id == ownFlag }?.isDeleted == true) + // And the own-flagged card now has a row of its own, which it did not while the lane was + // tombstoned. + #expect(TrashModel.entries(of: model).map(\.id).contains(ownFlag)) + } + + @Test("Put Back on a live item, an empty set, or an unknown id writes nothing") + func noOps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let live = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + + store.putBack([card1]) + store.putBack([]) + store.putBack([ItemID(rawValue: Ident.indexless.uppercased())]) + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == live.modified) + } +} + +// MARK: - Delete Immediately and Empty Trash + +@MainActor +@Suite("BoardStore ▸ purge") +struct TrashPurgeTests { + + @Test("Delete Immediately removes the folder and leaves everything else alone") + func purgeRemovesTheFolder() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let sibling = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + + store.deleteImmediately([card2]) + + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == sibling.modified) + #expect(store.selection.isEmpty) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("Purging a lane takes its whole folder, tombstoned cards and all") + func purgingALaneTakesItsSubtree() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.deleteImmediately([lane3]) + + #expect(!fixture.exists(Ident.lane3)) + #expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)")) + #expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)")) + #expect(fixture.exists(Ident.lane1), "the live lanes are untouched") + } + + @Test("Delete Immediately never reaches a live item") + func purgeIsTrashedOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.deleteImmediately([card1, lane1]) + + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) + #expect(fixture.exists(Ident.lane1)) + } + + @Test("Empty Trash purges every tombstone on the board, own-flag cards under a lane included") + func emptyTrashPurgesEverything() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.emptyTrash() + + // The tombstoned card under a live lane, the tombstoned lane, and — through the lane's own + // folder — the card that carried its own flag inside it. + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(!fixture.exists(Ident.lane3)) + #expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)")) + + // Everything live survives, and the board now has an empty trash. + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card3)")) + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(TrashModel.isEmpty(model)) + #expect(store.selection.isEmpty) + } + + @Test("Empty Trash on a board with no tombstones writes nothing") + func emptyTrashOnACleanBoard() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + let store = try BoardStore(rootURL: fixture.root) + let card = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + + store.emptyTrash() + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == card.modified) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("Every board is unrecoverable today, so Delete Immediately always confirms") + func purgeIsUnrecoverableEverywhere() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + // m7-git: the git milestone is what makes this answer `false` for some boards. + #expect(store.purgeIsUnrecoverable) + } +} + +// MARK: - Drag to restore + +@MainActor +@Suite("BoardStore ▸ drag to restore") +struct TrashDragRestoreTests { + + @Test("A drop back on the card's own lane removes the key only — the folder never moves") + func sameLaneIsAPutBack() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let original = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") + + store.restoreByDrag(cardID: card2, intoLane: lane1) + + let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(!after.contains("deleted")) + // The `order` is untouched, so the card returns where it was rather than at the bottom — + // the position-perfect restore a pure-view trash makes possible. + #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) + #expect(untouchedLines(after) == untouchedLines(original)) + } + + @Test("A drop on another lane restores and appends at that lane's bottom, in one bracket") + func crossLaneMovesAndAppends() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.restoreByDrag(cardID: card2, intoLane: lane2) + + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card2)")) + let after = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)") + #expect(!after.contains("deleted")) + // m5-drag: the positional drop replaces this append. Lane two's one visible card is at + // 1024, so the arrival lands at 2048 — the Writer's own append over visible siblings. + #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) + + let model = try BoardLoader.load(boardRoot: fixture.root).model + let lane = try #require(model.lanes.first { $0.id == lane2 }) + #expect(lane.cards.map(\.id.rawValue) == [Ident.card3, Ident.card2]) + #expect(lane.cards.allSatisfy { !$0.isDeleted }) + #expect(TrashModel.isEmpty(model) == false, "the tombstoned lane is still in the trash") + } + + @Test("A drop that names nothing droppable writes nothing") + func noOps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let trashedCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") + let liveCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + + // A tombstoned destination lane is never a drop target (04 ▸ Drag and drop: "a card is + // never filed under a `deleted:` parent"). + store.restoreByDrag(cardID: card2, intoLane: lane3) + // A lane that is not on the board at all. + store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless)) + // A card that is not a trash row: live, and — for `card4` — hidden by its lane rather than + // by its own flag, so it has no row to drag in the first place. + store.restoreByDrag(cardID: card1, intoLane: lane2) + store.restoreByDrag(cardID: card4, intoLane: lane1) + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashedCard.modified) + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == liveCard.modified) + #expect(fixture.exists("\(Ident.lane3)/\(Ident.card4)")) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A read-only board refuses the drop") + func readOnlyRefuses() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + + store.restoreByDrag(cardID: card2, intoLane: lane2) + + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:")) + } +} + +// MARK: - The confirmations + +@MainActor +@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() + + confirmations.requestPurge(of: [card2], in: store) + + let pending = try #require(confirmations.pending) + #expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?") + #expect(pending.action == .purge([card2])) + // Nothing has happened yet — the alert is what stands between the keystroke and the loss. + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + + confirmations.confirm(in: store) + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(confirmations.pending == nil) + // Idempotent: the binding's own dismissal fires an instant after the button. + confirmations.confirm(in: store) + } + + @Test("Cancelling dismisses and writes nothing") + func cancelWritesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let confirmations = TrashConfirmations() + + confirmations.requestPurge(of: [card2], in: store) + confirmations.cancel() + + #expect(confirmations.pending == nil) + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + } + + @Test("Empty Trash always confirms, and its scope is the whole trash") + func emptyTrashConfirms() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let confirmations = TrashConfirmations() + + confirmations.requestEmptyTrash(in: store) + + let pending = try #require(confirmations.pending) + #expect(pending.action == .emptyTrash) + #expect(pending.prompt.title == "Permanently delete 1 lane and 1 card?") + + confirmations.confirm(in: store) + #expect(TrashModel.isEmpty(try BoardLoader.load(boardRoot: fixture.root).model)) + } + + @Test("Neither command raises an alert with nothing to act on") + func nothingToConfirm() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + let store = try BoardStore(rootURL: fixture.root) + let confirmations = TrashConfirmations() + + confirmations.requestEmptyTrash(in: store) + #expect(confirmations.pending == nil) + + confirmations.requestPurge(of: [lane1], in: store) + #expect(confirmations.pending == nil) + } +} diff --git a/README.md b/README.md index bd8b1a0..bd35b2c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ 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** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live. + - **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it. ## Development