diff --git a/Kanban/App/AppCommands.swift b/Kanban/App/AppCommands.swift index a3a1eaf..2828b76 100644 --- a/Kanban/App/AppCommands.swift +++ b/Kanban/App/AppCommands.swift @@ -347,7 +347,7 @@ struct RevealInFinderCommand: View { if let store { let ids = store.selection.ids guard !ids.isEmpty else { return [store.rootURL] } - return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot) + return ItemPath.resolve(ids, in: store.selection.container, snapshot: store.snapshot) .map { $0.folder(under: store.rootURL) } } if let attachments { diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 7ddcd9a..66c60c1 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -645,11 +645,9 @@ public final class AppModel { public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) { var lanes = 0 var cards = 0 - for lane in snapshot.lanes where !lane.isDeleted { + for lane in snapshot.lanes { lanes += 1 - for card in lane.cards where !card.isDeleted { - cards += 1 - } + cards += lane.cards.count } return (lanes, cards) } diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 9854853..c1f28c0 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -175,9 +175,7 @@ struct CardWindowHost: View { let identity = ItemID(rawValue: cardID) for lane in snapshot.lanes { guard let card = lane.cards.first(where: { $0.id == identity }) else { continue } - return lane.isDeleted || card.isDeleted - ? .dismisses - : .shows(CardPlacement(card: card, lane: lane)) + return .shows(CardPlacement(card: card, lane: lane)) } return .dismisses } @@ -319,7 +317,7 @@ struct CardWindowHost: View { /// links resolve against (05-card-window.md ▸ Preview). /// /// Built off the store's *current* `rootURL` rather than the ref's captured one, for - /// `BoardStore.liveItem`'s reason: a mid-session folder rename moves the board, and a preview + /// `BoardStore.boardItem`'s reason: a mid-session folder rename moves the board, and a preview /// resolving images against where the board used to be would quietly stop showing them. static func cardFolder(root: URL, placement: CardPlacement) -> URL { root diff --git a/Kanban/App/ClipboardManifest.swift b/Kanban/App/ClipboardManifest.swift index 3a18ca1..d08db97 100644 --- a/Kanban/App/ClipboardManifest.swift +++ b/Kanban/App/ClipboardManifest.swift @@ -27,7 +27,7 @@ extension UTType { /// snapshot is missing or unreadable — "the staging-less fallback: content intact, attachments /// absent", announced by a banner rather than discovered later. /// -/// `kind` and `side` are the selection's own vocabulary (`SelectionKind`, `Liveness`) rather than +/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than /// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and /// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw /// spellings are pasteboard API — a manifest written before a quit is decoded after the relaunch. @@ -53,7 +53,7 @@ public struct ClipboardManifest: Codable, Sendable, Equatable { public var boardRoot: String public var kind: SelectionKind - public var side: Liveness + public var container: ItemContainer public var entries: [Entry] /// One copied item: where its snapshot is staged, what it is called, and its bytes. @@ -132,14 +132,14 @@ public struct ClipboardManifest: Codable, Sendable, Equatable { copyID: String, boardRoot: URL, kind: SelectionKind, - side: Liveness, + container: ItemContainer, entries: [Entry] ) { self.version = version self.copyID = copyID self.boardRoot = boardRoot.path self.kind = kind - self.side = side + self.container = container self.entries = entries } diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index e8796f0..7e470dd 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -192,7 +192,7 @@ public final class ClipboardStore { copyID: copyID, boardRoot: store.rootURL, kind: capture.kind, - side: capture.side, + container: capture.container, entries: capture.subjects.map(\.entry) ) guard let data = manifest.encoded() else { return } @@ -209,7 +209,7 @@ public final class ClipboardStore { armedCut = ArmedCut(copyID: copyID, source: store) store.transient.pendingCut = ItemReferenceSet( ids: Set(capture.subjects.map(\.id)), - liveness: capture.side + container: capture.container ) } // "A sweep at launch and on each copy purges entries the pasteboard no longer references." @@ -219,8 +219,9 @@ public final class ClipboardStore { // MARK: - Availability - /// Whether Edit ▸ Copy applies — a non-empty selection that still names something the board - /// renders, on either side of the live/tombstoned boundary. + /// Whether Edit ▸ Copy applies — a non-empty selection that still names something, in either + /// container ("⌘C copies a trash card — a live copy lands wherever pasted, like copying out of + /// Finder's Trash" — 04-interactions.md ▸ The trash). /// /// **The read-only lock deliberately does not close it**: "reading, selecting, searching and /// copying out all stay live" (02-architecture.md § The lock's scope) — a copy is a read. The @@ -233,12 +234,15 @@ public final class ClipboardStore { return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil } - /// Whether Edit ▸ Cut applies. Copy's conditions, plus the two a *move* adds: the board must - /// accept writes (a cut mutates its source), and the selection must be **live** — "⌘X is - /// disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else" (04 ▸ The - /// trash). + /// Whether Edit ▸ Cut applies. Copy's conditions plus the one a *move* adds: the board must + /// accept writes, since a cut mutates its source. + /// + /// **The trash no longer disqualifies it** (04-interactions.md ▸ The trash, resettled + /// 2026-07-28): "⌘X works — it was disabled under the tombstone model: cut in the trash, paste + /// into a lane is the keyboard-native restore, an ordinary folder move". So there is no + /// container clause here at all, which is the pivot showing up as a deleted line. public func canCut(from store: BoardStore) -> Bool { - canCopy(from: store) && !store.isReadOnly && store.selection.liveness == .live + canCopy(from: store) && !store.isReadOnly } /// Whether Edit ▸ Paste applies to `store`. @@ -347,7 +351,6 @@ public final class ClipboardStore { operation: .move, toLane: target.laneID, at: target.index, - clearingTombstones: false, normalizingLooseFiles: true ) case let .lanes(index): @@ -355,7 +358,6 @@ public final class ClipboardStore { sources, operation: .move, at: index, - clearingTombstones: false, normalizingLooseFiles: true ) } @@ -389,9 +391,9 @@ public final class ClipboardStore { } } - // "⌘C strips `deleted:` at materialization" (04 ▸ The trash) — the trash's copy-out-only rule, - // and the one axis a paste varies that a within-board drop never does. - let clearingTombstones = manifest.side == .trashed + // A card copied out of the trash needs nothing done to it on arrival: it carries no + // `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled + // 2026-07-28). The tombstone era's strip-at-materialization axis is gone with it. switch plan { case let .cards(target): store.receiveCards( @@ -399,7 +401,6 @@ public final class ClipboardStore { operation: .copy, toLane: target.laneID, at: target.index, - clearingTombstones: clearingTombstones, normalizingLooseFiles: true ) case let .lanes(index): @@ -407,7 +408,6 @@ public final class ClipboardStore { sources, operation: .copy, at: index, - clearingTombstones: clearingTombstones, normalizingLooseFiles: true ) } @@ -427,10 +427,11 @@ public final class ClipboardStore { let survivors = source.transient.pendingCut guard !survivors.isEmpty else { return nil } - // `TrashModel.paths` walks lanes in board order and each lane's cards in card order, which is - // the flatten order the drop commits insert in — and the pending cut is homogeneous by kind, - // so only one of its two branches ever contributes. - let folders = TrashModel.paths(of: survivors.ids, on: .live, in: source.snapshot) + // `ItemPath.resolve` walks the container in display order, which is the flatten order the + // drop commits insert in — and the pending cut is homogeneous by container, so it is asked + // for exactly the side the cut was made on. A cut made in the trash therefore hands the + // paste the trash folders it must move out, which is the keyboard restore (04 ▸ The trash). + let folders = ItemPath.resolve(survivors.ids, in: survivors.container, snapshot: source.snapshot) .map { $0.folder(under: source.rootURL) } guard !folders.isEmpty else { return nil } return (source, folders) @@ -541,17 +542,17 @@ public final class ClipboardStore { /// produces. struct Subject { let id: ItemID - let path: TrashModel.ItemPath + let path: ItemPath let entry: ClipboardManifest.Entry } /// The selection, resolved into copy subjects in the order the clipboard records them — or `nil` - /// when it names nothing the board renders on its own side. + /// when it names nothing its container holds. /// - /// **The order is `SelectionGrammar.order`'s**, which is already the right answer for all four - /// (side, kind) pairs: flatten order for live cards, left-to-right for live lanes, and the trash's - /// own deterministic sort for either kind of entry. Deriving it here would be a fifth definition - /// of an order the app already states once. + /// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every + /// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the + /// trash's own `order` for trash cards. Deriving it here would be a second definition of an order + /// the app already states once. /// /// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line /// span, so `serialized()` on an untouched document returns the file's bytes exactly — which @@ -560,58 +561,68 @@ public final class ClipboardStore { static func capture( selection: ItemReferenceSet, snapshot: BoardModel - ) -> (kind: SelectionKind, side: Liveness, subjects: [Subject])? { + ) -> (kind: SelectionKind, container: ItemContainer, subjects: [Subject])? { guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil } - let side = selection.liveness - let ordered = SelectionGrammar.order(of: kind, on: side, in: snapshot) + let container = selection.container + let ordered = SelectionGrammar.order(of: kind, in: container, snapshot: snapshot) .filter { selection.ids.contains($0) } guard !ordered.isEmpty else { return nil } var subjects: [ItemID: Subject] = [:] - for lane in snapshot.lanes { - if kind == .lane, Liveness(isDeleted: lane.isDeleted) == side { - subjects[lane.id] = Subject( - id: lane.id, - path: TrashModel.ItemPath(laneID: lane.id, cardID: nil), - entry: ClipboardManifest.Entry( - id: lane.id.rawValue, - folder: lane.id.rawValue, - title: lane.title.value, - index: lane.document.serialized(), - attachmentCount: 0, - // Live cards only — a lane copy strips tombstoned cards, and the fallback - // only ever materializes a copy (see `ClipboardManifest.Entry.cards`). - cards: lane.cards.filter { !$0.isDeleted }.map { card in - ClipboardManifest.Entry.Card( - id: card.id.rawValue, - title: card.title.value, - index: card.document.serialized(), - attachmentCount: card.attachments.count - ) - } - ) + func addCard(_ card: Card, at path: ItemPath) { + subjects[card.id] = Subject( + id: card.id, + path: path, + entry: ClipboardManifest.Entry( + id: card.id.rawValue, + folder: card.id.rawValue, + title: card.title.value, + index: card.document.serialized(), + attachmentCount: card.attachments.count ) + ) + } + + switch container { + case .trash: + for card in snapshot.trash { + addCard(card, at: .trashCard(card.id)) } - // A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live and - // have no trash row of their own, so they are nobody's copy subject. - guard kind == .card, !lane.isDeleted else { continue } - for card in lane.cards where Liveness(isDeleted: card.isDeleted) == side { - subjects[card.id] = Subject( - id: card.id, - path: TrashModel.ItemPath(laneID: lane.id, cardID: card.id), - entry: ClipboardManifest.Entry( - id: card.id.rawValue, - folder: card.id.rawValue, - title: card.title.value, - index: card.document.serialized(), - attachmentCount: card.attachments.count + case .board: + for lane in snapshot.lanes { + if kind == .lane { + subjects[lane.id] = Subject( + id: lane.id, + path: .lane(lane.id), + entry: ClipboardManifest.Entry( + id: lane.id.rawValue, + folder: lane.id.rawValue, + title: lane.title.value, + index: lane.document.serialized(), + attachmentCount: 0, + // Every card the lane has — "a lane carries exactly its cards", and the + // trash is board-level, so there is nothing nested to strip + // (04-interactions.md ▸ Drag and drop, resettled 2026-07-28). + cards: lane.cards.map { card in + ClipboardManifest.Entry.Card( + id: card.id.rawValue, + title: card.title.value, + index: card.document.serialized(), + attachmentCount: card.attachments.count + ) + } + ) ) - ) + continue + } + for card in lane.cards { + addCard(card, at: .card(lane: lane.id, id: card.id)) + } } } let resolved = ordered.compactMap { subjects[$0] } guard !resolved.isEmpty else { return nil } - return (kind, side, resolved) + return (kind, container, resolved) } } diff --git a/Kanban/History/BoardStoreHistory.swift b/Kanban/History/BoardStoreHistory.swift index 39db7d8..80c3589 100644 --- a/Kanban/History/BoardStoreHistory.swift +++ b/Kanban/History/BoardStoreHistory.swift @@ -110,7 +110,7 @@ extension BoardStore { // gone is not one to pop: the stack is about to be cleared with the session anyway. guard let store else { return .failed } - guard HistoryStaleness.isCurrent(expectations, under: store.rootURL) else { + guard HistoryStaleness.isCurrent(expectations) else { store.banners.postSkippedStep(direction, subject: subject) return .skipped } @@ -157,11 +157,11 @@ extension BoardStore { /// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte. /// - /// **Removal, not a tombstone**, exactly as 13 words it: an undone create leaves *no trace*, - /// because the item was born of the gesture being undone — a tombstone would leave a trash row - /// for a card the user never really made. `purgeIsUnrecoverable` is untouched by this: that flag - /// is about Delete Immediately, whose loss is the user's own final gesture, while this loss is - /// one ⇧⌘Z away. + /// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no + /// trace*, because the item was born of the gesture being undone — filing it in the trash would + /// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched + /// by this: that flag is about Delete Immediately, whose loss is the user's own final gesture, + /// while this loss is one ⇧⌘Z away. /// /// The honest edge, recorded rather than papered over: anything that happened *inside* the /// created folder through an operation that registers no step of its own — an attachment added by @@ -169,19 +169,19 @@ extension BoardStore { /// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather /// than from anything decided here. /// - /// **Its staleness predicate is existence and liveness, and deliberately nothing else** (13: - /// "existence/liveness for create/delete/restore steps"). A create's after-value *is* the item's - /// being there, so the undo validates that the folders are still there and still live and the - /// redo that they are still gone. The same honest edge follows: a foreign *edit* inside a created - /// card does not stop ⌘Z from removing it, because the create never wrote that field — while a - /// foreign *delete* does, since the trash row the user is looking at is not this step's to purge. + /// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence … + /// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the + /// undo validates that the folders are still at their paths and the redo that they are still + /// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop ⌘Z + /// from removing it, because the create never wrote that field — while a foreign *delete* does, + /// since the card the user is looking at in the trash is at a different path now. func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) { guard !items.isEmpty else { return } let operation: WriteOperation = kind == .lane ? .createLane : .createCard registerStep( HistoryPhrase.name(.add, kind: kind, count: items.count), subject: subject, - undoExpects: items.map { .live($0.folder) }, + undoExpects: items.map { .present($0.folder) }, redoExpects: items.map { .absent($0.folder) } ) { _ in // Reversed, so a lane and a card created by one gesture unwind child-first — the same @@ -282,14 +282,4 @@ extension BoardStore { } } - /// Puts an item's tombstone back — **with the timestamp it carried**, not with `now`. - /// - /// The inverse of Put Back is the item returning to the trash exactly where it was, and the trash - /// sorts by `deleted` (03-board-ui.md § Trash ▸ Contents): re-stamping would file the row under - /// today and quietly reorder a list the user was reading. A prior that was malformed (or, by - /// construction impossibly, missing) falls back to `now` — the item has to be tombstoned, and an - /// unreadable timestamp is not a value to preserve. - static func restoreTombstone(_ prior: FieldValue, in document: inout FrontmatterDocument) { - document.set(FrontmatterKeys.deleted, to: .date(prior.value ?? Date())) - } } diff --git a/Kanban/History/HistoryStaleness.swift b/Kanban/History/HistoryStaleness.swift index 226251a..9e6227d 100644 --- a/Kanban/History/HistoryStaleness.swift +++ b/Kanban/History/HistoryStaleness.swift @@ -43,40 +43,42 @@ public enum ExpectedField: Sendable, Equatable { /// What one folder must currently hold for a step to be safe to cross — the state that step's write /// left it in. /// -/// Two halves, both of them 13's: **existence and liveness** ("target folder gone ... → the step is -/// skipped"; "existence/liveness for create/delete/restore steps"), and the **field-level** -/// comparison above. A step carries one of these per item it touched, so a multi-card move validates -/// three targets and a single rename validates one — which is the whole of "a foreign change to an -/// unrelated item must not skip anything": an item no step named is an item no expectation mentions. +/// Two halves, both of them 13's: **existence** ("target folder gone ... → the step is skipped"), +/// and the **field-level** comparison above. A step carries one of these per item it touched, so a +/// multi-card move validates three targets and a single rename validates one — which is the whole of +/// "a foreign change to an unrelated item must not skip anything": an item no step named is an item +/// no expectation mentions. /// -/// **The folder's *path* is the parent check.** A move's step expects the card at its destination -/// path; a card that a foreign writer moved elsewhere leaves nothing at that path, so the ordinary -/// existence half already answers "moved away" without a parent field of its own. +/// ### The container side rides in the folder path +/// +/// **The folder's *path* is the parent check**, and since the trash was materialized that check is +/// also the container check (03-board-ui.md § Trash, resettled 2026-07-28). A delete step's undo +/// expects its card at `/.trash/`; a foreign restore moves the folder out, so nothing is +/// at that path and the existence half already answers "the card is not in the trash any more". +/// The mirror holds: the redo expects it back at `//`, where a foreign re-delete +/// leaves nothing. That is why `Presence` is a two-case answer rather than the tombstone era's +/// three-way live/tombstoned/absent reading of a `deleted:` key — there is no key to read, and no +/// ancestor to walk to find one. public struct HistoryExpectation: Sendable, Equatable { /// Where the item this step wrote to should be — the destination for a move, the item's own /// folder for everything else, and the board root for the board's own rename and styling. public let folder: URL - /// Whether the item should be there, and if so on which side of the tombstone. + /// Whether the item should be there. public let presence: Presence /// The fields the step's write set, with the values it set them to. Empty for a step whose - /// whole subject *is* existence — a create, a delete, a Put Back. + /// whole subject *is* existence — a create, a lane delete. public let fields: [ExpectedField] - /// Where an item stands, as the trash's own three-way reading of it. + /// Whether anything is at this path. public enum Presence: Sendable, Equatable { - /// There, and rendered: no `deleted:` on the item **or on any ancestor**. Liveness is - /// effective, the same ancestor walk `BoardStore.liveItem` and the card windows' fate rule - /// apply — a card under a tombstoned lane renders nowhere, so it is as gone as a deleted one. - case live + /// There, with a readable `index.md`. Which container that is, is the path's own answer. + case present - /// There, and tombstoned — a trash row, or a card hidden under a tombstoned lane. - case tombstoned - - /// Not there at all: the folder is gone. What an undone create leaves, and what a redone one - /// expects to find before putting it back. + /// Not there at all: the folder is gone. What an undone create and an undone lane delete + /// leave, and what a redone one expects to find before putting it back. case absent } @@ -86,37 +88,21 @@ public struct HistoryExpectation: Sendable, Equatable { self.fields = fields } - /// The item is live and its fields say what the step set them to. - public static func live(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation { - HistoryExpectation(folder: folder, presence: .live, fields: fields) + /// The item is at this path and its fields say what the step set them to. + public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation { + HistoryExpectation(folder: folder, presence: .present, fields: fields) } /// The same, for a caller whose field list is computed — the styling gesture's, which varies per - /// dimension. A label rather than a second variadic, so `.live(folder)` stays unambiguous. - public static func live(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation { - HistoryExpectation(folder: folder, presence: .live, fields: fields) - } - - /// The item is tombstoned and its fields say what the step set them to. - public static func tombstoned(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation { - HistoryExpectation(folder: folder, presence: .tombstoned, fields: fields) + /// dimension. A label rather than a second variadic, so `.present(folder)` stays unambiguous. + public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation { + HistoryExpectation(folder: folder, presence: .present, fields: fields) } /// Nothing is at this path. public static func absent(_ folder: URL) -> HistoryExpectation { HistoryExpectation(folder: folder, presence: .absent, fields: []) } - - /// The variant for a step whose target's liveness is not known until the gesture runs — the Edit - /// session's, which is registered against a card that may have been tombstoned out from under - /// the buffer (05-card-window.md ▸ Deletion & lifecycle). - public static func item( - _ folder: URL, - tombstoned: Bool, - _ fields: ExpectedField... - ) -> HistoryExpectation { - HistoryExpectation(folder: folder, presence: tombstoned ? .tombstoned : .live, fields: fields) - } } // MARK: - HistoryStaleness @@ -140,29 +126,29 @@ public struct HistoryExpectation: Sendable, Equatable { /// (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning ... /// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line /// before the inverse would have been written. +/// +/// ### It needs no board root +/// +/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and +/// needed the root to know where to stop. Materializing the trash removed the walk: an item's +/// container is its path, and a path is checked by asking the filesystem whether anything is there. public enum HistoryStaleness { /// Whether every target a step named still holds what that step left there. - /// - /// `root` is the board's current root, which the liveness walk stops at — a lane's parent. - public static func isCurrent(_ expectations: [HistoryExpectation], under root: URL) -> Bool { - expectations.allSatisfy { isCurrent($0, under: root) } + public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool { + expectations.allSatisfy(isCurrent) } /// One target's answer. /// - /// A file that cannot be read or parsed fails every expectation but `.absent`: an `index.md` - /// somebody has just broken is not one holding this step's after-value, and the honest reading of - /// "the field no longer holds it" covers a field that can no longer be read at all. - public static func isCurrent(_ expectation: HistoryExpectation, under root: URL) -> Bool { + /// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody + /// has just broken is not one holding this step's after-value, and the honest reading of "the + /// field no longer holds it" covers a field that can no longer be read at all. + public static func isCurrent(_ expectation: HistoryExpectation) -> Bool { guard expectation.presence != .absent else { return !FileManager.default.fileExists(atPath: expectation.folder.path) } guard let document = index(at: expectation.folder) else { return false } - - let tombstoned = isEffectivelyTombstoned(expectation.folder, document: document, under: root) - guard tombstoned == (expectation.presence == .tombstoned) else { return false } - return expectation.fields.allSatisfy { matches($0, in: document) } } @@ -192,40 +178,6 @@ public enum HistoryStaleness { } } - // MARK: Liveness - - /// Whether the item at `folder` renders — **presence of `deleted:`, not its validity** - /// (`Lane.isDeleted`'s rule), walked up through the ancestors the way every other liveness - /// question in this app is. - /// - /// The board root is never tombstoned however its own frontmatter reads: a board-level `deleted:` - /// is a tolerated load *warning* (01-storage-format.md § Deletion), not a state that hides the - /// board from itself. - private static func isEffectivelyTombstoned( - _ folder: URL, - document: FrontmatterDocument, - under root: URL - ) -> Bool { - guard !isRoot(folder, root) else { return false } - guard document.deleted.isMissing else { return true } - - // Lane and card are the only levels below the root, so this walks at most twice; the bound - // is there so a folder that is not under this root at all (a step registered before a - // mid-session root change) ends rather than climbing to `/`. - var parent = folder.deletingLastPathComponent() - for _ in 0 ..< 4 { - guard !isRoot(parent, root) else { return false } - guard let ancestor = index(at: parent) else { return false } - if !ancestor.deleted.isMissing { return true } - parent = parent.deletingLastPathComponent() - } - return false - } - - private static func isRoot(_ folder: URL, _ root: URL) -> Bool { - folder.standardizedFileURL.path == root.standardizedFileURL.path - } - // MARK: Reading /// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index d5464a1..65325c5 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -416,6 +416,27 @@ public final class BannerCenter { postLoss(message) } + /// **The legacy tombstone migration** (01-storage-format.md § Deletion, resettled 2026-07-28: + /// "Legacy `deleted:` keys migrate on load-and-write, never destroy … a graceful warning-tone + /// notice"): a board written by an older version carried `deleted:` keys, the app moved the + /// cards those keys named into `.trash/` and returned the lanes live, and this is the row that + /// says so. + /// + /// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event: + /// the app moved the user's folders on its own initiative, on a board it opened rather than on a + /// gesture they made. That must be said out loud, must not evaporate unread, and must not rank + /// as an error, because no action failed. The one nuance worth naming: the *lane* half is a + /// resurrection rather than a removal — cards nobody asked to see again may reappear on the + /// board — which is exactly the kind of surprise this class exists to announce. + /// + /// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for + /// an untitled one — "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so + /// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing. + public func postMigratedTombstones(cards: [String?], lanes: [String?]) { + guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return } + postLoss(message) + } + /// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its /// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a /// mixed drag proposes for its files only, and the drop imports the files while a one-shot @@ -822,6 +843,59 @@ public final class BannerCenter { return "Moved '\(name)' into attachments — \(subject)" } + /// The legacy tombstone migration's line — **one folded sentence for both halves**, written in + /// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first, + /// the subject after an em dash, plurals folded, a sole item named. + /// + /// The two clauses are joined rather than posted as two rows, because it is **one migration**: + /// a board opened, its old deletion markers were resolved, and that is one thing that happened + /// to the user's files. Two rows would also mean two dismissals for one event, and would rank a + /// resurrection and a relocation against each other for no reason. + /// + /// The shapes, in the relocation's own idiom: + /// + /// - **One card**, no lanes: "Moved 'Fix login' to the trash — it carried an old deleted marker". + /// - **Several cards**: "Moved 3 cards to the trash — they carried old deleted markers". + /// - **One lane**, no cards: "Restored 'Doing' — it carried an old deleted marker". + /// - **Both**: "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers". + /// + /// **The tail names the cause once**, and it is the whole explanation the row owes: the user did + /// not delete anything just now, and without the clause the sentence would read as an action + /// they had somehow just taken. The singular/plural of the tail follows the *total*, so the + /// mixed case never has to spell a singular (two clauses carry at least two items). + /// + /// `nil` when nothing migrated — a migration that migrated nothing is not news. + public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? { + let total = cards.count + lanes.count + guard total > 0 else { return nil } + + var clauses: [String] = [] + if !cards.isEmpty { + let subject = cards.count == 1 + ? sole(cards[0]) + : "\(cards.count) cards" + clauses.append("Moved \(subject) to the trash") + } + if !lanes.isEmpty { + let subject = lanes.count == 1 + ? sole(lanes[0]) + : "\(lanes.count) lanes" + clauses.append(clauses.isEmpty ? "Restored \(subject)" : "restored \(subject)") + } + let tail = total == 1 + ? "it carried an old deleted marker" + : "they carried old deleted markers" + return "\(clauses.joined(separator: " and ")) — \(tail)" + } + + /// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation + /// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the + /// clause it sits in already says which level it is). + private nonisolated static func sole(_ title: String?) -> String { + guard let title else { return "an untitled item" } + return "'\(title)'" + } + /// The skipped-step line — 13-native-undo.md ▸ Rules' own example sentence, "Undo skipped — 'Fix /// login' changed outside Lanework", with ⇧⌘Z's mirror ("Redo skipped — …"). /// diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 5cc2516..24219be 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -247,6 +247,14 @@ public final class BoardStore { /// `relocateLooseCardFiles()`, immediately below the reload that produced it. public private(set) var looseCardFiles: [LooseCardFiles] + /// The legacy `deleted:` keys the load that produced `snapshot` found — the retired tombstone + /// model's migration input (01-storage-format.md § Deletion, resettled 2026-07-28), in the + /// loose-file channel's idiom and replaced with the snapshot exactly as it is. + /// + /// **Nothing renders it either.** Its one consumer is `migrateLegacyTombstones()`, immediately + /// below the reload that produced it. + public private(set) var legacyTombstones: [LegacyTombstone] + /// The standing read-side condition: the error from the last reload that failed, `nil` when the /// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path /// and what is wrong with it — which is the whole of what the banner needs to render @@ -429,6 +437,11 @@ public final class BoardStore { @ObservationIgnored private var attemptedRelocation: Set = [] + /// The tombstone set the last migration attempt was made against — `attemptedRelocation`'s twin, + /// documented at `migrateLegacyTombstones()`. + @ObservationIgnored + private var attemptedTombstoneMigration: Set = [] + /// Awaited off the main actor **after** a tree walk finishes and **before** its result is /// applied — the one seam this type keeps, `nil` in production. /// @@ -467,6 +480,7 @@ public final class BoardStore { self.snapshot = result.model self.loadWarnings = result.warnings self.looseCardFiles = result.looseCardFiles + self.legacyTombstones = result.legacyTombstones self.reloadFailure = nil self.readOnlyLock = nil self.transient = TransientBoardState() @@ -614,6 +628,7 @@ public final class BoardStore { // this one did not. reloadFailure = nil looseCardFiles = result.looseCardFiles + legacyTombstones = result.legacyTombstones clearLockIfDisproved(by: origin) // The registry write-through, for the same "not board structure" reason the lock // clearing sits out here: whether this board's row needs a new title, icon, or @@ -621,11 +636,19 @@ public final class BoardStore { // guard), not a decision this store makes by comparing against its own prior // snapshot. displayStateDelegate?() - // Last, and after `clearLockIfDisproved` deliberately: this is the seam the deferred - // relocation is armed on. A board that was locked read-only tolerated its loose files - // for exactly as long as the lock stood, and the reload that clears the lock is the - // reload that lets them move — see `relocateLooseCardFiles()`. + // Last, and after `clearLockIfDisproved` deliberately: this is the seam the two + // deferred app-initiated writes are armed on. A board that was locked read-only + // tolerated its loose files and its legacy tombstones for exactly as long as the lock + // stood, and the reload that clears the lock is the reload that lets them move — see + // `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. + // + // The migration goes second only because the relocation is the older rule; they touch + // disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and + // share one bracket-per-call posture, so neither can see the other's work half-done — + // each opens its own bracket and each is re-armed by the reload the other's write + // produces. relocateLooseCardFiles() + migrateLegacyTombstones() case let .failure(error): // `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload @@ -864,7 +887,7 @@ public final class BoardStore { /// refused because one member has nowhere to go, matching the style batch's silent-skip shape. public func stepLaneWidths(_ ids: Set, by delta: Int) { let changes: [(ItemID, Int)] = snapshot.lanes - .filter { ids.contains($0.id) && !$0.isDeleted } + .filter { ids.contains($0.id) } .map { ($0.id, max(1, LaneLayoutMath.displayUnits(of: $0) + delta)) } writeLaneWidths(changes) } @@ -909,8 +932,8 @@ public final class BoardStore { registerStep( HistoryPhrase.name(.resize, kind: .lane, count: writes.count), subject: writes.count == 1 ? writes[0].title : nil, - undoExpects: writes.map { .live($0.folder, .width($0.units == 1 ? nil : $0.units)) }, - redoExpects: writes.map { .live($0.folder, .width($0.prior.value)) } + undoExpects: writes.map { .present($0.folder, .width($0.units == 1 ? nil : $0.units)) }, + redoExpects: writes.map { .present($0.folder, .width($0.prior.value)) } ) { _ in for write in writes { try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in @@ -972,7 +995,7 @@ public final class BoardStore { case let .items(ids): var subjects: [StyleSubject] = [] - for lane in snapshot.lanes where !lane.isDeleted { + for lane in snapshot.lanes { let laneFolder = rootURL.appendingPathComponent(lane.id.rawValue) if ids.contains(lane.id) { subjects.append(StyleSubject( @@ -982,7 +1005,7 @@ public final class BoardStore { icon: lane.icon )) } - for card in lane.cards where !card.isDeleted && ids.contains(card.id) { + for card in lane.cards where ids.contains(card.id) { subjects.append(StyleSubject( id: card.id, folder: laneFolder.appendingPathComponent(card.id.rawValue), @@ -1008,7 +1031,7 @@ public final class BoardStore { return .board case let .items(ids): let namesACard = snapshot.lanes.contains { lane in - !lane.isDeleted && lane.cards.contains { !$0.isDeleted && ids.contains($0.id) } + lane.cards.contains { ids.contains($0.id) } } return namesACard ? .card : .lane } @@ -1087,16 +1110,16 @@ public final class BoardStore { // the field-level predicate read at its narrowest, and it is free — `effective(_:against:)` // has already narrowed each dimension to what this write actually changed. let subject = edits.count == 1 - ? edits[0].id.flatMap { Self.liveItem($0, in: snapshot)?.title } + ? edits[0].id.flatMap { Self.boardItem($0, in: snapshot)?.title } : nil registerStep( HistoryPhrase.name(.restyle, kind: kind, count: edits.count), subject: subject, undoExpects: edits.map { - .live($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon)) + .present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon)) }, redoExpects: edits.map { - .live($0.folder, fields: Self.restoredStyleFields( + .present($0.folder, fields: Self.restoredStyleFields( background: $0.background, priorBackground: $0.priorBackground, icon: $0.icon, @@ -1197,14 +1220,14 @@ public final class BoardStore { let title = placeholder.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty, - let lane = snapshot.lanes.first(where: { $0.id == placeholder.laneID && !$0.isDeleted }) + let lane = snapshot.lanes.first(where: { $0.id == placeholder.laneID }) else { transient.discardPlaceholder() return nil } let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue) - let visible = lane.cards.filter { !$0.isDeleted } + let visible = lane.cards // `nil` means "append", which is `createCard`'s own default — so the anchored case is the // only one that needs a rank at all. let position = Self.insertionIndex(after: placeholder.anchorCardID, among: visible) @@ -1282,7 +1305,7 @@ public final class BoardStore { /// - **A vanished target writes nothing, silently.** "A target that is tombstoned, deleted, or /// gone at commit time discards the editor and its keystrokes silently … nothing is ever /// written into a vanished folder, and no partial `index.md` can resurrect deleted data." - /// Liveness is effective — a card under a tombstoned lane is vanished too. + /// Entering the trash is a vanish from the board, and so is losing the lane you were in. /// - **An empty commit removes the `title` key** (03-board-ui.md § Card face; 04 ▸ Selection: /// "Committing an empty rename on an existing item removes its `title` key"), rather than /// writing `title: ""` — titles are optional, and the face shows the untitled placeholder. @@ -1296,7 +1319,7 @@ public final class BoardStore { guard let editor = transient.renameEditor else { return } transient.discardRename() - guard let target = Self.liveItem(editor.targetID, in: snapshot) else { return } + guard let target = Self.boardItem(editor.targetID, in: snapshot) else { return } let typed = editor.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) let newTitle: String? = typed.isEmpty ? nil : typed @@ -1326,8 +1349,8 @@ public final class BoardStore { registerStep( HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card), subject: newTitle ?? priorTitle, - undoExpects: [.live(folder, .title(newTitle))], - redoExpects: [.live(folder, .title(priorTitle))] + undoExpects: [.present(folder, .title(newTitle))], + redoExpects: [.present(folder, .title(priorTitle))] ) { _ in try Self.setTitle(priorTitle, at: folder) } redo: { _ in @@ -1348,23 +1371,27 @@ public final class BoardStore { } } - /// Where a live item lives and what it is currently called, or `nil` when the id names nothing - /// the board renders. + /// Where a **board** item lives and what it is currently called, or `nil` when the id names + /// nothing on the board. /// - /// **Effective liveness, ancestor-walked** — the same rule `CardWindowHost.cardWindowFate` - /// applies to a card window and `ItemReferenceSet` applies to the selection: a card under a - /// tombstoned lane renders nowhere, so it is as gone as a deleted one. The path is returned as - /// its two identity components rather than as a URL so the caller builds it off the store's - /// *current* `rootURL`, which a mid-session folder rename may have moved. - nonisolated static func liveItem( + /// **The board container, and only it.** A card that has been deleted is in `.trash/`, where it + /// does not open, cannot be renamed, takes no attachments and has no task boxes to tick + /// (03-board-ui.md § Trash's no-editing rule) — so every caller of this wants exactly the board + /// side, and a trash card answering `nil` is the vanished-target guard those gestures already + /// make. The tombstone era's ancestor walk is gone with the tombstones: presence in the lanes is + /// the whole question. + /// + /// The path is returned as its two identity components rather than as a URL so the caller builds + /// it off the store's *current* `rootURL`, which a mid-session folder rename may have moved. + nonisolated static func boardItem( _ id: ItemID, in snapshot: BoardModel ) -> (laneID: ItemID, cardID: ItemID?, title: String?)? { - for lane in snapshot.lanes where !lane.isDeleted { + for lane in snapshot.lanes { if lane.id == id { return (laneID: lane.id, cardID: nil, title: lane.title.value) } - if let card = lane.cards.first(where: { $0.id == id && !$0.isDeleted }) { + if let card = lane.cards.first(where: { $0.id == id }) { return (laneID: lane.id, cardID: card.id, title: card.title.value) } } @@ -1392,12 +1419,12 @@ public final class BoardStore { /// here would be extending 13's inventory rather than implementing it. /// /// **A checkbox in a card that has gone writes nothing** — the vanished-target guard every - /// gesture in this file makes, ancestor-walked through `liveItem`: the card window would be + /// gesture in this file makes, ancestor-walked through `boardItem`: the card window would be /// dismissing itself in the same breath, and the reload that removed the card is the authority. /// The read-only lock is `performWrite`'s refusal, which is also why the controls disable in /// place on the Preview side rather than failing here (02-architecture.md § the lock's scope). public func toggleTaskMarker(inCard cardID: ItemID, bodyOffset: Int, checked: Bool) { - guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return } + guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return } let folder = rootURL .appendingPathComponent(target.laneID.rawValue, isDirectory: true) .appendingPathComponent(card.rawValue, isDirectory: true) @@ -1425,17 +1452,15 @@ public final class BoardStore { /// and on failure it must keep holding it — the whole of "nothing is lost while the window stays /// open" (02-architecture.md § Write-failure surfacing). Hence an outcome, not a `Void`. /// - /// **Tombstones are writable here, deliberately.** The folder is resolved by - /// `cardBodyTarget(_:in:)` — a walk that does *not* skip tombstoned cards or lanes — because 05 - /// ▸ Deletion & lifecycle requires exactly that: "a dirty Edit buffer flushes into the - /// tombstoned card's folder before the window dismisses ... so the keystrokes survive Put Back". - /// The write is surgical (`BoardWriter.writeBody` replaces the body span and nothing else), so - /// the `deleted:` key it lands beside is left standing and the card is not resurrected. + /// **A trashed card is writable here, deliberately.** The folder is resolved by + /// `cardBodyTarget(_:in:)` — a walk that spans **both containers** — because 05-card-window.md ▸ + /// Deletion & lifecycle requires exactly that: "a dirty Edit buffer flushes into the card's + /// folder at its new `.trash/` location before the window dismisses — a surgical body write, so + /// the keystrokes survive a later restore". The write replaces the body span and nothing else, + /// so the card is not otherwise disturbed on its way into the trash. public func writeCardBody(inCard cardID: ItemID, body: String) -> CardBodyWriteOutcome { guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished } - let folder = rootURL - .appendingPathComponent(target.laneID.rawValue, isDirectory: true) - .appendingPathComponent(target.cardID.rawValue, isDirectory: true) + let folder = target.folder(under: rootURL) do { // The closure's signature is spelled out because it returns a value — the inference wart @@ -1477,33 +1502,24 @@ public final class BoardStore { /// the delimiter were never this write's to change. That is the one inverse in the app whose /// fidelity is byte-level rather than field-level. /// - /// Liveness is `writeCardBody`'s deliberately blind walk (`cardBodyTarget`), so a session that - /// ended because its card was tombstoned still registers — the keystrokes survived into the - /// tombstoned folder, and their undo has to be able to reach the same place. - /// - /// ### Its staleness predicate is the bytes, and the side of the trash the card was on + /// ### Its staleness predicate is the bytes, at the path the session wrote to /// /// "Body steps compare bytes" (13 ▸ Rules), so the expectation is the whole body span as this /// session left it — a foreign editor that changed one character of it skips the step rather than - /// throwing that character away. The liveness half is captured rather than assumed, for the same - /// reason this method resolves its folder blind: a session that ended *because* the card was - /// tombstoned belongs to a tombstoned card, and demanding a live one would make its own undo - /// stale the instant it was registered. + /// throwing that character away. The **container rides in the path** (`HistoryStaleness`): a + /// session that ended because its card was moved to the trash registers against the trash folder + /// it actually flushed into, and a later restore moves the card out from under the step, which + /// the ordinary existence check then reads as the collision it is. public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) { guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return } - let folder = rootURL - .appendingPathComponent(target.laneID.rawValue, isDirectory: true) - .appendingPathComponent(target.cardID.rawValue, isDirectory: true) - - let lane = snapshot.lanes.first { $0.id == target.laneID } - let card = lane?.cards.first { $0.id == target.cardID } - let tombstoned = lane?.isDeleted == true || card?.isDeleted == true + let folder = target.folder(under: rootURL) + let title = Self.cardTitle(at: target, in: snapshot) registerStep( HistoryPhrase.name(.edit, kind: .card), - subject: card?.title.value, - undoExpects: [.item(folder, tombstoned: tombstoned, .body(newBody))], - redoExpects: [.item(folder, tombstoned: tombstoned, .body(priorBody))] + subject: title, + undoExpects: [.present(folder, .body(newBody))], + redoExpects: [.present(folder, .body(priorBody))] ) { _ in _ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody) } redo: { _ in @@ -1511,24 +1527,31 @@ public final class BoardStore { } } - /// Which folder a card's body write lands in — **the one card walk that ignores liveness**. + /// Which folder a card's body write lands in — **the one card walk that spans both containers**. /// - /// Every other resolution in this file goes through `liveItem`, whose ancestor-walked liveness is - /// what keeps gestures off vanished targets. This one deliberately does not: the card window's - /// dismissal flush has to reach a card that was tombstoned *out from under the buffer* (05 ▸ - /// Deletion & lifecycle), and to `liveItem` that card is already gone. A card whose folder is - /// genuinely no longer in the tree — hard-deleted, or moved to another board — still resolves to - /// `nil`, which is the case 05 answers with "nowhere left to write". - nonisolated static func cardBodyTarget( - _ id: ItemID, - in snapshot: BoardModel - ) -> (laneID: ItemID, cardID: ItemID)? { + /// Every other resolution in this file goes through `boardItem`, whose board-side-only answer is + /// what keeps gestures off cards that have left the working set. This one deliberately does not: + /// the card window's dismissal flush has to reach a card that was moved into the trash *out from + /// under the buffer* (05 ▸ Deletion & lifecycle), and to `boardItem` that card is already gone. A + /// card whose folder is genuinely no longer in the tree — purged, or moved to another board — + /// still resolves to `nil`, which is the case 05 answers with "nowhere left to write". + nonisolated static func cardBodyTarget(_ id: ItemID, in snapshot: BoardModel) -> ItemPath? { for lane in snapshot.lanes { - if let card = lane.cards.first(where: { $0.id == id }) { - return (laneID: lane.id, cardID: card.id) - } + if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) } + } + return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil + } + + /// A card's title at a resolved path, in either container — the skip banner's quoted subject. + nonisolated static func cardTitle(at path: ItemPath, in snapshot: BoardModel) -> String? { + switch path { + case let .card(lane, id): + snapshot.lanes.first { $0.id == lane }?.cards.first { $0.id == id }?.title.value + case let .trashCard(id): + snapshot.trash.first { $0.id == id }?.title.value + case let .lane(id): + snapshot.lanes.first { $0.id == id }?.title.value } - return nil } // MARK: - Raw source @@ -1543,7 +1566,7 @@ public final class BoardStore { /// an agent write that landed a moment ago would be invisible to the one surface that promises to /// show what is actually there. /// - /// **Ancestor-walked liveness** (`liveItem`), unlike `writeCardBody`'s deliberately liveness-blind + /// **Ancestor-walked liveness** (`boardItem`), unlike `writeCardBody`'s deliberately liveness-blind /// walk: there is nothing to *rescue* here — a tombstoned card's window is dismissing itself, and /// opening its whole `index.md` in an editor whose Apply would undelete it is exactly what 05 ▸ /// Deletion & lifecycle forbids ("An open raw-source buffer discards instead: its Apply writes @@ -1553,7 +1576,7 @@ public final class BoardStore { /// not UTF-8, or is gone between the snapshot and the read — is the card window's alert to raise, /// where it can say "so source mode did not open" rather than joining a strip of write failures. public func readCardSource(inCard cardID: ItemID) -> RawSourceReadOutcome { - guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return .vanished } + guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return .vanished } let folder = rootURL .appendingPathComponent(target.laneID.rawValue, isDirectory: true) .appendingPathComponent(card.rawValue, isDirectory: true) @@ -1593,7 +1616,7 @@ public final class BoardStore { /// bytes the user typed themselves, with the raw buffer still on screen as its own record of what /// they were. public func applyCardSource(inCard cardID: ItemID, text: String) -> RawSourceApplyOutcome { - guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return .vanished } + guard let target = Self.boardItem(cardID, in: snapshot), let card = target.cardID else { return .vanished } let folder = rootURL .appendingPathComponent(target.laneID.rawValue, isDirectory: true) .appendingPathComponent(card.rawValue, isDirectory: true) @@ -1666,8 +1689,8 @@ public final class BoardStore { registerStep( HistoryPhrase.name(.rename, kind: .board), subject: newTitle ?? priorTitle, - undoExpects: [.live(folder, .title(newTitle))], - redoExpects: [.live(folder, .title(priorTitle))] + undoExpects: [.present(folder, .title(newTitle))], + redoExpects: [.present(folder, .title(priorTitle))] ) { _ in try Self.setTitle(priorTitle, at: folder) } redo: { _ in @@ -1690,7 +1713,7 @@ public final class BoardStore { /// its own slot, and a no-op must not stamp `modified` or mint a commit — the resize drag's /// rule, and for the same reason. public func moveLane(_ id: ItemID, toIndex index: Int) { - let lanes = snapshot.lanes.filter { !$0.isDeleted } + let lanes = snapshot.lanes guard let from = lanes.firstIndex(where: { $0.id == id }) else { return } var remaining = lanes @@ -1740,8 +1763,8 @@ public final class BoardStore { registerStep( HistoryPhrase.name(.reorder, kind: .lane), subject: lanes[from].title.value, - undoExpects: [.live(folder, .order(newOrder))], - redoExpects: [.live(folder, .order(restored))] + undoExpects: [.present(folder, .order(newOrder))], + redoExpects: [.present(folder, .order(restored))] ) { _ in try Self.setOrder(restored, at: folder) } redo: { _ in @@ -1772,7 +1795,7 @@ public final class BoardStore { /// case: if the strip would render exactly what it renders now, no rank is rewritten and no /// commit is minted. public func moveLanes(_ ids: Set, toIndex index: Int) { - let lanes = snapshot.lanes.filter { !$0.isDeleted } + let lanes = snapshot.lanes let members = lanes.filter { ids.contains($0.id) } guard !members.isEmpty else { return } @@ -1821,8 +1844,8 @@ public final class BoardStore { registerStep( HistoryPhrase.name(.reorder, kind: .lane, count: forward.count), subject: members.count == 1 ? members[0].title.value : nil, - undoExpects: forward.map { .live($0.folder, .order($0.order)) }, - redoExpects: inverse.map { .live($0.0, .order($0.1)) } + undoExpects: forward.map { .present($0.folder, .order($0.order)) }, + redoExpects: inverse.map { .present($0.0, .order($0.1)) } ) { _ in for (folder, order) in inverse { try Self.setOrder(order, at: folder) @@ -1861,35 +1884,62 @@ public final class BoardStore { // where everything already is (a drag that ends where it started must not stamp `modified` or // mint a commit — the resize drag's rule). - /// One member of a dragged card set, resolved against the snapshot: which lane holds it *now*. + /// One member of a dragged card set, resolved against the snapshot: **where it is now** — a lane, + /// or the board's trash. private struct DraggedCard { let id: ItemID - let laneID: ItemID + /// The card's current home, which is also where an undo puts it back. + let path: ItemPath let order: Double /// What it is called, for the skip banner a stale step would raise — read here because the /// snapshot this resolves against is the pre-write one, which is where a title still is. let title: String? - } - /// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane - /// `order` first, then card `order`" (`SelectionGrammar.liveCards`), which is what "drop inserts - /// contiguously in preserved relative order" means and the only order a `Set` cannot supply. - /// - /// Members that vanished or flipped liveness since the drag began are simply absent: drag - /// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial - /// vanishing drops the survivors" is the design's own wording. - private func draggedCards(_ ids: Set) -> [DraggedCard] { - var homes: [ItemID: (lane: ItemID, order: Double, title: String?)] = [:] - for lane in snapshot.lanes where !lane.isDeleted { - for card in lane.cards where !card.isDeleted { - homes[card.id] = (lane.id, card.order, card.title.value) + /// The parent folder an inverse move returns it to. + func parent(under root: URL) -> URL { + switch path { + case let .card(lane, _): ItemPath.lane(lane).folder(under: root) + case .trashCard: BoardWriter.trashFolder(inBoard: root) + case .lane: root } } - return SelectionGrammar.liveCards(in: snapshot) - .filter { ids.contains($0) } - .compactMap { id in - homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order, title: $0.title) } + } + + /// `ids` narrowed to cards the snapshot holds and sorted into **flatten order** — "lane `order` + /// first, then card `order`" (`SelectionGrammar.boardCards`), which is what "drop inserts + /// contiguously in preserved relative order" means and the only order a `Set` cannot supply. + /// + /// **Both containers, because a drag out of the trash is an ordinary move** (03-board-ui.md § + /// Trash, resettled 2026-07-28: "Restoring is an ordinary move out … there is no restore-specific + /// machinery and no Put Back"). A drag membership set is homogeneous by container, so exactly one + /// of the two branches below ever contributes; asking both is what lets `moveCards` and + /// `copyCards` serve the restore without a second code path able to disagree with them. + /// + /// Members that vanished since the drag began are simply absent: drag membership is a UUID set + /// that vanished items leave silently (02-architecture.md), and "partial vanishing drops the + /// survivors" is the design's own wording. + private func draggedCards(_ ids: Set) -> [DraggedCard] { + var members: [DraggedCard] = [] + for lane in snapshot.lanes { + for card in lane.cards where ids.contains(card.id) { + members.append(DraggedCard( + id: card.id, + path: .card(lane: lane.id, id: card.id), + order: card.order, + title: card.title.value + )) } + } + guard members.isEmpty else { return members } + for card in snapshot.trash where ids.contains(card.id) { + members.append(DraggedCard( + id: card.id, + path: .trashCard(card.id), + order: card.order, + title: card.title.value + )) + } + return members } /// The within-board card drop: `ids` land contiguously at logical position `index` among @@ -1906,40 +1956,42 @@ public final class BoardStore { /// The selection is deliberately untouched: every id survives the move, and the cards the user /// is dragging should stay the cards the user is dragging. public func moveCards(_ ids: Set, toLane laneID: ItemID, at index: Int) { - guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return } + guard let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return } let members = draggedCards(ids) guard !members.isEmpty else { return } - let rendered = destination.cards.filter { !$0.isDeleted } + let rendered = destination.cards let memberIDs = members.map(\.id) let remaining = rendered.filter { !ids.contains($0.id) } let target = min(max(0, index), remaining.count) // The no-op guard, stated as the arrangement rather than as a special case: if the lane // would render exactly what it renders now, nothing moved. A member sitting in another lane - // makes the two lists differ by construction, so this covers the cross-lane case too. + // — or in the trash — makes the two lists differ by construction, so this covers the + // cross-container case too. guard DropSlotMath.applied(rendered.map(\.id), moving: memberIDs, to: target) != rendered.map(\.id) else { return } let root = rootURL - let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + let laneFolder = ItemPath.lane(laneID).folder(under: root) // The pre-write home of every member, per 13's "move → move back (original lane, original - // `order`)". A renumber inside the bracket rewrites the destination lane's own cards, so a - // member that was already there has its captured rank refreshed — `moveLane`'s note. - var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (lane: $0.laneID, order: $0.order)) }) + // `order`)" — and, for a card coming out of the trash, back into `.trash/` at the rank it + // was filed under. A renumber inside the bracket rewrites the destination lane's own cards, + // so a member that was already there has its captured rank refreshed — `moveLane`'s note. + var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (path: $0.path, order: $0.order)) }) var arrivals: [(id: ItemID, order: Double)] = [] let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count) if ranks == nil { // Compact and place again. The renumber assigns in display order over the lane's - // *live* cards, so the compacted ladder lines up one-for-one with `rendered`; the - // members already in this lane are dropped from it before the neighbours are - // consulted, exactly as `moveLane` drops the dragged lane's own rung. + // cards, so the compacted ladder lines up one-for-one with `rendered`; the members + // already in this lane are dropped from it before the neighbours are consulted, + // exactly as `moveLane` drops the dragged lane's own rung. try BoardWriter.renumberVisibleChildren(of: laneFolder) let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count))) for (card, rank) in renumbered where ids.contains(card.id) { - origins[card.id] = (lane: laneID, order: rank) + origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank) } let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1) ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count) @@ -1947,12 +1999,9 @@ public final class BoardStore { guard let ranks else { return } for (member, rank) in zip(members, ranks) { - let folder = root - .appendingPathComponent(member.laneID.rawValue, isDirectory: true) - .appendingPathComponent(member.id.rawValue, isDirectory: true) arrivals.append((id: member.id, order: rank)) _ = try BoardWriter.moveItem( - at: folder, + at: member.path.folder(under: root), toParent: laneFolder, sourceBoardRoot: root, destinationBoardRoot: root, @@ -1962,36 +2011,36 @@ public final class BoardStore { } guard landed != nil, !arrivals.isEmpty else { return } - // move → move back (original lane, original `order`); a drop that never left its lane is - // 06's Reorder rather than Move, which is the same distinction the commit vocabulary draws. + // move → move back (original container, original `order`); a drop that never left its lane is + // 06's Reorder rather than Move, which is the same distinction the commit vocabulary draws — + // and a card arriving from the trash always counts as a Move, because it crossed containers. let inverse: [(from: URL, toParent: URL, order: Double)] = arrivals.compactMap { arrival in guard let origin = origins[arrival.id] else { return nil } return ( from: laneFolder.appendingPathComponent(arrival.id.rawValue, isDirectory: true), - toParent: root.appendingPathComponent(origin.lane.rawValue, isDirectory: true), + toParent: Self.parentFolder(of: origin.path, under: root), order: origin.order ) } let forward: [(from: URL, order: Double)] = arrivals.compactMap { arrival in guard let origin = origins[arrival.id] else { return nil } - return ( - from: root - .appendingPathComponent(origin.lane.rawValue, isDirectory: true) - .appendingPathComponent(arrival.id.rawValue, isDirectory: true), - order: arrival.order - ) + return (from: origin.path.folder(under: root), order: arrival.order) + } + let crossed = members.contains { member in + if case let .card(lane, _) = member.path { return lane != laneID } + return true } - let crossedLanes = members.contains { $0.laneID != laneID } // The two lists are index-aligned mirror images — `inverse[i].from` is where the card is now // and `forward[i].from` is where it was — so the expectations read as one swap: **the undo // wants the card at its destination holding the rank the drop gave it; the redo wants it back - // at its origin holding the rank it left.** The destination *path* is the lane check: a card - // a foreign writer moved elsewhere leaves nothing there to validate. + // at its origin holding the rank it left.** The destination *path* is both the lane check and + // the container check: a card a foreign writer moved elsewhere — into the trash included — + // leaves nothing there to validate (`HistoryStaleness`). registerStep( - HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count), + HistoryPhrase.name(crossed ? .move : .reorder, kind: .card, count: arrivals.count), subject: members.count == 1 ? members[0].title : nil, - undoExpects: zip(inverse, forward).map { .live($0.from, .order($1.order)) }, - redoExpects: zip(inverse, forward).map { .live($1.from, .order($0.order)) } + undoExpects: zip(inverse, forward).map { .present($0.from, .order($1.order)) }, + redoExpects: zip(inverse, forward).map { .present($1.from, .order($0.order)) } ) { _ in for step in inverse { _ = try BoardWriter.moveItem( @@ -2015,6 +2064,15 @@ public final class BoardStore { } } + /// The parent folder an item at `path` sits in — the destination an inverse move returns it to. + nonisolated static func parentFolder(of path: ItemPath, under root: URL) -> URL { + switch path { + case let .card(lane, _): ItemPath.lane(lane).folder(under: root) + case .trashCard: BoardWriter.trashFolder(inBoard: root) + case .lane: root + } + } + /// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among /// `laneID`'s rendered cards, **originals untouched** (04-interactions.md ▸ Drag and drop: /// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop"). @@ -2029,11 +2087,11 @@ public final class BoardStore { /// reappear. So the drop's index is mapped through to the neighbour it names — the card the run /// lands in front of — and the rank is taken there. public func copyCards(_ ids: Set, toLane laneID: ItemID, at index: Int) { - guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return } + guard let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return } let members = draggedCards(ids) guard !members.isEmpty else { return } - let rendered = destination.cards.filter { !$0.isDeleted } + let rendered = destination.cards let remaining = rendered.filter { !ids.contains($0.id) } let target = min(max(0, index), remaining.count) // The resting-layout index, re-read against the layout the originals are still part of. @@ -2042,7 +2100,7 @@ public final class BoardStore { : rendered.count let root = rootURL - let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + let laneFolder = ItemPath.lane(laneID).folder(under: root) try? performWrite { () throws(BoardWriteError) -> Void in var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count) @@ -2057,10 +2115,12 @@ public final class BoardStore { guard let ranks else { return } for (member, rank) in zip(members, ranks) { - let folder = root - .appendingPathComponent(member.laneID.rawValue, isDirectory: true) - .appendingPathComponent(member.id.rawValue, isDirectory: true) - _ = try BoardWriter.copyItem(at: folder, toParent: laneFolder, order: rank, stamps: .fork) + _ = try BoardWriter.copyItem( + at: member.path.folder(under: root), + toParent: laneFolder, + order: rank, + stamps: .fork + ) } } } @@ -2131,26 +2191,28 @@ public final class BoardStore { operation: operation, toLane: laneID, at: index, - clearingTombstones: false, normalizingLooseFiles: false ) } - /// **The clipboard's card arrival** — `receiveCards`/`receiveRestoredCards` with the two axes a - /// paste varies independently (04-interactions.md ▸ Clipboard). + /// **The clipboard's card arrival** — `receiveCards` with the one axis a paste varies + /// independently (04-interactions.md ▸ Clipboard). /// /// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot - /// (or, per entry, from the embedded `index.md`), `.move` is the armed cut's — "the ⌘-drag move - /// path — identity travels" — and `clearingTombstones` is the trash's copy-out rule, "`deleted:` - /// is stripped **at materialization**". A cut is live-only (⌘X is disabled in the trash), so the - /// two flags never both fire; the parameter is not narrowed for that, because which of them is - /// reachable is the *clipboard's* rule and this method's job is only to obey both. + /// (or, per entry, from the embedded `index.md`) and `.move` is the armed cut's — "the ⌘-drag + /// move path — identity travels", which is also the keyboard restore when the cut was made in the + /// trash (▸ The trash: "cut in the trash, paste into a lane is the keyboard-native restore, an + /// ordinary folder move"). /// - /// `normalizingLooseFiles` is the third axis: **"a paste is an import boundary, so normalization - /// applies"** (04-interactions.md ▸ Clipboard, settled 2026-07-28 — 01-storage-format.md's - /// loose-file rule). Loose files the staged snapshot carries beside a card's `index.md` land in - /// the pasted card's `attachments/`, Finder-renamed on collision, so "nothing the snapshot - /// preserved is dropped on arrival" *and* nothing arrives out of place. + /// **The trash's old copy-out rule is gone with the tombstone it stripped** (resettled + /// 2026-07-28): a trashed card carries no `deleted:` key, so a card copied out of the trash is + /// an ordinary copy of an ordinary card and there is nothing to clear at materialization. + /// + /// `normalizingLooseFiles` is the remaining axis: **"a paste is an import boundary, so + /// normalization applies"** (04-interactions.md ▸ Clipboard, settled 2026-07-28 — + /// 01-storage-format.md's loose-file rule). Loose files the staged snapshot carries beside a + /// card's `index.md` land in the pasted card's `attachments/`, Finder-renamed on collision, so + /// "nothing the snapshot preserved is dropped on arrival" *and* nothing arrives out of place. /// /// It has **no default**, here and on `receiveLanes`, so every arrival path states which side of /// the import boundary it is on rather than inheriting an answer. The clipboard passes `true` @@ -2163,7 +2225,6 @@ public final class BoardStore { operation: TransferOperation, toLane laneID: ItemID, at index: Int, - clearingTombstones: Bool, normalizingLooseFiles: Bool ) { receive( @@ -2171,55 +2232,25 @@ public final class BoardStore { operation: operation, toLane: laneID, at: index, - clearingTombstones: clearingTombstones, normalizingLooseFiles: normalizingLooseFiles ) } - /// The cross-board half of drag-to-restore (04-interactions.md ▸ The trash): tombstoned rows - /// dropped on *another* board. - /// - /// Identical to `receiveCards` but for one extra write per arrival — `deleted:` is removed once - /// the folder is at its destination, so what lands is **live**, "like copying a file out of - /// Finder's Trash". The two cases the design names fall straight out of the operation: - /// - /// - `.copy` (the default) — a live copy lands here and the tombstoned original stays in the - /// source board's trash, exactly as ⌘C out of the trash behaves. - /// - `.move` (⌘-drag) — the true cross-board restore-move: the tombstone leaves the source - /// board entirely, ordinary cross-board move semantics apply, and `deleted:` is cleared at the - /// destination. - /// - /// The strip is a second `updateIndex` rather than a flag on the first because the arrival's - /// `order` is written by `copyItem`/`moveItem` before this store has a folder to point at, and - /// because `restoreItem` is already the one expression in the app for "remove the `deleted:` - /// key" — the bytes are never rewritten any other way. - public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) { - receive( - sources.map(ItemSource.folder), - operation: operation, - toLane: laneID, - at: index, - clearingTombstones: true, - normalizingLooseFiles: false - ) - } - private func receive( _ sources: [ItemSource], operation: TransferOperation, toLane laneID: ItemID, at index: Int, - clearingTombstones: Bool, normalizingLooseFiles: Bool ) { guard !sources.isEmpty, - let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) + let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return } - let rendered = destination.cards.filter { !$0.isDeleted } + let rendered = destination.cards let target = min(max(0, index), rendered.count) let root = rootURL - let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + let laneFolder = ItemPath.lane(laneID).folder(under: root) try? performWrite { () throws(BoardWriteError) -> Void in var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count) @@ -2242,14 +2273,12 @@ public final class BoardStore { sourceBoardRoot: Self.boardRoot(ofCardFolder:), order: rank ) else { continue } - let cardFolder = laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true) // Inside the same bracket, so the card lands normalized in one round trip rather // than appearing loose for a reload and being tidied afterwards. - if normalizingLooseFiles { - try BoardWriter.normalizeLooseFiles(inCard: cardFolder) - } - guard clearingTombstones else { continue } - try BoardWriter.restoreItem(at: cardFolder) + guard normalizingLooseFiles else { continue } + try BoardWriter.normalizeLooseFiles( + inCard: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true) + ) } } } @@ -2294,20 +2323,14 @@ public final class BoardStore { } } - /// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes. + /// A cross-board lane drop, landing contiguously at `stripIndex` among this board's lanes. /// - /// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md - /// ▸ Drag and drop's rule: - /// - /// - `.copy` — "Lanes copy cards and all", then **the copy strips tombstoned cards**: the copy - /// transfers content, and trash isn't content (09-templates.md's instantiation precedent — a - /// board isn't born with trash). The tombstoned originals stay recoverable in the source - /// board. `copyItem` offers no filter hook — it copies the tree verbatim by design, which is - /// what makes attachments and strays arrive byte-identical — so the strip is the line after - /// (`BoardWriter.stripTombstonedChildren`), pointed at a folder minted seconds earlier. - /// - `.move` — "A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in - /// the destination's trash." Nothing to arrange: a move never reads below its root, so the - /// tombstones travel and the destination's trash quasi-lane renders them. + /// **The two operations no longer differ** (04-interactions.md ▸ Drag and drop, resettled + /// 2026-07-28): "A lane carries exactly its cards — the trash is board-level (`.trash/`), so + /// there is nothing lane-nested to strip or carry: copy and ⌘-drag move alike transfer the lane's + /// folder as it is; the old tombstone-stripping rule is retired with the tombstone model." A + /// copy therefore mints fresh GUIDs and a move carries the identity, and that is the whole of the + /// difference. /// /// Within-board lane reorders are `moveLane(_:toIndex:)`, and a within-board lane *copy* does /// not exist by drag at all (⌥ is ignored on lane drags; the clipboard is that operation's one @@ -2317,22 +2340,16 @@ public final class BoardStore { sources.map(ItemSource.folder), operation: operation, at: stripIndex, - clearingTombstones: false, normalizingLooseFiles: false ) } - /// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback and the - /// trash's copy-out rule folded in (04-interactions.md ▸ Clipboard, ▸ The trash). + /// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback folded in + /// (04-interactions.md ▸ Clipboard). /// /// The two operations keep their drag semantics exactly, because 04 says they are the same - /// semantics: "a pasted *copy* takes fresh GUIDs throughout and **strips tombstoned cards**; a - /// cut-paste is the ⌘-drag move — the folder moves whole, tombstoned cards landing in the - /// destination's trash". `clearingTombstones` adds the one thing a drag never asks for: a lane - /// *entry* copied out of the trash arrives live, its own `deleted:` removed once it is at the - /// destination — the lane-level twin of `receiveRestoredCards`, and the reason the strip runs - /// first is that the two writes touch different files and the strip's target list is the one that - /// must be read before anything is rewritten. + /// semantics: "a pasted *copy* takes fresh GUIDs throughout; a cut-paste is the ⌘-drag move — + /// the folder moves whole (nothing lane-nested to strip or carry — the trash is board-level)". /// /// `normalizingLooseFiles` is the import boundary's, exactly as on `receiveCards` and with the /// same no-default rule; at lane level it reaches each arriving lane's **cards**, which is the @@ -2341,13 +2358,12 @@ public final class BoardStore { _ sources: [ItemSource], operation: TransferOperation, at stripIndex: Int, - clearingTombstones: Bool, normalizingLooseFiles: Bool ) { guard !sources.isEmpty else { return } let root = rootURL - let rendered = snapshot.lanes.filter { !$0.isDeleted } + let rendered = snapshot.lanes let target = min(max(0, stripIndex), rendered.count) try? performWrite { () throws(BoardWriteError) -> Void in @@ -2372,20 +2388,10 @@ public final class BoardStore { order: rank ) else { continue } - let laneFolder = root.appendingPathComponent(arrived.rawValue, isDirectory: true) - // The strip belongs to the copy alone: "a move never reads below its root, so the - // tombstones travel and the destination's trash quasi-lane renders them". - if operation == .copy { - try BoardWriter.stripTombstonedChildren(of: laneFolder) - } - // After the strip, so a tombstoned card the copy is about to remove is not tidied - // on its way to being deleted. - if normalizingLooseFiles { - try BoardWriter.normalizeLooseFiles(inLane: laneFolder) - } - if clearingTombstones { - try BoardWriter.restoreItem(at: laneFolder) - } + guard normalizingLooseFiles else { continue } + try BoardWriter.normalizeLooseFiles( + inLane: root.appendingPathComponent(arrived.rawValue, isDirectory: true) + ) } } } @@ -2414,11 +2420,11 @@ public final class BoardStore { /// Copies `urls` into `cardID`'s `attachments/` — the drop-on-a-card half. /// - /// **Liveness is ancestor-walked** (`liveItem`): a card under a tombstoned lane renders nowhere, - /// so it is as gone as a deleted one, and a drop on a target that vanished under the gesture - /// writes nothing at all. That is also the whole of "Finder file drops on tombstoned cards are - /// inert" (04-interactions.md ▸ The trash) on the write side — the gesture refuses to propose one - /// in the first place, and this refuses to serve one that slipped through a reload. + /// **The board container, and only it** (`boardItem`): a card that has been deleted is in + /// `.trash/`, and a drop on a target that vanished under the gesture writes nothing at all. That + /// is also the whole of "Finder file drops on trash cards are inert" (04-interactions.md ▸ The + /// trash) on the write side — the gesture refuses to propose one in the first place, and this + /// refuses to serve one that slipped through a reload. /// /// A lane id is refused for the same reason a lane folder is: attachments belong to cards. /// Multi-file, any type, and a name already taken is renamed Finder-style rather than @@ -2426,7 +2432,7 @@ public final class BoardStore { /// failing file stops the batch and banners naming it, and everything already copied stays. public func importAttachments(_ urls: [URL], toCard cardID: ItemID) { guard !urls.isEmpty, - let item = Self.liveItem(cardID, in: snapshot), + let item = Self.boardItem(cardID, in: snapshot), let card = item.cardID else { return } @@ -2451,15 +2457,15 @@ public final class BoardStore { /// and its failures have to reach the banner strip like every other write's. On git boards it /// is also one commit, for free, for the same reason. /// - /// The guards are `importAttachments`' exactly, and its inverse in every way: **liveness is - /// ancestor-walked** (`liveItem`), so a card under a tombstoned lane is as gone as a deleted one + /// The guards are `importAttachments`' exactly, and its inverse in every way: **the board + /// container and only it** (`boardItem`), so a trashed card is as unreachable as a deleted one /// and its attachments are not removable from a window that is dismissing itself in the same /// breath; a lane id is refused because attachments belong to cards. Which *file* may go is /// `BoardWriter.removeAttachment`'s listing check, and a name that is no longer there is a /// silent no-op rather than a failure — the reload is the authority on what the card has. public func removeAttachment(named name: String, fromCard cardID: ItemID) { guard !name.isEmpty, - let item = Self.liveItem(cardID, in: snapshot), + let item = Self.boardItem(cardID, in: snapshot), let card = item.cardID else { return } @@ -2604,12 +2610,12 @@ public final class BoardStore { /// (04-interactions.md ▸ Grammar) is the rule it would otherwise break. public func createCards(fromFiles urls: [URL], inLane laneID: ItemID, at index: Int) { guard !urls.isEmpty, - let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) + let lane = snapshot.lanes.first(where: { $0.id == laneID }) else { return } transient.noteUserCreation() - let rendered = lane.cards.filter { !$0.isDeleted } + let rendered = lane.cards let target = min(max(0, index), rendered.count) let root = rootURL let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) @@ -2679,22 +2685,23 @@ public final class BoardStore { /// is not available — **the menu items' `disabled` condition and the write's guard, as one /// answer** (`LaneWidthCommands`' rule). /// - /// `nil` covers every refusal the design names in one expression: an empty or tombstoned - /// selection ("⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards"), a lane selection ("with a lane + /// `nil` covers every refusal the design names in one expression: an empty or trash-side + /// selection ("⌥⌘↑/⌥⌘↓ are inert on trash cards — the trash's order is its arrival order, not a + /// workspace to arrange"), a lane selection ("with a lane /// selected … ⌥⌘↑/⌥⌘↓ are inert"), a card selection that **spans lanes** ("cards never change /// lanes by ⌘-arrow … so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes"), and a block /// already at the end of its lane. func sortPlan(_ direction: SortMath.Direction) -> (lane: Lane, ordering: [ItemID])? { let selection = transient.selection - guard selection.liveness == .live, + guard selection.container == .board, SelectionGrammar.kind(of: selection, in: snapshot) == .card, // `nil` here *is* the spans-lanes case: the helper answers only when one lane holds // the whole set. let laneID = Self.lane(holding: selection.ids, in: snapshot), - let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) + let lane = snapshot.lanes.first(where: { $0.id == laneID }) else { return nil } - let rendered = lane.cards.filter { !$0.isDeleted }.map(\.id) + let rendered = lane.cards.map(\.id) guard let ordering = SortMath.reordered(rendered, moving: selection.ids, direction) else { return nil } return (lane, ordering) } @@ -2721,7 +2728,7 @@ public final class BoardStore { public func sortSelection(_ direction: SortMath.Direction) { guard let plan = sortPlan(direction) else { return } - let rendered = plan.lane.cards.filter { !$0.isDeleted } + let rendered = plan.lane.cards let laneFolder = rootURL.appendingPathComponent(plan.lane.id.rawValue, isDirectory: true) let orders = rendered.map(\.order) let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) }) @@ -2767,9 +2774,9 @@ public final class BoardStore { let moved = selection.ids registerStep( HistoryPhrase.name(.reorder, kind: .card, count: moved.count), - subject: moved.count == 1 ? moved.first.flatMap { Self.liveItem($0, in: snapshot)?.title } : nil, - undoExpects: steps.map { .live($0.folder, .order($0.to)) }, - redoExpects: steps.map { .live($0.folder, .order($0.from)) } + subject: moved.count == 1 ? moved.first.flatMap { Self.boardItem($0, in: snapshot)?.title } : nil, + undoExpects: steps.map { .present($0.folder, .order($0.to)) }, + redoExpects: steps.map { .present($0.folder, .order($0.from)) } ) { _ in for step in steps { try Self.setOrder(step.from, at: step.folder) @@ -2790,40 +2797,53 @@ 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"). + /// Whether physically removing a card on this board destroys the only copy of it — and + /// therefore whether a permanent delete stands an alert between one keystroke and unrecoverable + /// deletion (03-board-ui.md § Trash, "Both confirm 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. + // m7-git: git boards answer `false` here — "on git boards they act immediately (delete-never- + // forgets)" (06-history-undo.md). 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). + /// **File ▸ Delete ⌘⌫ and its plain-⌫ grammar twin — staged by place** (04-interactions.md ▸ + /// The map, resettled 2026-07-28: "one Delete vocabulary, staged by place"). /// - /// A convenience over `delete(_:)` so the two call sites cannot disagree about *what* the - /// command acts on. + /// The selection's container is the whole of the staging, and it is asked exactly once, here: + /// a board selection moves into `.trash/` (or, for lanes, deletes physically), a trash selection + /// deletes **permanently**. That is why Put Back's ⌘⌫ twin could retire — there is one Delete + /// item and one predicate, and which write it performs is a fact about where the user was + /// working, not about which of two menu rows AppKit happened to enable. + /// + /// **The confirmation is not here.** Whether the permanent branch's loss is real is + /// `purgeIsUnrecoverable`'s question and the alert is the window's (`TrashConfirmations`); a + /// store method that put up its own dialog could not be driven from a test. public func deleteSelection() { - delete(selection.ids) + switch selection.container { + case .board: delete(selection.ids) + case .trash: deleteTrashCards(selection.ids) + } } - /// Tombstones every live item in `ids` — cards or lanes, in one bracket. + /// Deletes every **board** item in `ids` — cards into `.trash/`, lanes outright — 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`). + /// **Two writes, because they are two acts** (03-board-ui.md § Trash): "deleting a card moves its + /// folder into `/.trash/`", while "Cards only. Lanes are never trashed — deleting a + /// lane deletes it, folder and contents, physically. The net is undo, not the trash." /// - /// **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. + /// **A set naming both is not a gesture this app can produce** — the selection is cards XOR lanes + /// (04-interactions.md § Selection) — so the partition below never actually splits, and when a + /// caller hands one anyway the lanes win: a lane delete takes its cards with it, and running the + /// trash move as well would put a second step on the undo stack for one keystroke. + /// + /// **Ids that name nothing are silently skipped**, not refused: the paths are resolved against + /// the snapshot, so a selection the next reload will drop writes nothing. An empty resolution + /// never opens a bracket at all. /// /// **The selection moves to the successor sibling** — 04-interactions.md ▸ The map's Finder-style /// rule ("next card in the lane, next lane on the board; the last sibling's predecessor @@ -2832,40 +2852,50 @@ public final class BoardStore { /// /// Two things make that hold. The successor is computed from the **pre-write** snapshot, which is /// the last one that still knows where the doomed items sat; and it is selected **immediately**, - /// rather than waiting for the reload the tombstone will echo back — a second ⌫ pressed before - /// the watcher rounds the first one back must already have somewhere to land. + /// rather than waiting for the reload the delete will echo back — a second ⌫ pressed before the + /// watcher rounds the first one back must already have somewhere to land. /// /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's - /// reload-survival rule), and neither do `putBack`/`deleteImmediately` — the item merely changed - /// sides, or nothing survives on either. + /// reload-survival rule). public func delete(_ ids: Set) { - let paths = TrashModel.paths(of: ids, on: .live, in: snapshot) + let paths = ItemPath.resolve(ids, in: .board, snapshot: snapshot) guard !paths.isEmpty else { return } - // The successor is drawn from what the lane is *showing*, so a delete under an active search - // walks the filtered lane rather than selecting a card the query has hidden. - let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter) + // The successor is drawn from what the container is *showing*, so a delete under an active + // search walks the filtered lane rather than selecting a card the query has hidden. + let successor = SelectionGrammar.successor( + afterDeleting: ids, + in: .board, + snapshot: snapshot, + filter: searchFilter + ) - tombstone(paths) + let lanes = paths.compactMap { path -> ItemID? in + guard case let .lane(id) = path else { return nil } + return id + } + let landed = lanes.isEmpty + ? moveToTrash(paths.compactMap(Self.cardMove(of:))) + : removeLanes(lanes) + guard landed else { return } if let successor { - select([successor], liveness: .live, anchor: successor, head: successor) + select([successor], in: .board, anchor: successor, head: successor) } else { clearSelection() } } - /// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28): "the drag - /// becomes the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫ - /// tombstone". + /// **Drop-on-trash deletes** (04-interactions.md ▸ The trash): "the drag is the pointer's delete + /// gesture — release moves the dragged card(s) into `.trash/`". /// - /// *Exactly* the ⌫ tombstone is a claim about the disk, and `tombstone(_:)` is what makes it + /// *Exactly* the ⌫ delete is a claim about the disk, and `moveToTrash(_:)` is what makes it /// structural rather than a matter of two call sites staying in step: one write op, one bracket, - /// one set of stamps, so a card deleted by drop and a card deleted by keystroke are - /// byte-indistinguishable afterwards (`TrashDropWriteTests`). + /// one set of ranks and stamps, so a card deleted by drop and a card deleted by keystroke are + /// byte-indistinguishable afterwards. /// /// ### The one thing it does not share is the successor /// - /// ⌫ moves the selection to the deleted item's successor sibling because *the selection* lost its + /// ⌫ moves the selection to the deleted card's successor sibling because *the selection* lost its /// cards and "repeated ⌫ walks down a lane" — the rule exists to keep a keyboard gesture /// repeatable. A drag has no such continuation, and its run is **not necessarily the selection at /// all**: dragging a card outside the selection drags that card alone and leaves the selection @@ -2873,391 +2903,406 @@ public final class BoardStore { /// selection that never lost anything. /// /// So this writes and says nothing about the selection, and the ordinary reload does the rest: a - /// live-side set ejects members that flip to tombstoned, as the vanish it is (02-architecture.md's - /// reload-survival rule). Drag the selection itself onto the trash and the selection empties; - /// drag something else and it is untouched. Neither case needs surgery here. - /// - /// Cards only, by the gesture's own gate (`TrashDrop.accepts`) — but nothing here depends on - /// that: the paths resolve on the live side exactly as `delete(_:)`'s do. + /// board-side set ejects members that cross into the trash, as the vanish it is + /// (02-architecture.md's reload-survival rule). public func deleteByDrag(cardIDs: [ItemID]) { - let paths = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot) - guard !paths.isEmpty else { return } - tombstone(paths) + _ = moveToTrash(ItemPath.resolve(Set(cardIDs), in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:))) } - /// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — tombstones the - /// card … the window then dismisses itself"). + /// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card + /// to the trash … the window then dismisses itself"). /// - /// The write is `tombstone(_:)`, so a card deleted from its own window is byte-indistinguishable - /// from one deleted with ⌫ on the board or dropped on the trash lane — one write op, one bracket, - /// one set of stamps. What differs is the same thing that differs for the drag, and for its - /// reason: **it says nothing about the selection.** ⌫ moves the board's selection to the deleted - /// card's successor sibling because the rule exists to make a repeated keystroke walk down a lane; - /// a button in another window has no such continuation, and the card it deletes need not be - /// selected on the board at all — picking a successor here would re-point a selection that never - /// lost anything. The ordinary reload does the rest: a live-side selection ejects a member that - /// flips to tombstoned, as the vanish it is. + /// The write is `moveToTrash(_:)`, so a card deleted from its own window is byte-indistinguishable + /// from one deleted with ⌫ on the board or dropped on the trash column. What differs is the same + /// thing that differs for the drag, and for its reason: **it says nothing about the selection.** /// /// **It does not dismiss the window either**, and must not: the window's dismissal is a *fate* - /// re-derived from every snapshot (`CardWindowHost.cardWindowFate`), so the tombstone this writes + /// re-derived from every snapshot (`CardWindowHost.cardWindowFate`), so the move this writes /// comes back through the watcher and the fate walk takes the window down — the same path an /// agent's or another window's delete takes. A second dismissal from here would be a second rule /// able to disagree with the first. - /// - /// A vanished or already-tombstoned card resolves to no path and writes nothing, `delete(_:)`'s - /// rule; liveness is ancestor-walked, so a card under a tombstoned lane is gone too — and its - /// window is already dismissing. public func deleteCard(_ id: ItemID) { - let paths = TrashModel.paths(of: [id], on: .live, in: snapshot) - guard !paths.isEmpty else { return } - tombstone(paths) + _ = moveToTrash(ItemPath.resolve([id], in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:))) } - /// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and - /// whichever gesture asked** (DRAG-REORDER.md § The drop commits; the style batch's rule). - /// - /// Spelled once so ⌫ and drop-on-trash cannot drift apart on disk; everything that differs - /// between them is about the *selection*, and lives in the callers. - /// It is also where the tombstone's **undo step** is registered, for the identical reason: 13's - /// "tombstone (⌫) → restore" has to mean the same thing whichever gesture asked, and a step - /// registered at each caller would be three chances to name it differently. - private func tombstone(_ paths: [TrashModel.ItemPath]) { - let folders = paths.map { $0.folder(under: rootURL) } - let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in - for folder in folders { - try BoardWriter.deleteItem(at: folder) - } - } - guard landed != nil else { return } - - // tombstone → restore. The undo is Put Back's own write, which is what makes 13's "the stack - // and the trash are two doors to the same tombstone state" true on disk rather than by - // agreement — undoing a delete is *identical* in effect to Put Back. - // - // **Existence and liveness, no fields** (13: "existence/liveness for ... delete ... steps"): - // what a tombstone writes is the item's side of the trash, so that is the whole after-value. - // The `deleted` timestamp is deliberately *not* compared — it is a machine stamp rather than - // a decision, and an item somebody put back and re-deleted is still on the side this step - // left it on. - let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card - registerStep( - HistoryPhrase.name(.delete, kind: kind, count: folders.count), - subject: paths.count == 1 ? title(at: paths[0]) : nil, - undoExpects: folders.map { .tombstoned($0) }, - redoExpects: folders.map { .live($0) } - ) { _ in - for folder in folders { - try BoardWriter.restoreItem(at: folder) - } - } redo: { _ in - for folder in folders { - try BoardWriter.deleteItem(at: folder) - } - } + /// One card about to be moved into the trash: where it is now, and what an undo has to put back. + private struct TrashMove { + let id: ItemID + let laneID: ItemID + /// The rank it holds in its lane — the position an undo returns it to (13's "move → move + /// back (original lane, original `order`)"). + let order: Double + let title: String? } - /// 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 is **not reachable here at all**, by construction - /// rather than by the UI happening not to offer it: it has no trash row, and the trashed side of - /// `TrashModel.paths` is that row set exactly (`Liveness.walk`). Recovering it stays the two-step - /// the design settled on — put the lane back, then put the card back from the row it regains. - /// - /// 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 paths = TrashModel.paths(of: ids, on: .trashed, in: snapshot) - guard !paths.isEmpty else { return } - // Captured before the write: the timestamp each row is filed in the trash under, which is - // what an undo has to put back — see `restoreTombstone`. - let restored = paths.map { (folder: $0.folder(under: rootURL), deleted: deletedField(at: $0)) } - - let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in - for item in restored { - try BoardWriter.restoreItem(at: item.folder) - } - } - guard landed != nil else { return } - - // restore (Put Back) → tombstone (13-native-undo.md ▸ Rules) — the trash pair read the other - // way round from `tombstone(_:)`'s step. - let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card - registerStep( - HistoryPhrase.name(.restore, kind: kind, count: restored.count), - subject: paths.count == 1 ? title(at: paths[0]) : nil, - undoExpects: restored.map { .live($0.folder) }, - redoExpects: restored.map { .tombstoned($0.folder) } - ) { _ in - for item in restored { - try BoardWriter.updateIndex(inItemFolder: item.folder, operation: .delete(title: nil)) { document in - Self.restoreTombstone(item.deleted, in: &document) - } - } - } redo: { _ in - for item in restored { - try BoardWriter.restoreItem(at: item.folder) - } - } + /// A resolved board path as a card move, or `nil` for a lane — the one place the partition is + /// spelled, so no caller re-derives it. + private static func cardMove(of path: ItemPath) -> (lane: ItemID, card: ItemID)? { + guard case let .card(lane, id) = path else { return nil } + return (lane, id) } - /// The `deleted` value a trash row currently carries — the one field a Put Back's inverse has to - /// carry forward, and one the `ItemPath` vocabulary deliberately does not (a path is a location, - /// not a reading of the file there). - /// What a trash-pair step's one item is called — the skip banner's quoted subject, `nil` for an - /// untitled item (which falls back to the step's own phrase) and for an id the snapshot has - /// already lost. A path is a location, not a reading of the file there, so this is the same - /// deliberate lookup `deletedField(at:)` is. - private func title(at path: TrashModel.ItemPath) -> String? { - guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return nil } - guard let cardID = path.cardID else { return lane.title.value } - return lane.cards.first(where: { $0.id == cardID })?.title.value - } - - private func deletedField(at path: TrashModel.ItemPath) -> FieldValue { - guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return .missing } - guard let cardID = path.cardID else { return lane.deleted } - return lane.cards.first(where: { $0.id == cardID })?.deleted ?? .missing - } - - /// Delete Immediately ⌥⌘⌫: physically removes every tombstoned item in `ids` (03-board-ui.md § - /// Trash), in one bracket. + /// **The delete write itself: a physical move into `/.trash/`, at a freshly minted top + /// rank** — one `performWrite` bracket whatever the set's size and whichever gesture asked. /// - /// **It registers no undo step, and `purgeIsUnrecoverable` stays `true`** — 13-native-undo.md - /// ▸ Rules settles this by name: "Permanently delete (Delete Immediately, Empty Trash) — - /// `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule already fires on - /// all base boards … the confirm *is* the safety". A stack entry here would be a promise the - /// filesystem cannot keep. + /// Spelled once so ⌫, drop-on-trash and the card window's button cannot drift apart on disk; + /// everything that differs between them is about the *selection*, and lives in the callers. /// - /// **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. + /// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the + /// trash's topmost position, minting an `order` rank above the current top"). That is a question + /// about the snapshot, which the stateless Writer does not have — so `Ranks.insertAtHead` runs + /// here over `snapshot.trash`, and a multi-card delete threads the minted rank back through the + /// running list so each card in the run lands above the one before it. Newest-first therefore + /// falls out of ordinary ranks, with no timestamp sort anywhere. /// - /// 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. - /// - /// Not undoable, `deleteImmediately`'s ruling and its wording — this is the other half of 13's - /// "Permanently delete". - /// - /// **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, - /// at the drop position** — `deleted:` removed and `order` set (03-board-ui.md § Trash, - /// 04-interactions.md ▸ The trash: "dropping a tombstoned card into one of its own board's lanes - /// restores it at the drop position"). - /// - /// `index` is the drag model's own index — a position among the destination lane's rendered - /// cards, which the tombstoned card is by definition not among (DRAG-REORDER.md § The drop - /// commits). Clamped, like every other drop commit. - /// - /// **Cross-lane is 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 the move, carrying the - /// rank. Doing it the other way round would have the second call chasing a folder the first one - /// had already relocated. - /// - /// **Same lane never moves a folder**, so it is one write: the key removed and, only when the - /// drop actually names a different rank than the card already carries, the `order` beside it. - /// That guard is what preserves the position-perfect restore the trash's pure-view design pays - /// for — a row dropped back where its recorded order already puts it comes back *exactly* there, - /// with no rank invented for it and no neighbour disturbed. - /// - /// **Within-board only.** A drop on another board follows the locality model instead - /// (`receiveRestoredCards`): a live copy by default with the tombstoned original staying put, - /// and ⌘-drag forcing the true restore-move. - /// - /// 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, at index: Int) { - restoreByDrag(cardIDs: [cardID], intoLane: laneID, at: index) - } - - /// The multi-drag face of the same gesture: N trash rows dropped over one live lane land - /// contiguously at `index`, **in drop order** — the order the payload carries, which is the - /// trash's own sorted order (03-board-ui.md § Trash ▸ Contents). - /// - /// It is the plural rather than a loop over the singular for `moveLanes`' reason: one - /// `performWrite` bracket per gesture whatever the set's size, so one reload and one commit - /// (DRAG-REORDER.md § The drop commits). Every rule above holds per member — the same-lane - /// single write, the cross-lane restore-then-move pair, and the recorded-`order` preservation, - /// which is what makes a row dropped back where it already belonged come back exactly there. - public func restoreByDrag(cardIDs: [ItemID], intoLane laneID: ItemID, at index: Int) { - guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return } - - // A row exists only for a card whose *own* flag is set under a *live* lane — the trash's - // absolute ancestor walk (03-board-ui.md § Trash ▸ Contents). Anything else in the list names - // nothing draggable and is silently skipped, which is this method's standing posture. - let rows: [(laneID: ItemID, card: Card)] = cardIDs.compactMap { id in - guard let source = snapshot.lanes.first(where: { lane in - !lane.isDeleted && lane.cards.contains { $0.id == id && $0.isDeleted } - }), - let card = source.cards.first(where: { $0.id == id }) + /// - Returns: whether the write landed, so a caller can decide what to do with the selection. + @discardableResult + private func moveToTrash(_ cards: [(lane: ItemID, card: ItemID)]) -> Bool { + let moves: [TrashMove] = cards.compactMap { entry in + guard let lane = snapshot.lanes.first(where: { $0.id == entry.lane }), + let card = lane.cards.first(where: { $0.id == entry.card }) else { return nil } - return (source.id, card) + return TrashMove(id: card.id, laneID: lane.id, order: card.order, title: card.title.value) } - guard !rows.isEmpty else { return } + guard !moves.isEmpty else { return false } let root = rootURL - let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) - let rendered = destination.cards.filter { !$0.isDeleted } - let target = min(max(0, index), rendered.count) + // The ranks, minted against the trash as it stands and threaded forward: each arrival is + // above the previous one, so a three-card ⌫ reads newest-first in the column exactly as three + // separate deletes would. + var ladder = snapshot.trash.map(\.order) + var ranks: [Double] = [] + for _ in moves { + let rank = Ranks.insertAtHead(ofVisible: ladder) + ranks.append(rank) + ladder.insert(rank, at: 0) + } - // What each row was before the gesture — its lane, its recorded rank, and the timestamp it is - // filed in the trash under — against where it lands. A tombstoned card is not among the - // destination's rendered cards, so a renumber inside the bracket cannot touch its own rank; - // only the neighbours' move, and their sequence is preserved. - var moves: [(cardID: ItemID, laneID: ItemID, priorOrder: Double, deleted: FieldValue, order: Double)] = [] let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in - var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count) - if ranks == nil { - try BoardWriter.renumberVisibleChildren(of: laneFolder) - ranks = Ranks.insertionRanks( - amongVisible: Ranks.renumbered(count: rendered.count), - at: target, - count: rows.count - ) - } - guard let ranks else { return } - - for (row, rank) in zip(rows, ranks) { - let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root) - moves.append(( - cardID: row.card.id, - laneID: row.laneID, - priorOrder: row.card.order, - deleted: row.card.deleted, - order: rank - )) - - guard row.laneID != laneID else { - try BoardWriter.updateIndex( - inItemFolder: cardFolder, - // `.restore(title: nil)`: `updateIndex` enriches it off the document it reads. - operation: .restore(title: nil) - ) { document in - document.remove(FrontmatterKeys.deleted) - if rank != row.card.order { - document.set(FrontmatterKeys.order, to: .double(rank)) - } - } - continue - } - - try BoardWriter.restoreItem(at: cardFolder) - _ = try BoardWriter.moveItem( - at: cardFolder, - toParent: laneFolder, - sourceBoardRoot: root, - destinationBoardRoot: root, + for (move, rank) in zip(moves, ranks) { + try BoardWriter.deleteCardToTrash( + at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root), + inBoard: root, order: rank ) } } - guard landed != nil, !moves.isEmpty else { return } + guard landed != nil else { return false } - // restore → tombstone (13-native-undo.md ▸ Rules), with the position half of the gesture - // walked back too: the row returns to the lane it was trashed in, at the rank it was trashed - // holding, under the timestamp it was trashed at — which is exactly where its trash row was. + // delete → **move back out of `.trash/`** (13-native-undo.md ▸ Rules, ▸ Interaction with the + // trash: "a card delete is a move into `.trash/`, so its undo is the ordinary inverse move, + // returning the card to its source lane and rank"). // - // Both halves of the gesture are validated, because both were written: the card must be live - // in the destination lane at the rank the drop gave it, and — the other way round — back in - // the lane it was trashed in, tombstoned, at the rank it was trashed holding. The same-lane - // case collapses to one folder and is still exactly this: the write that set no `order` set - // it to the value it already had. - let steps = moves + // The redo replays the *forward* write with its own captured rank, exactly as every other + // redo in this file replays the values its gesture wrote — so a redone delete lands the card + // back where the undo took it from, rather than at whatever the top of the trash has become + // in the meantime. + // + // **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`): + // the undo wants the card in the trash holding the rank the delete gave it; the redo wants it + // back in its lane holding the rank it left. A foreign restore empties the trash path and the + // undo skips; a foreign re-delete empties the lane path and the redo skips. + let steps = zip(moves, ranks).map { move, rank in + ( + trashed: ItemPath.trashCard(move.id).folder(under: root), + origin: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root), + laneFolder: ItemPath.lane(move.laneID).folder(under: root), + priorOrder: move.order, + trashRank: rank + ) + } registerStep( - HistoryPhrase.name(.restore, kind: .card, count: steps.count), - subject: rows.count == 1 ? rows[0].card.title.value : nil, - undoExpects: steps.map { - .live(laneFolder.appendingPathComponent($0.cardID.rawValue, isDirectory: true), .order($0.order)) - }, - redoExpects: steps.map { - .tombstoned( - TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.cardID).folder(under: root), - .order($0.priorOrder) - ) - } + HistoryPhrase.name(.delete, kind: .card, count: steps.count), + subject: moves.count == 1 ? moves[0].title : nil, + undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) }, + redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) } ) { _ in for step in steps { - let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root) - if step.laneID != laneID { - _ = try BoardWriter.moveItem( - at: laneFolder.appendingPathComponent(step.cardID.rawValue, isDirectory: true), - toParent: root.appendingPathComponent(step.laneID.rawValue, isDirectory: true), - sourceBoardRoot: root, - destinationBoardRoot: root, - order: step.priorOrder - ) - } - try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .delete(title: nil)) { document in - Self.restoreTombstone(step.deleted, in: &document) - document.set(FrontmatterKeys.order, to: .double(step.priorOrder)) - } + _ = try BoardWriter.moveItem( + at: step.trashed, + toParent: step.laneFolder, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: step.priorOrder + ) } } redo: { _ in for step in steps { - let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root) - guard step.laneID != laneID else { - try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .restore(title: nil)) { document in - document.remove(FrontmatterKeys.deleted) - document.set(FrontmatterKeys.order, to: .double(step.order)) - } - continue - } - try BoardWriter.restoreItem(at: priorFolder) - _ = try BoardWriter.moveItem( - at: priorFolder, - toParent: laneFolder, - sourceBoardRoot: root, - destinationBoardRoot: root, - order: step.order - ) + try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root, order: step.trashRank) } } + return true + } + + /// **Deleting a lane is physical** — the folder and its contents go (03-board-ui.md § Trash: + /// "Cards only. Lanes are never trashed … The net is undo, not the trash"). + /// + /// **Capture before you remove.** The undo replays the lane's bytes, which only works if the step + /// is holding them: `captureSubtree` reads the whole tree — nested cards, their `attachments/`, + /// every stray, symlinks as links, POSIX modes — inside the same bracket as the removal, so + /// nothing can change between the two. The capture is the reason this is a *destructive* delete + /// with a real inverse rather than an unrecoverable one. + /// + /// **In-session only, and that is the accepted net** (13-native-undo.md ▸ Rules ▸ session-only + /// persistence): the bytes live on the stack, so closing the board loses them. Git boards keep + /// the lane reachable forever (06-history-undo.md's delete-never-forgets) — a Pro difference, + /// stated honestly. + /// + /// A capture that fails takes the whole bracket down and nothing is removed: better a delete that + /// visibly did not happen than one whose undo could not. + @discardableResult + private func removeLanes(_ ids: [ItemID]) -> Bool { + let lanes = ids.compactMap { id in snapshot.lanes.first { $0.id == id } } + guard !lanes.isEmpty else { return false } + + let root = rootURL + let folders = lanes.map { ItemPath.lane($0.id).folder(under: root) } + var captures: [SubtreeSnapshot] = [] + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + captures.append(try BoardWriter.captureSubtree(at: folder, operation: .delete(title: nil))) + try BoardWriter.removeLane(at: folder) + } + } + guard landed != nil, captures.count == folders.count else { return false } + + // lane delete → **recreate the folder from the registered inverse** (13 ▸ Rules). Values, not + // references: the capture is a `SubtreeSnapshot` of bytes taken before the removal, so the + // step means the same thing any number of reloads later. + // + // Its predicate is existence and nothing else — the undo wants the paths still empty (a + // recreate refuses to clobber, so a lane somebody re-made at that id is not this step's to + // overwrite), the redo wants them back. + let steps = Array(zip(folders, captures)) + registerStep( + HistoryPhrase.name(.delete, kind: .lane, count: steps.count), + subject: lanes.count == 1 ? lanes[0].title.value : nil, + undoExpects: steps.map { .absent($0.0) }, + redoExpects: steps.map { .present($0.0) } + ) { _ in + for (folder, capture) in steps { + try BoardWriter.recreateSubtree(at: folder, from: capture, operation: .createLane) + } + } redo: { _ in + // Reversed, so a multi-lane delete unwinds in the mirror of the order it was made in. + for (folder, _) in steps.reversed() { + try BoardWriter.removeLane(at: folder) + } + } + return true + } + + /// **The trash's own Delete — permanent** (03-board-ui.md § Trash: "on a trash card, Delete + /// (⌫/⌘⌫) is permanent; in the trash it removes the folder"). + /// + /// Its own method rather than a flag on `delete(_:)` because it is a different act with a + /// different safety story: it registers **no undo step**, and `purgeIsUnrecoverable` stays `true` + /// — 13-native-undo.md ▸ Rules settles this by name ("Permanently delete (Delete Immediately, + /// Empty Trash) … the confirm *is* the safety"). A stack entry here would be a promise the + /// filesystem cannot keep. + /// + /// **The confirmation is the window's** (`TrashConfirmations`), for `deleteSelection`'s reason — + /// and it is why this seam is explicit: the alert has to be able to name what this will purge + /// before it runs. + /// + /// The selection moves to the successor sibling **within the trash**: the permanent delete is as + /// deliberate a gesture as the move-to-trash, so repeated ⌫ walks down the column exactly as it + /// walks down a lane (04-interactions.md ▸ The map). + public func deleteTrashCards(_ ids: Set) { + let paths = ItemPath.resolve(ids, in: .trash, snapshot: snapshot) + guard !paths.isEmpty else { return } + let successor = SelectionGrammar.successor( + afterDeleting: ids, + in: .trash, + snapshot: snapshot, + filter: searchFilter + ) + let root = rootURL + + try? performWrite { () throws(BoardWriteError) -> Void in + for path in paths { + try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root) + } + } + + if let successor { + select([successor], in: .trash, anchor: successor, head: successor) + } else { + clearSelection() + } + } + + /// **Delete Immediately ⌥⌘⌫ — skips the trash from anywhere** (03-board-ui.md § Trash; + /// 11-command-nexus.md: "Board window, card selection — skips the trash from anywhere"). + /// + /// The one method whose targets can be in either container, and the reason is the command's own + /// wording: from a lane it bypasses the trash the ordinary delete would have used, and from the + /// trash it is the permanent delete the card is already one keystroke from. Cards only — a lane's + /// delete is physical already, so there is nothing for "skip the trash" to mean on one. + /// + /// **It registers no undo step**, `deleteTrashCards`' ruling and its wording. + /// + /// The selection is cleared rather than walked to a successor: unlike ⌫, this is the command a + /// confirmation stands in front of, and what follows it is reading the board rather than pressing + /// the key again. + public func deleteImmediately(_ ids: Set) { + let container = selection.container + let paths = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane } + guard !paths.isEmpty else { return } + let root = rootURL + + try? performWrite { () throws(BoardWriteError) -> Void in + for path in paths { + switch path { + case .trashCard: + try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root) + default: + try BoardWriter.purgeItem(at: path.folder(under: root)) + } + } + } + clearSelection() + } + + /// **Empty Trash… ⇧⌘⌫** — purges every card in `/.trash/`, in one bracket. + /// + /// Not undoable, `deleteTrashCards`' ruling — this is the other half of 13's "Permanently delete". + /// + /// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the writer walks + /// the folder itself, never a 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, and strays a hand-editor left in the container are + /// preserved verbatim rather than swept up with the cards (`BoardWriter.emptyTrash`). + public func emptyTrash() { + guard !snapshot.trash.isEmpty else { return } + let root = rootURL + + try? performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.emptyTrash(inBoard: root) + } + if selection.container == .trash { + clearSelection() + } + } + + // MARK: - The legacy tombstone migration + + /// Migrates every legacy `deleted:` key the last applied snapshot found, and posts one notice — + /// the **act** half of 01-storage-format.md § Deletion's migration rule ("Legacy `deleted:` keys + /// migrate on load-and-write, never destroy"; the loader's `legacyTombstones` is the notice half). + /// + /// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, deliberately: same tail hook + /// on a successful reload, same lock deferral, same attempted-set loop guard, same one bracket + /// for the whole board, same warning-tone loss row. Two migrations arriving in one release with + /// two different schedulings would be two things to keep honest. + /// + /// ### The two acts + /// + /// - **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`). + /// - **A lane returns live** with the key stripped and nothing moved + /// (`BoardWriter.migrateTombstonedLane`) — "resurrection is the safe direction, nothing is + /// destroyed by migration". A tombstoned lane's own cards come back with it; any of them + /// carrying their own key migrate on their own account, as ordinary tombstoned cards, in the + /// same pass. + /// + /// ### The order among migrating cards is `deleted:`-ascending, deliberately + /// + /// Every arrival mints a rank above the current top, so the *last* card migrated ends up topmost. + /// Migrating oldest-first therefore reproduces the newest-first column the tombstone model's + /// timestamp sort used to render — the same board, read the same way, with ordinary ranks doing + /// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's + /// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's + /// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole + /// corpus already uses. `legacyTombstones` carries no timestamp of its own, so the stamps are + /// read out of the snapshot the same walk produced. + /// + /// ### The read-only lock defers it, it does not cancel it + /// + /// Exactly the relocation's posture, and stated there: a locked board returns having written + /// nothing **and having remembered nothing**, so the reload that lifts the lock is the reload + /// that performs the migration. + /// + /// ### It cannot hot-loop + /// + /// The migration's own write triggers a reload, which re-walks the tree — the loop the guard + /// exists for. After a success the walk finds no keys, `legacyTombstones` empties, and the memo + /// clears. After a *failure* it finds the same keys again, and an unguarded call would fail + /// forever at the speed of a directory walk; so an attempt is made only when the tombstone set + /// **differs from the last one attempted**. + public func migrateLegacyTombstones() { + let work = legacyTombstones + guard !work.isEmpty else { + attemptedTombstoneMigration = [] + return + } + guard readOnlyLock == nil else { + Self.logger.debug("legacy tombstone migration deferred — the board is read-only") + return + } + let signature = Self.migrationSignature(of: work) + guard signature != attemptedTombstoneMigration else { return } + attemptedTombstoneMigration = signature + + let root = rootURL + let cards = Self.migrationOrder(of: work, in: snapshot) + let lanes = work.filter { $0.kind == .lane } + + var movedCards: [String?] = [] + var returnedLanes: [String?] = [] + // The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a + // migrated card is indistinguishable on disk from one the user deletes today. + var ladder = snapshot.trash.map(\.order) + try? performWrite { () throws(BoardWriteError) -> Void in + for card in cards { + guard let cardID = card.cardID else { continue } + let rank = Ranks.insertAtHead(ofVisible: ladder) + try BoardWriter.migrateTombstonedCard( + at: ItemPath.card(lane: card.laneID, id: cardID).folder(under: root), + inBoard: root, + order: rank + ) + ladder.insert(rank, at: 0) + movedCards.append(card.title) + } + for lane in lanes { + try BoardWriter.migrateTombstonedLane(at: ItemPath.lane(lane.laneID).folder(under: root)) + returnedLanes.append(lane.title) + } + } + banners.postMigratedTombstones(cards: movedCards, lanes: returnedLanes) + } + + /// The tombstone picture as a comparable value — `relocationSignature`'s shape, for its reason: + /// the *identity* of the work is what matters, not the order the walk happened to meet it in. + nonisolated static func migrationSignature(of work: [LegacyTombstone]) -> Set { + Set(work.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "")" }) + } + + /// The `.card` tombstones in the order they should be filed into the trash — oldest `deleted:` + /// first, so the newest ends up on top (see `migrateLegacyTombstones`). + /// + /// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the + /// key rather than relied on: an unparseable or missing stamp takes `Date.distantPast` and ties + /// break on the index the loader met the card at. + nonisolated static func migrationOrder( + of work: [LegacyTombstone], + in snapshot: BoardModel + ) -> [LegacyTombstone] { + var stamps: [ItemID: Date] = [:] + for lane in snapshot.lanes { + for card in lane.cards { + if let deleted = card.deleted.value { stamps[card.id] = deleted } + } + } + return work + .enumerated() + .filter { $0.element.kind == .card } + .sorted { lhs, rhs in + let left = lhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast + let right = rhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast + return left == right ? lhs.offset < rhs.offset : left < right + } + .map(\.element) } // MARK: - Selection (delegated) @@ -3335,8 +3380,8 @@ public final class BoardStore { /// for "the lane that most recently held selection or a creation", and a *card* selection is /// its lane holding selection just as much as the lane's own header click is — so both are /// noted here, and creation notes itself in `beginPlaceholder`. - public func select(_ ids: Set, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) { - transient.select(ids, liveness: liveness, anchor: anchor, head: head) + public func select(_ ids: Set, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) { + transient.select(ids, in: container, anchor: anchor, head: head) transient.noteActiveLane(Self.lane(holding: ids, in: snapshot)) } @@ -3371,7 +3416,7 @@ public final class BoardStore { // every branch — see `SelectionGrammar.Outcome`. select( outcome.selection.ids, - liveness: outcome.selection.liveness, + in: outcome.selection.container, anchor: outcome.anchor, head: outcome.head ) @@ -3381,12 +3426,13 @@ public final class BoardStore { /// trash's own reading of the same command when the trash side is the one in play. /// /// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**, - /// the selection is on the trashed side, and it still names a row — the exact conditions under - /// which "all" could mean anything but the board. It then selects every trash row **of the - /// selection's kind**, because 04 ▸ The trash's card-entries-XOR-lane-entries rule binds a - /// wholesale selection as tightly as it binds a click. A trashed selection naming nothing (a - /// foreign Put Back, a purge) falls through to the board rather than selecting the trash - /// wholesale on a guess. + /// the selection is in the trash, and it still names a card — the exact conditions under which + /// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "with the trash + /// visible and a non-empty trash selection, Select All selects all visible trash cards; in every + /// other state, all visible live cards — the container boundary decides which 'all' is meant"). + /// A trash selection naming nothing (a foreign restore, a purge) falls through to the board + /// rather than selecting the trash wholesale on a guess. There is no kind clause any more: + /// lanes are never trashed, so every trash row is a card. /// /// The anchor — and the navigation head with it — **survives if it is still in the set** and is /// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor, @@ -3397,24 +3443,24 @@ public final class BoardStore { /// filter threads in, so this command and every ⇧-range narrow together by construction. public func selectAll() { let filter = searchFilter - if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty, - let kind = SelectionGrammar.kind(of: selection, in: snapshot) { - apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot, filter: filter)), on: .trashed) + if transient.isTrashVisible, selection.container == .trash, !selection.isEmpty, + SelectionGrammar.kind(of: selection, in: snapshot) != nil { + apply(Set(SelectionGrammar.trashCards(in: snapshot, filter: filter)), in: .trash) return } - apply(Set(SelectionGrammar.liveCards(in: snapshot, filter: filter)), on: .live) + apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board) } /// Select All's storage half: an empty universe clears rather than storing an empty set, and the /// anchor and head are kept only while they are still inside what was selected. - private func apply(_ ids: Set, on side: Liveness) { + private func apply(_ ids: Set, in container: ItemContainer) { guard !ids.isEmpty else { clearSelection() return } let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil } let head = transient.selectionHead.flatMap { ids.contains($0) ? $0 : nil } - select(ids, liveness: side, anchor: anchor, head: head) + select(ids, in: container, anchor: anchor, head: head) } /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). @@ -3425,14 +3471,15 @@ public final class BoardStore { transient.clearSelection() } - /// The lane a selection sits in, or `nil` when it names no single one — a live lane selects - /// itself; live cards select their lane, but only when they all share one (a cross-lane - /// selection has no single home to remember). + /// The lane a selection sits in, or `nil` when it names no single one — a lane selects itself; + /// cards select their lane, but only when they all share one (a cross-lane selection has no + /// single home to remember). A trash selection names no lane at all, which is exactly 04's "a + /// trash selection never anchors creation". nonisolated static func lane(holding ids: Set, in snapshot: BoardModel) -> ItemID? { guard !ids.isEmpty else { return nil } var found: ItemID? - for lane in snapshot.lanes where !lane.isDeleted { - let names = ids.contains(lane.id) || lane.cards.contains { !$0.isDeleted && ids.contains($0.id) } + for lane in snapshot.lanes { + let names = ids.contains(lane.id) || lane.cards.contains { ids.contains($0.id) } guard names else { continue } guard found == nil else { return nil } found = lane.id diff --git a/Kanban/LiveStore/ItemLocation.swift b/Kanban/LiveStore/ItemLocation.swift new file mode 100644 index 0000000..483a082 --- /dev/null +++ b/Kanban/LiveStore/ItemLocation.swift @@ -0,0 +1,171 @@ +import Foundation + +// MARK: - ItemContainer + +/// Which of the board's two **card containers** something sits in — the board's lanes, or the +/// board's `.trash/`. +/// +/// **This is the materialized trash's replacement for `Liveness`** (02-architecture.md § Changes +/// from Kanban, resettled 2026-07-28; 03-board-ui.md § Trash). Deletion is a *move* now, so there is +/// no flag to read, no ancestor to walk, and no "effective liveness" to compute: an item is in a +/// container or it is not, and which container is a fact about where its folder sits on disk. The +/// tombstone model's two-sided machinery — the absolute ancestor walk, the entry-vs-universe split, +/// kind-homogeneity inside the trash — is retired wholesale with it. +/// +/// **An item-referencing set carries one of these** (`ItemReferenceSet`), because 04-interactions.md +/// ▸ The trash keeps exactly one boundary: "a selection never mixes trash cards with board cards — a +/// single container rule replacing the old liveness law, because Delete would otherwise mean two +/// different things in one gesture (move-to-trash vs permanent)". +/// +/// **Lanes live only on the board side.** "Cards only. Lanes are never trashed" (03-board-ui.md § +/// Trash), so the trash's universe is cards and nothing else — which is why the trash needs no +/// kind axis of its own any more. +/// +/// `String`-backed and `Codable` because the clipboard manifest carries one: a manifest written +/// before a quit is decoded after the relaunch, so these raw spellings are pasteboard API, and they +/// are the case names so nothing has to remember a second vocabulary. +public enum ItemContainer: String, Codable, Sendable, Equatable, CaseIterable { + + /// The board proper — every lane, and every card inside a lane. + case board + + /// `/.trash/` — the reserved container deletion moves cards into. + case trash +} + +extension ItemContainer { + + /// Every id `snapshot` holds in this container — **the universe** every item-referencing set is + /// held to (02-architecture.md § Live-reload resilience: "re-resolution matches UUID *and* + /// container side ... presence in the snapshot is the whole question"). + /// + /// One walk, no filtering: the board side is the lanes plus their cards, the trash side is + /// `snapshot.trash`. There is deliberately no liveness predicate anywhere in here — a legacy + /// `deleted:` key still riding in from an unmigrated board (`BoardLoader`'s migration window) + /// names an ordinary board card until its folder actually moves, which is the safe direction and + /// the one the migration then takes (01-storage-format.md § Deletion). + public func ids(in snapshot: BoardModel) -> Set { + var universe: Set = [] + switch self { + case .board: + for lane in snapshot.lanes { + universe.insert(lane.id) + for card in lane.cards { + universe.insert(card.id) + } + } + case .trash: + for card in snapshot.trash { + universe.insert(card.id) + } + } + return universe + } +} + +// MARK: - ItemPath + +/// Where an item's folder sits under a board root, as identity components rather than as a URL. +/// +/// **Components, not a URL**, for the reason every path-shaped value in this app is: 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). +/// +/// **Three cases, because the board has exactly three places an identity-bearing folder can be** — +/// `/`, `//`, and `/.trash/`. The old two-optional-fields +/// shape could spell a fourth thing that does not exist; this cannot. +public enum ItemPath: Sendable, Equatable { + + /// A lane: `//`. + case lane(ItemID) + + /// A card in a lane: `///`. + case card(lane: ItemID, id: ItemID) + + /// A card in the board's trash: `/.trash//`. + case trashCard(ItemID) + + /// The item this path names. + public var id: ItemID { + switch self { + case let .lane(id): id + case let .card(_, id): id + case let .trashCard(id): id + } + } + + public var isLane: Bool { + if case .lane = self { return true } + return false + } + + /// Which container this path is in — the board for a lane or a lane's card, the trash for a + /// trash card. Derived rather than stored: the case *is* the answer. + public var container: ItemContainer { + if case .trashCard = self { return .trash } + return .board + } + + /// This path resolved under a board root. + public func folder(under root: URL) -> URL { + switch self { + case let .lane(id): + root.appendingPathComponent(id.rawValue, isDirectory: true) + case let .card(lane, id): + root + .appendingPathComponent(lane.rawValue, isDirectory: true) + .appendingPathComponent(id.rawValue, isDirectory: true) + case let .trashCard(id): + BoardWriter.trashFolder(inBoard: root) + .appendingPathComponent(id.rawValue, isDirectory: true) + } + } +} + +extension ItemPath { + + /// The folders `ids` names inside one container, **in display order**. + /// + /// Display order — lanes left to right, each lane then its cards; the trash top to bottom — + /// 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). + /// + /// Ids the container does not hold are simply absent, which is every caller's standing posture: + /// a selection the next reload will drop writes nothing rather than being refused. + public static func resolve( + _ ids: Set, + in container: ItemContainer, + snapshot: BoardModel + ) -> [ItemPath] { + guard !ids.isEmpty else { return [] } + var result: [ItemPath] = [] + switch container { + case .board: + for lane in snapshot.lanes { + if ids.contains(lane.id) { result.append(.lane(lane.id)) } + for card in lane.cards where ids.contains(card.id) { + result.append(.card(lane: lane.id, id: card.id)) + } + } + case .trash: + for card in snapshot.trash where ids.contains(card.id) { + result.append(.trashCard(card.id)) + } + } + return result + } + + /// Where one id lives, searching both containers — `nil` when the snapshot does not hold it. + /// + /// The board is searched first because that is where the overwhelming majority of lookups land; + /// an id can only be in one container anyway (board-wide uniqueness spans both — + /// 01-storage-format.md § Fractal layout ▸ Rules). + public static func of(_ id: ItemID, in snapshot: BoardModel) -> ItemPath? { + for lane in snapshot.lanes { + if lane.id == id { return .lane(id) } + if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) } + } + return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil + } +} diff --git a/Kanban/LiveStore/SearchFilter.swift b/Kanban/LiveStore/SearchFilter.swift index 7059563..c820b1b 100644 --- a/Kanban/LiveStore/SearchFilter.swift +++ b/Kanban/LiveStore/SearchFilter.swift @@ -21,7 +21,7 @@ import Foundation /// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee, /// ranges, arrow nav, and lane count badges all read it." They read it *here* — the masonry through /// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the -/// trash through `TrashLaneView.entries`, and the selection through +/// trash through `SelectionGrammar.trashCards`, and the selection through /// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does /// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2). /// @@ -29,9 +29,9 @@ import Foundation /// /// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the /// query stays on the board showing an empty body and a `0` badge, because the width division is -/// layout and the filter is content. `matches(_: Lane)` exists only for the trash, whose rows are -/// tombstoned lanes as often as they are cards and which filter "like any lane" by their own -/// title + body (03-board-ui.md § Trash). +/// layout and the filter is content. There is no lane overload at all: the tombstone model's trash +/// held lane *entries* that had to be filtered like rows, and lanes are never trashed now +/// (03-board-ui.md § Trash), so a card predicate is the whole of the filter. public struct SearchFilter: Sendable, Equatable { /// The query exactly as typed — kept so a caller can key a transaction or a test on it. @@ -78,54 +78,32 @@ public struct SearchFilter: Sendable, Equatable { matches(title: card.title.value, body: card.body) } - /// A lane, by its own title and description — the trash's lane entries, and nothing on the board - /// itself (see the type's doc comment). - public func matches(_ lane: Lane) -> Bool { - matches(title: lane.title.value, body: lane.body) - } - - /// A trash row, **by its own title and body**, whichever kind it is: "shown, it participates in - /// the filter like any lane" (03-board-ui.md § Trash), and a lane entry is a row like a card row. - /// - /// A lane entry is deliberately *not* matched through its cards: the entry is one restorable - /// thing, and a lane surfacing because a card buried inside it matched would be a row the user - /// cannot act on the way the match suggests. - public func matches(_ entry: TrashEntry) -> Bool { - switch entry { - case let .card(card, _): matches(card) - case let .lane(lane, _): matches(lane) - } - } - // MARK: - The visible universe - /// Every id the filter leaves visible on `side` — **the universe + /// Every id the filter leaves visible in `container` — **the universe /// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection" /// (`TransientBoardState.constrainToSearch(in:)`). /// - /// It is shaped exactly like `ItemReferenceSet.idUniverse(of:on:)` and means the same thing one - /// step narrower: that one answers "what does the board *have*", this one "what does the board - /// *show*". Two differences, both stated above and neither incidental: - /// - /// - **Live lanes are all in it.** The filter hides cards, so a lane is visible whatever its - /// cards do — a lane selection survives a query that empties its body. - /// - **The trashed side is the trash's rows**, filtered — `TrashModel.entries`' absolute - /// ancestor walk, which already excludes the cards a tombstoned lane subsumes. Those have no - /// row, so they are visible to nobody and belong in no universe a selection is held to. - public func visibleIDs(in snapshot: BoardModel, on side: Liveness) -> Set { - switch side { - case .live: - var ids: Set = [] - for lane in snapshot.lanes where !lane.isDeleted { + /// It is shaped exactly like `ItemContainer.ids(in:)` and means the same thing one step + /// narrower: that one answers "what does the board *have*", this one "what does the board + /// *show*". The one difference is stated above and is not incidental: **every lane is in it**, + /// because the filter hides cards, so a lane selection survives a query that empties its body. + public func visibleIDs(in snapshot: BoardModel, container: ItemContainer) -> Set { + var ids: Set = [] + switch container { + case .board: + for lane in snapshot.lanes { ids.insert(lane.id) - for card in lane.cards where !card.isDeleted && matches(card) { + for card in lane.cards where matches(card) { ids.insert(card.id) } } - return ids - case .trashed: - return Set(TrashModel.entries(of: snapshot).lazy.filter { matches($0) }.map(\.id)) + case .trash: + for card in snapshot.trash where matches(card) { + ids.insert(card.id) + } } + return ids } // MARK: - Folding diff --git a/Kanban/LiveStore/SelectionGrammar.swift b/Kanban/LiveStore/SelectionGrammar.swift index 0dbd64a..2f83a31 100644 --- a/Kanban/LiveStore/SelectionGrammar.swift +++ b/Kanban/LiveStore/SelectionGrammar.swift @@ -21,22 +21,22 @@ public enum SelectionKind: String, Codable, Sendable, Equatable { case lane } -/// What a pointer click names: an item, its level, and the side of the live/trash boundary the -/// surface it was clicked on sits on. +/// What a pointer click names: an item, its level, and the container the surface it was clicked on +/// belongs to. /// -/// The **side is the surface's, not the item's** — a card face is always `.live` and a trash row is -/// always `.trashed`, because that is what the user clicked. A click on a surface whose item flipped -/// liveness a moment ago simply selects nothing the next reload will keep, which is the ordinary -/// vanish rule and not a case for this type to model. +/// The **container is the surface's, not the item's** — a card face is always `.board` and a trash +/// row is always `.trash`, because that is what the user clicked. A click on a surface whose item +/// crossed containers a moment ago simply selects nothing the next reload will keep, which is the +/// ordinary vanish rule and not a case for this type to model. public struct SelectionTarget: Sendable, Equatable { public var id: ItemID public var kind: SelectionKind - public var side: Liveness + public var container: ItemContainer - public init(id: ItemID, kind: SelectionKind, side: Liveness) { + public init(id: ItemID, kind: SelectionKind, container: ItemContainer) { self.id = id self.kind = kind - self.side = side + self.container = container } } @@ -59,15 +59,16 @@ public enum ClickModifier: Sendable, Equatable { /// (`SelectionGrammarTests`). /// /// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is -/// homogeneous on three axes at once — cards XOR lanes (§ Selection), live XOR tombstoned, and -/// within the trash card entries XOR lane entries (§ The trash) — and every one of them is a -/// property of what a *click* is allowed to produce. So no outcome below is ever mixed: a modifier -/// that would cross an axis degrades to a replace, which is the only answer that keeps the -/// invariant true without silently dropping what the user asked for. +/// homogeneous on **two** axes now — cards XOR lanes (§ Selection) and board XOR trash (§ The +/// trash's "single container rule replacing the old liveness law") — and the third, kind-inside-the +/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed". +/// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below +/// is ever mixed: a modifier that would cross an axis degrades to a replace, which is the only +/// answer that keeps the invariant true without silently dropping what the user asked for. /// /// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to /// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space, -/// trash row) share one answer instead of four near-copies of it. +/// trash card) share one answer instead of four near-copies of it. public enum SelectionGrammar { /// What a click leaves behind: the new selection, the anchor a subsequent ⇧-click would range @@ -100,7 +101,7 @@ public enum SelectionGrammar { /// The grammar, one call. /// /// - Parameters: - /// - target: what was clicked, with the surface's liveness side (see `SelectionTarget`). + /// - target: what was clicked, with the surface's container (see `SelectionTarget`). /// - modifier: the effective modifier, already reduced to one of three (`ClickModifier`). /// - selection: the board's current selection. /// - anchor: the range origin — `TransientBoardState.selectionAnchor`. @@ -145,18 +146,18 @@ public enum SelectionGrammar { selection: ItemReferenceSet, togglesOnRepeat: Bool ) -> Outcome { - if togglesOnRepeat, selection.liveness == target.side, selection.ids == [target.id] { + if togglesOnRepeat, selection.container == target.container, selection.ids == [target.id] { return .cleared } return Outcome( - selection: ItemReferenceSet(ids: [target.id], liveness: target.side), + selection: ItemReferenceSet(ids: [target.id], container: target.container), anchor: target.id, head: target.id ) } /// **⌘-click toggles** — but only *within* a homogeneous set. Crossing either axis (a card - /// clicked while lanes are selected, a trash row clicked while live cards are) is not a mixed + /// clicked while lanes are selected, a trash card clicked while board cards are) is not a mixed /// selection and not a refusal: it is a **replace**, the same outcome a plain click would give, /// because the click unambiguously names a new set of one. /// @@ -168,7 +169,7 @@ public enum SelectionGrammar { selection: ItemReferenceSet, snapshot: BoardModel ) -> Outcome { - guard selection.liveness == target.side, + guard selection.container == target.container, let current = kind(of: selection, in: snapshot), current == target.kind else { @@ -183,7 +184,7 @@ public enum SelectionGrammar { return .cleared } return Outcome( - selection: ItemReferenceSet(ids: ids, liveness: target.side), + selection: ItemReferenceSet(ids: ids, container: target.container), anchor: target.id, head: target.id ) @@ -195,7 +196,7 @@ public enum SelectionGrammar { /// /// The anchor is valid **iff both it and the target sit in the same order list** — which folds /// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is - /// exactly one (side, kind) pair. An invalid anchor makes the click a plain one, never a no-op: + /// exactly one (container, kind) pair. An invalid anchor makes the click a plain one, never a no-op: /// the keyboard's ⇧-arrow goes inert at a boundary because its next step is ambiguous, while a /// click names an unambiguous target and so always has something to do. private static func shift( @@ -210,15 +211,15 @@ public enum SelectionGrammar { from: anchor, to: target.id, kind: target.kind, - on: target.side, - in: snapshot, + in: target.container, + snapshot: snapshot, filter: filter ) else { return plain(target, selection: selection, togglesOnRepeat: false) } return Outcome( - selection: ItemReferenceSet(ids: span, liveness: target.side), + selection: ItemReferenceSet(ids: span, container: target.container), anchor: anchor, head: target.id ) @@ -234,8 +235,8 @@ public enum SelectionGrammar { /// /// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil /// anchor's caller-side absence, and every axis crossing into one test — a list is exactly one - /// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain - /// click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is + /// (container, kind) pair. The callers differ on what they do with that: a click degrades to a + /// plain click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is /// ambiguous). /// /// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search @@ -246,44 +247,47 @@ public enum SelectionGrammar { from: ItemID, to: ItemID, kind: SelectionKind, - on side: Liveness, - in snapshot: BoardModel, + in container: ItemContainer, + snapshot: BoardModel, filter: SearchFilter = .inactive ) -> Set? { - let list = order(of: kind, on: side, in: snapshot, filter: filter) + let list = order(of: kind, in: container, snapshot: snapshot, filter: filter) guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil } return Set(start <= end ? list[start...end] : list[end...start]) } // MARK: - The order lists - /// The list a ⇧-range walks for one (side, kind) pair — **the single place a "what's on the + /// The list a ⇧-range walks for one (container, kind) pair — **the single place a "what's on the /// board, in what order" question is answered** for the pointer. /// /// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere /// else** — the filter "is the single source of truth for what's on the board … ranges … all /// read it" (04-interactions.md § Search), and every range, every Select All and every arrow - /// walk is stated in terms of these four lists, so one parameter narrows all of them together. + /// walk is stated in terms of these lists, so one parameter narrows all of them together. /// /// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten /// order, a lane-index lookup, the successor's container) read exactly as they did before the /// filter existed; the callers that *are* the board's input grammar pass the store's query. /// /// **The lane list takes no filter**, because a card query hides no lane — see `SearchFilter`. + /// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed" + /// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to. public static func order( of kind: SelectionKind, - on side: Liveness, - in snapshot: BoardModel, + in container: ItemContainer, + snapshot: BoardModel, filter: SearchFilter = .inactive ) -> [ItemID] { - switch (side, kind) { - case (.live, .card): liveCards(in: snapshot, filter: filter) - case (.live, .lane): liveLanes(in: snapshot) - case (.trashed, _): trashEntries(of: kind, in: snapshot, filter: filter) + switch (container, kind) { + case (.board, .card): boardCards(in: snapshot, filter: filter) + case (.board, .lane): lanes(in: snapshot) + case (.trash, .card): trashCards(in: snapshot, filter: filter) + case (.trash, .lane): [] } } - /// Live cards in **flatten order** — "lane `order` first, then card `order` (a cross-lane + /// The board's cards in **flatten order** — "lane `order` first, then card `order` (a cross-lane /// selection flattens left-to-right, top-to-bottom)", the multi-drag order the ⌘N target rule /// and paste anchoring already share (04-interactions.md ▸ Drag and drop, ▸ The map). /// @@ -293,85 +297,65 @@ public enum SelectionGrammar { /// **The filter narrows the walk in place**, which is what makes a search-time ⇧-range and /// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same /// predicate to the same cards, one lane at a time, and this is that collection flattened. - public static func liveCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { + public static func boardCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { var ids: [ItemID] = [] - for lane in snapshot.lanes where !lane.isDeleted { - for card in lane.cards where !card.isDeleted && filter.matches(card) { + for lane in snapshot.lanes { + for card in lane.cards where filter.matches(card) { ids.append(card.id) } } return ids } - /// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md § - /// Trash collapses each into one entry), so they are absent from the live lane order entirely. + /// The board's lanes, left to right. /// /// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the /// query empties is still a lane on the board — the width division is layout, and the badge /// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one /// part of the board grammar a search does not narrow. - public static func liveLanes(in snapshot: BoardModel) -> [ItemID] { - snapshot.lanes.filter { !$0.isDeleted }.map(\.id) + public static func lanes(in snapshot: BoardModel) -> [ItemID] { + snapshot.lanes.map(\.id) } - /// One kind of trash row, in the quasi-lane's own deterministic order (`TrashModel.entries`, - /// whose sort is "load-bearing for input — arrow walks, ⇧-ranges, and the rubber band all read - /// it"). + /// The trash's cards, top to bottom — `snapshot.trash` itself, which the loader already sorted + /// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like + /// any lane", newest-first falling out of the ranks rather than a timestamp sort). /// - /// **Filtered to one kind, so a range skips what it cannot include.** Card and lane entries - /// interleave in one ordering, and "a selection never mixes card entries and lane entries" - /// (04-interactions.md ▸ The trash), so a ⇧-range between two card rows spans the sorted order - /// and collects only the card rows — stepping over any lane row that sits between them. That is - /// the deliberate pointer twin of the keyboard's rule: a ⇧-arrow onto a lane entry is *inert* - /// because its next step is ambiguous, while a click names an unambiguous same-kind target and - /// so the range simply skips. - /// - /// **Filtered like any lane** (03-board-ui.md § Trash) — the same predicate `TrashLaneView` - /// applies to the same rows, so a trash-side range walks exactly what the column is showing. - public static func trashEntries( - of kind: SelectionKind, - in snapshot: BoardModel, - filter: SearchFilter = .inactive - ) -> [ItemID] { - TrashModel.entries(of: snapshot) - .filter { $0.isLaneEntry == (kind == .lane) && filter.matches($0) } - .map(\.id) + /// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the + /// filter exactly like any other card") — the same predicate `TrashLaneView` applies to the same + /// cards, so a trash-side range walks exactly what the column is showing. + public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { + snapshot.trash.filter { filter.matches($0) }.map(\.id) } // MARK: - The current selection's kind - /// Which level the selection holds, or `nil` when it holds nothing the board renders on its own - /// side. + /// Which level the selection holds, or `nil` when it holds nothing its container renders. /// /// **Any member answers, because the set is homogeneous** — but the walk is the snapshot's order /// rather than the set's iteration order, so the answer is deterministic even for a set that /// somehow was not. Members that name nothing are ignored, and a set of only such members reads /// as empty: a selection the next reload will drop must not decide what a click does now. /// - /// The membership rules are exactly the order lists': on the live side an item counts when its - /// effective liveness is live, and on the trashed side only **rows** count — a card under a - /// tombstoned lane has no row of its own (`TrashModel.entries`' absolute ancestor walk), so it - /// is nobody's kind. + /// **The trash answers `.card` or nothing**, because lanes are never trashed — which is why the + /// trash's old kind axis (card entries XOR lane entries) has no code left anywhere. public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? { guard !selection.isEmpty else { return nil } - for lane in snapshot.lanes { - if Liveness(isDeleted: lane.isDeleted) == selection.liveness, selection.ids.contains(lane.id) { - return .lane - } - // A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live - // and have no trash row of their own. - guard !lane.isDeleted else { continue } - for card in lane.cards - where Liveness(isDeleted: card.isDeleted) == selection.liveness && selection.ids.contains(card.id) { - return .card + switch selection.container { + case .trash: + return snapshot.trash.contains { selection.ids.contains($0.id) } ? .card : nil + case .board: + for lane in snapshot.lanes { + if selection.ids.contains(lane.id) { return .lane } + if lane.cards.contains(where: { selection.ids.contains($0.id) }) { return .card } } + return nil } - return nil } // MARK: - Successor on delete - /// What ⌫ selects after tombstoning `ids` — 04-interactions.md ▸ The map's Finder-style + /// What ⌫ selects after deleting `ids` — 04-interactions.md ▸ The map's Finder-style /// successor sibling, as a pure function of the **pre-write** snapshot. /// /// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the @@ -389,49 +373,56 @@ public enum SelectionGrammar { /// - **`nil` is a legitimate answer** — an emptied container selects nothing, and the caller /// clears. /// + /// **Both stagings of Delete get one** (04, resettled 2026-07-28 — "one Delete vocabulary, + /// staged by place"): `container` says which side the gesture ran on, and the trash walks its own + /// ordered cards exactly as a lane walks its own. The permanent delete is as deliberate an act as + /// the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for. + /// /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's /// reload-survival rule: "the selection just shrinks"), which is why this is called by - /// `BoardStore.delete` and by nothing on the reload path. + /// `BoardStore`'s delete paths and by nothing on the reload path. /// - /// **The container is what the lane is *showing*.** Under a search the successor must be a card - /// the user can see — "nothing invisible stays selected" is the trash's phrasing of a rule the - /// filter obeys too — and picking a hidden neighbour would hand the selection straight back to + /// **The container is what the surface is *showing*.** Under a search the successor must be a + /// card the user can see — picking a hidden neighbour would hand the selection straight back to /// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the /// filter narrows the container, and repeated ⌫ walks down the *filtered* lane. public static func successor( afterDeleting ids: Set, - in snapshot: BoardModel, + in container: ItemContainer = .board, + snapshot: BoardModel, filter: SearchFilter = .inactive ) -> ItemID? { guard !ids.isEmpty else { return nil } - let selection = ItemReferenceSet(ids: ids, liveness: .live) + let selection = ItemReferenceSet(ids: ids, container: container) guard let kind = kind(of: selection, in: snapshot) else { return nil } - let container: [ItemID] - switch kind { - case .lane: - container = liveLanes(in: snapshot) - case .card: + let siblings: [ItemID] + switch (container, kind) { + case (.trash, _): + siblings = trashCards(in: snapshot, filter: filter) + case (.board, .lane): + siblings = lanes(in: snapshot) + case (.board, .card): // The last selected card in flatten order names the lane; its lane's rendered cards are // the container the successor is drawn from. - guard let last = liveCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }), + guard let last = boardCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }), let lane = snapshot.lanes.first(where: { lane in - !lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted } + lane.cards.contains { $0.id == last } }) else { return nil } - container = lane.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id) + siblings = lane.cards.filter { filter.matches($0) }.map(\.id) } - let doomed = container.indices.filter { ids.contains(container[$0]) } + let doomed = siblings.indices.filter { ids.contains(siblings[$0]) } guard let first = doomed.first, let last = doomed.last else { return nil } - if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after } - return container[.. Set { - let hits = targets.filter { $0.side == side && rect.intersects($0.frame) } - guard !hits.isEmpty else { return [] } - - switch side { - case .live: - return Set(hits.lazy.filter { $0.kind == .card }.map(\.id)) - case .trashed: - guard let topmost = hits.min(by: isAbove) else { return [] } - return Set(hits.lazy.filter { $0.kind == topmost.kind }.map(\.id)) - } + public static func selection( + rect: CGRect, + targets: [MarqueeTarget], + in container: ItemContainer + ) -> Set { + Set( + targets.lazy + .filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) } + .map(\.id) + ) } /// Which of two drawn rows is "higher" — top edge, then leading edge, then identity. /// /// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order - /// the same way twice, or the topmost-kind rule would pick differently on identical input. + /// the same way twice. /// - /// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two - /// candidates that a metric cannot separate must still be separated the same way twice. + /// Used by `NavigationMath`, which breaks its score ties with it: two candidates that a metric + /// cannot separate must still be separated the same way twice. static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool { if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY } if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX } diff --git a/Kanban/LiveStore/StyleModel.swift b/Kanban/LiveStore/StyleModel.swift index 1e513eb..b9487bb 100644 --- a/Kanban/LiveStore/StyleModel.swift +++ b/Kanban/LiveStore/StyleModel.swift @@ -154,7 +154,7 @@ public struct StyleEditorSession: Sendable, Equatable { case .board: return self case let .items(ids): - let live = ItemReferenceSet(ids: ids, liveness: .live).resolved(against: snapshot).ids + let live = ItemReferenceSet(ids: ids, container: .board).resolved(against: snapshot).ids guard !live.isEmpty else { return nil } return live == ids ? self : StyleEditorSession(target: .items(live)) } @@ -170,9 +170,9 @@ public struct StyleEditorSession: Sendable, Equatable { /// the popover lands on the first thing the user's eye would find. public func presentationAnchor(in snapshot: BoardModel) -> ItemID? { guard case let .items(ids) = target else { return nil } - for lane in snapshot.lanes where !lane.isDeleted { + for lane in snapshot.lanes { if ids.contains(lane.id) { return lane.id } - if let card = lane.cards.first(where: { !$0.isDeleted && ids.contains($0.id) }) { return card.id } + if let card = lane.cards.first(where: { ids.contains($0.id) }) { return card.id } } return nil } diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index b2ae2df..07a3ba0 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -1,82 +1,9 @@ import Foundation import Observation -// MARK: - Liveness - -/// Which side of the live/tombstoned boundary something sits on. -/// -/// An item-referencing set is **homogeneous by liveness** (04-interactions.md § The trash): it never -/// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of -/// each member — which is exactly what makes re-resolution across a reload a matching rule rather -/// than a partition. -/// -/// **`String`-backed and `Codable` because the clipboard manifest carries one** (04-interactions.md -/// ▸ Clipboard: a manifest records its entries' "source side (live/trashed)"). The raw spellings are -/// therefore pasteboard API — a manifest written before a quit is decoded after the relaunch — and -/// they are the case names so nothing has to remember a second vocabulary. -public enum Liveness: String, Codable, Sendable, Equatable { - case live - case trashed - - /// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are - /// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed — see - /// their doc comments in `BoardModel.swift`. - init(isDeleted: Bool) { - self = isDeleted ? .trashed : .live - } -} - -// MARK: - The one definition of a side - -extension Liveness { - - /// Every item `snapshot` has on this side, visited in **board order** — lanes left to right, - /// each lane immediately before its own cards — as the lane it lives under and, for a card, the - /// card itself. - /// - /// **This is the definition, and it is the only one** (02-architecture.md § Changes from Kanban, - /// settled: "universe and rows are one function, never a broader set with a pointer-side - /// subset"). Everything that asks what is on a side is this walk with a different accumulator: - /// `ItemReferenceSet.idUniverse` collects ids, `TrashModel.entries` builds the trash's rows, - /// `TrashModel.paths` and `emptyTrashTargets` build folders. Two spellings of the rule would be - /// two things to keep in step, and the one they would eventually disagree about is precisely the - /// item below. - /// - /// **The whole rule is the `continue`: a tombstoned lane subsumes its subtree.** It contributes - /// one item to the trashed side — its own row — and its cards contribute nothing to *either* - /// side, whatever their own flags say. That is 03-board-ui.md § Trash's absolute ancestor walk - /// stated as code: "a card that carries its own `deleted:` under a tombstoned lane has **no row - /// of its own**". - /// - /// **So the two sides do not partition the board, and that is the point.** A card beneath a - /// tombstoned lane is in *neither* universe, because it renders nowhere — no row, no membership. - /// A selection, a drag, a pending cut, a range anchor or a navigation head can therefore never - /// survive a reload sitting on something no surface would draw, and 04-interactions.md § Search's - /// hidden-cards-leave-the-selection rule and 02's reload-survival rule stay one rule rather than - /// two that happen to agree. - /// - /// Non-escaping and accumulator-driven rather than array-returning: the callers below run on - /// every reload and on every menu validation, and none of them wants a board-sized copy of the - /// model to throw away. - func walk(_ snapshot: BoardModel, visiting visit: (Lane, Card?) -> Void) { - for lane in snapshot.lanes { - if lane.isDeleted { - // The subsumption, both halves of it: the lane is a trash row, and its cards are - // nobody's — so the loop below never runs for them. - if self == .trashed { visit(lane, nil) } - continue - } - if self == .live { visit(lane, nil) } - for card in lane.cards where Liveness(isDeleted: card.isDeleted) == self { - visit(lane, card) - } - } - } -} - // MARK: - ItemReferenceSet -/// A set of UUIDs over the snapshot plus the liveness side it lives on — **the one shape every +/// A set of UUIDs over the snapshot plus the container it lives in — **the one shape every /// piece of transient state that points at items wears**: the selection, drag membership, and the /// pending cut are three values of this type, not three hand-rolled near-copies /// (02-architecture.md § Live-reload resilience, "Selection — and every transient state that @@ -92,46 +19,52 @@ extension Liveness { /// and everything else here is that primitive with a universe supplied. Only the universe differs /// between the two callers: /// -/// - **Reload survival**: the universe is the new snapshot's ids on this set's liveness side, which +/// - **Reload survival**: the universe is the new snapshot's ids in this set's container, which /// is what `resolved(against:)` computes before delegating. /// - **The live search filter**: the universe is the visible ids the predicate produced, so /// 04-interactions.md § Search's "hidden cards leave the selection" needs no second rule — it is -/// this one, with a different universe (m5 wires that caller). +/// this one, with a different universe. /// /// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and /// storing the result belongs to `TransientBoardState` — a set that filtered itself would need to /// know about snapshots, and the whole point of the value-type snapshot is that nothing has to. public struct ItemReferenceSet: Sendable, Equatable { public var ids: Set - public var liveness: Liveness - public init(ids: Set = [], liveness: Liveness = .live) { + /// Which container the members live in — the board, or `.trash/` (`ItemContainer`). + /// + /// A property of the set as a whole rather than of each member, because 04-interactions.md ▸ The + /// trash keeps exactly one boundary: "a selection never mixes trash cards with board cards". + /// That is what makes re-resolution across a reload a matching rule rather than a partition. + public var container: ItemContainer + + public init(ids: Set = [], container: ItemContainer = .board) { self.ids = ids - self.liveness = liveness + self.container = container } - /// Nothing referenced, on the live side — the state a board opens in, the state a drag with + /// Nothing referenced, on the board side — the state a board opens in, the state a drag with /// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to. public static let empty = ItemReferenceSet() public var isEmpty: Bool { ids.isEmpty } - /// This set narrowed to `universe`: members that are in it, **side unchanged**. + /// This set narrowed to `universe`: members that are in it, **container unchanged**. /// /// The primitive both directions are built from — intersection and nothing else. It is - /// deliberately ignorant of what a universe *is*: a snapshot's ids on one liveness side + /// deliberately ignorant of what a universe *is*: a snapshot's ids in one container /// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as /// the rule is concerned, which is what lets one rule be stated once and mean both. /// - /// The liveness side survives even when the membership does not: an emptied set is still a set - /// on a side, and re-populating it (a fresh click, a new drag) is the caller's business. + /// The container survives even when the membership does not: an emptied set is still a set in a + /// container, and re-populating it (a fresh click, a new drag) is the caller's business. public func constrained(to universe: Set) -> ItemReferenceSet { guard !ids.isEmpty else { return self } - return ItemReferenceSet(ids: ids.intersection(universe), liveness: liveness) + return ItemReferenceSet(ids: ids.intersection(universe), container: container) } - /// This set re-grounded on `snapshot`: the members that are still there, **on the same liveness - /// side**, and nothing else. + /// This set re-grounded on `snapshot`: the members that are still there, **in the same + /// container**, and nothing else. /// /// Two rules, both settled in 02-architecture.md § Live-reload resilience: /// @@ -139,35 +72,19 @@ public struct ItemReferenceSet: Sendable, Equatable { /// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different: /// ⌫ selects the successor sibling, because that is an act rather than a surprise — /// 04-interactions.md ▸ The map. That belongs to the delete command, not here.) - /// - **A liveness flip is a vanish.** A foreign edit that tombstones a selected live card — or - /// restores a selected tombstoned one — ejects it, keeping 04-interactions.md's - /// homogeneous-by-liveness invariant true across reloads so menu validation never sees a - /// mixed selection. The pending cut inherits the same rule for free (04 ▸ Clipboard: "a cut - /// item that is tombstoned or vanishes externally before paste drops out of the pending - /// cut"), and so does drag membership (04 ▸ Drag and drop's emptied-drag rule). + /// - **A container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a + /// foreign move that trashes a selected board card — or restores a selected trash card — + /// ejects it from the selection (and from the pending cut)", keeping 04's container-boundary + /// invariant true across reloads so menu validation never sees a mixed selection. Drag + /// membership inherits it for free (04 ▸ Drag and drop's emptied-drag rule). /// - /// The liveness that is matched is **effective — ancestor-walked** (settled), and the trashed - /// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read. - /// Tombstoning a lane therefore ejects its cards from a live set even though their own flags - /// never changed — and does **not** hand them to a trashed set, because the lane's single entry - /// subsumes them (03-board-ui.md). A card under a tombstoned lane renders nowhere on either - /// side, and nothing invisible may stay selected, drag-included, or pending-cut. + /// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to + /// compute — "the old effective-liveness ancestor walk is retired with the tombstone model" + /// (02-architecture.md) — because a deleted card's folder has actually moved, and a folder is + /// either in the container or it is not. public func resolved(against snapshot: BoardModel) -> ItemReferenceSet { guard !ids.isEmpty else { return self } - return constrained(to: Self.idUniverse(of: snapshot, on: liveness)) - } - - /// Every id in `snapshot` on `side` — the reload direction's universe. - /// - /// One line over `Liveness.walk`, which is where the rule itself lives and is stated: the live - /// side is the live lanes and their unflagged cards, and the trashed side is *exactly* the trash's - /// rows — tombstoned lanes, plus cards carrying their own `deleted:` under a live lane. Nothing - /// else is in either, so a card hidden beneath a tombstoned lane belongs to no universe and no - /// set may go on referencing it. - static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set { - var universe: Set = [] - side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) } - return universe + return constrained(to: container.ids(in: snapshot)) } } @@ -442,8 +359,8 @@ public final class TransientBoardState { // MARK: Per-open values /// The lane that most recently held selection or a creation **in this window session** — - /// 04-interactions.md's ⌘N target rule's fallback when nothing (or a tombstoned something) is - /// selected, before the last resort of the first lane. + /// 04-interactions.md's ⌘N target rule's fallback when nothing (or a trash selection, which + /// never anchors creation) is selected, before the last resort of the first lane. /// /// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing /// in the snapshot that could reconstruct which lane the user was last working in, which is @@ -454,7 +371,7 @@ public final class TransientBoardState { /// is no target at all; `NewCardTarget` then falls through to the first lane. public private(set) var lastActiveLaneID: ItemID? - /// Whether the trash quasi-lane is showing (03-board-ui.md ▸ Trash). + /// Whether the trash column is showing (03-board-ui.md ▸ Trash). /// /// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout /// choice, so it does not belong in the board registry beside window frames @@ -480,8 +397,8 @@ public final class TransientBoardState { /// /// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and /// `resolve(against:)` on the next reload is what keeps the set honest over time. - public func select(_ ids: Set, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) { - selection = ItemReferenceSet(ids: ids, liveness: liveness) + public func select(_ ids: Set, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) { + selection = ItemReferenceSet(ids: ids, container: container) let sole = ids.count == 1 ? ids.first : nil selectionAnchor = anchor ?? sole selectionHead = head ?? sole @@ -666,16 +583,15 @@ public final class TransientBoardState { /// /// **Every item-referencing set is resolved independently.** They are re-grounded against the /// same snapshot but never against each other: a card leaving the selection must not disturb a - /// drag in flight or a pending cut that also held it, and each set carries its own liveness - /// side. Independence is what makes that a property of the code rather than of the order the - /// lines happen to be in. + /// drag in flight or a pending cut that also held it, and each set carries its own container. + /// Independence is what makes that a property of the code rather than of the order the lines + /// happen to be in. /// /// **The placeholder has its own two rules**, because it references a lane rather than items: /// - /// - **Discarded when its anchor lane is gone** — absent from the snapshot, or effectively - /// tombstoned. A tombstoned lane renders nowhere (03-board-ui.md collapses it to a single - /// trash entry), so its lane "vanished" in every sense 02-architecture.md means: "if the - /// placeholder's lane vanished in the reload, it is discarded". + /// - **Discarded when its anchor lane is gone** — absent from the snapshot. "If the + /// placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane + /// delete is physical now, so gone is the only way a lane goes. /// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the /// snapshot. The real card arrived; the overlay's whole job was covering the gap between the /// Writer's create and the watcher's round trip, and holding it a moment longer would draw the @@ -687,25 +603,25 @@ public final class TransientBoardState { /// /// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸ /// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target - /// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently. - /// A foreign *move* is deliberately not a vanish — the editor follows the UUID and the commit - /// writes wherever the item now lives — which falls out for free from matching on identity - /// rather than on position. Liveness is **effective**, so a card under a lane an agent just - /// tombstoned vanishes with it. + /// that is trashed, deleted, or gone discards the editor and its keystrokes silently — + /// "entering the trash is a vanish from the board; nothing is ever written into a vanished + /// folder". A foreign *move between lanes* is deliberately not a vanish — the editor follows + /// the UUID and the commit writes wherever the card now lives — which falls out for free from + /// matching on identity within the board container. /// /// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md /// gives every item-referencing piece of transient state: nothing may reference an item the /// current universe does not have. It is not an `ItemReferenceSet` only because it is one /// optional rather than a set on a side — the rule it obeys is the same one. /// - /// **`selectionAnchor` obeys it too**, on the *selection's* side: a range origin that vanished - /// or flipped liveness is gone, and the next ⇧-click acts as a plain click rather than ranging - /// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the - /// selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor selected and - /// a range from it is still exactly what the user asked for. + /// **`selectionAnchor` obeys it too**, in the *selection's* container: a range origin that + /// vanished or crossed containers is gone, and the next ⇧-click acts as a plain click rather + /// than ranging from somewhere that renders nowhere. It deliberately does **not** have to stay + /// *in* the selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor + /// selected and a range from it is still exactly what the user asked for. /// /// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls, - /// settled): a member that vanishes or flips liveness leaves the set — so the editor's + /// settled): a member that vanishes or crosses containers leaves the set — so the editor's /// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload /// clears the session, which is how "the popover dismisses when it empties" reaches the screen. /// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns @@ -725,25 +641,22 @@ public final class TransientBoardState { newCardPlaceholder = resolvedPlaceholder(against: snapshot) styleEditor = styleEditor?.resolved(against: snapshot) - // One universe computed once and asked three questions — the rename target's liveness, the + // One universe computed once and asked three questions — the rename target's container, the // last-active lane's, and (via the placeholder above, which asks its own way) the anchor's. - let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live) - if let editor = renameEditor, !live.contains(editor.targetID) { + let board = ItemContainer.board.ids(in: snapshot) + if let editor = renameEditor, !board.contains(editor.targetID) { renameEditor = nil } - if let lane = lastActiveLaneID, !live.contains(lane) { + if let lane = lastActiveLaneID, !board.contains(lane) { lastActiveLaneID = nil } if selectionAnchor != nil || selectionHead != nil { - // The selection's side, because that is the side both cursors live on by construction — - // every route that sets either sets the selection to the same side in the same call. A - // vanished or liveness-flipped cursor is gone, which is the rule every item reference - // here gets: "a flip is a vanish from its side of the boundary". The head then re-derives - // from the selection's last member on the next arrow, which is the same fallback an - // anchorless ⇧-arrow already uses. - let universe = selection.liveness == .live - ? live - : ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness) + // The selection's container, because that is where both cursors live by construction — + // every route that sets either sets the selection to the same container in the same + // call. A vanished or container-crossed cursor is gone, which is the rule every item + // reference here gets. The head then re-derives from the selection's last member on the + // next arrow, which is the same fallback an anchorless ⇧-arrow already uses. + let universe = selection.container == .board ? board : selection.container.ids(in: snapshot) if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil } } @@ -776,13 +689,14 @@ public final class TransientBoardState { /// under a live filter is a gesture in flight, not a set the filter has any claim on. The /// editor's absence is the settled ruling in person: "an open inline rename survives the filter /// hiding its card … the vanish-discard rule stays reserved for true liveness flips" - /// (`RenameEditor`), so a foreign edit that stops the renaming card matching drops it from the - /// selection here and leaves the keystrokes exactly where the user left them. + /// (`RenameEditor`) — read for the materialized trash, true container crossings — so a foreign + /// edit that stops the renaming card matching drops it from the selection here and leaves the + /// keystrokes exactly where the user left them. public func constrainToSearch(in snapshot: BoardModel) { let filter = SearchFilter(query: searchQuery) guard filter.isActive else { return } - let universe = filter.visibleIDs(in: snapshot, on: selection.liveness) + let universe = filter.visibleIDs(in: snapshot, container: selection.container) selection = selection.constrained(to: universe) if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil } @@ -792,9 +706,7 @@ public final class TransientBoardState { private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? { guard let placeholder = newCardPlaceholder else { return nil } - guard let anchor = snapshot.lanes.first(where: { $0.id == placeholder.laneID }), - !anchor.isDeleted - else { return nil } + guard snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil } if case let .awaitingArrival(id) = placeholder.phase, snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) { diff --git a/Kanban/LiveStore/TrashModel.swift b/Kanban/LiveStore/TrashModel.swift index fb487d6..498ba69 100644 --- a/Kanban/LiveStore/TrashModel.swift +++ b/Kanban/LiveStore/TrashModel.swift @@ -1,409 +1,147 @@ 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. +/// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash's +/// **materialized** container (resettled 2026-07-28). /// -/// **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. +/// ### Almost nothing, and that is the point of the pivot /// -/// The three rules it owns, each of which the design settles explicitly: +/// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a +/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to +/// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a +/// special place", so the trash's contents *are* `snapshot.trash` — already parsed by the same card +/// parse the lanes use, already in `order` display order, already newest-first because every arrival +/// mints a rank above the current top. There is nothing to derive, and no second definition to keep +/// in step with the loader's. /// -/// 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. This rule is not spelled -/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is -/// held to *is* this row set — "universe and rows are one function" (02-architecture.md § Changes -/// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the -/// rows a user sees and the ids a selection may hold cannot drift apart. -/// 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. +/// What genuinely remains is what the *commands* need and no view can answer: the two purge +/// confirmations' phrasing, and the menu validation that stages Delete by place. Both are pure +/// functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is testable +/// without an alert on screen. 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. - /// - /// **Which items are rows is not decided here** — it is `Liveness.trashed.walk`, the same walk - /// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are - /// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane - /// contributes exactly one entry and its cards contribute none, whatever their own flags say. - /// - /// What is left for this function is what a row *carries* — the returning count and the sort - /// inputs — and the order it shows in, neither of which any other caller of the walk wants. - public static func entries(of snapshot: BoardModel) -> [TrashEntry] { - var rows: [Row] = [] - Liveness.trashed.walk(snapshot) { lane, card in - if let card { - rows.append(Row( - entry: .card(card, laneID: lane.id), - deleted: card.deleted.value, - name: card.id.rawValue - )) - } else { - // 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 - )) - } - } - 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). - /// - /// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is - /// restated and only because stopping early is the whole point: a tombstoned lane is a row - /// outright, and under a live lane any own-flagged card is one. There is deliberately no third - /// clause for a card beneath a tombstoned lane — the lane has already answered `true` for it. - /// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift. - 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 liveness side. - /// - /// **The membership rule is not restated here** — it is `Liveness.walk`'s, the same one - /// `ItemReferenceSet.idUniverse` and `entries(of:)` read — spelled in *paths* rather than ids - /// because a write needs to know where the item is. So the trashed side is the trash's rows and - /// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs - /// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee - /// that every path this hands a writer names something the board would draw. - /// - /// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than - /// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put - /// Back on it restores the lane and every card that rode along; Delete Immediately on it purges - /// the whole tree, own-flag cards included — the outcome the lane entry's confirmation sentence - /// exists to warn about (`message(lanes:unrecoverable:)`). - /// - /// 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). The walk - /// visits in exactly that order, so this is a filter over it and never a sort. - public static func paths(of ids: Set, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] { - guard !ids.isEmpty else { return [] } - var result: [ItemPath] = [] - side.walk(snapshot) { lane, card in - guard ids.contains(card?.id ?? lane.id) else { return } - 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). - /// - /// `paths(of:on:in:)` on the trashed side with **no id filter at all**, which is the strongest - /// form of that guarantee: the command's scope is the trashed universe itself, so it cannot - /// narrow to a selection any more than it can narrow to the search. - /// - /// **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) and would require a second, broader - /// definition of "in the trash" than the one every other caller reads. - public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] { - var result: [ItemPath] = [] - Liveness.trashed.walk(snapshot) { lane, card in - 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")" + /// "41 cards", "1 card" — 06-history-undo.md's **plural folding**, which is all the folding a + /// cards-only container can need ("Cards only. Lanes are never trashed" — 03-board-ui.md). + public static func phrase(_ count: Int) -> String { + "\(count) card\(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`). + /// A value rather than a view so the phrasing rules — plural folding, naming a sole item, and + /// whether the loss is actually irreversible — are testable without an alert on screen. 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). + /// The alert in front of a **permanent** card delete — the trash's own ⌫/⌘⌫ and File ▸ Delete + /// Immediately alike (03-board-ui.md § Trash: "Both confirm exactly where the loss is real ... + /// the alert stands between one keystroke and unrecoverable deletion"). /// - /// 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 + /// `container` is where the command found the cards: the trash for the trash's Delete, the board + /// for a Delete Immediately that skips the trash from a lane. The prompt reads the same either + /// way — what is being asked is whether to destroy these cards, and where they happen to be + /// sitting is not the question. + /// + /// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in that + /// container, which is also the command's own refusal — so the prompt and the action can never /// disagree about whether there is anything to purge. public static func purgePrompt( for ids: Set, - in snapshot: BoardModel, + in container: ItemContainer, + snapshot: BoardModel, unrecoverable: Bool ) -> PurgePrompt? { - let targets = paths(of: ids, on: .trashed, in: snapshot) + let targets = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane } 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) + subject = phrase(targets.count) } return PurgePrompt( title: "Permanently delete \(subject)?", - message: message(lanes: counts.lanes, unrecoverable: unrecoverable), + message: message(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. + /// Empty Trash…'s alert — **always shown** ("Empty Trash… confirms everywhere"), and always + /// naming the **true count**: every card in `.trash/`, never the filtered view (03-board-ui.md § + /// Trash: "search-independent, the confirmation naming the card count"). /// - /// 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). + /// Counts rather than names even for a single card, because the command is about the trash + /// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing. public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? { - let counts = counts(of: emptyTrashTargets(in: snapshot)) - guard !counts.isEmpty else { return nil } + guard !snapshot.trash.isEmpty else { return nil } return PurgePrompt( - title: "Permanently delete \(phrase(counts))?", - message: message(lanes: counts.lanes, unrecoverable: unrecoverable), + title: "Permanently delete \(phrase(snapshot.trash.count))?", + message: message(unrecoverable: unrecoverable), confirmTitle: "Delete" ) } - /// The alert's body: what a lane takes with it, and whether any of it comes back. + /// The alert's body: 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.") - } + /// The tombstone era's second sentence — "Deleting a lane also deletes every card inside it" — + /// is gone with the lane entries it warned about: no purge path reaches a lane any more + /// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with + /// undo as its net). + private static func message(unrecoverable: Bool) -> String { // m7-git: on a git board the content stays reachable in history, so the second sentence is // the honest one — and Delete Immediately does not confirm there at all // (`BoardStore.purgeIsUnrecoverable`). - parts.append(unrecoverable + unrecoverable ? "This can\u{2019}t be undone." - : "The board\u{2019}s history still has them.") - return parts.joined(separator: " ") + : "The board\u{2019}s history still has them." } - /// What to call an item in a prompt — its title, or the "Untitled" rendering. + /// What to call a card 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 + /// Total by construction: a path whose card 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" + switch path { + case let .lane(id): + return snapshot.lanes.first { $0.id == id }?.title.value ?? "Untitled" + case let .card(lane, id): + return snapshot.lanes.first { $0.id == lane }? + .cards.first { $0.id == id }?.title.value ?? "Untitled" + case let .trashCard(id): + return snapshot.trash.first { $0.id == id }?.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. + /// Whether File ▸ Delete has something to act on — **staged by place, but validated once** + /// (04-interactions.md ▸ The map, resettled 2026-07-28: "File ▸ Delete is the chord's only + /// owner — no twin menu items, no shared-equivalent routing"). /// - /// 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. + /// One predicate for both stagings, because there is only one item now: a board selection moves + /// into the trash, a trash selection deletes permanently, and the command is enabled whenever + /// either names something the board still holds. The old mirror-image pair + /// (which existed to make two ⌘⌫ twins enable exactly one of themselves) retired with Put Back. public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { - selection.liveness == .live && !paths(of: selection.ids, on: .live, in: snapshot).isEmpty + !ItemPath.resolve(selection.ids, in: selection.container, snapshot: 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 + /// Whether File ▸ Delete Immediately has something to purge — **a card selection, from anywhere** + /// (11-command-nexus.md: "Board window, card selection — skips the trash from anywhere"). + /// + /// Cards only, in either container: a lane's delete is physical already and has undo as its net, + /// so there is nothing for "skip the trash" to mean on one. + public static func canDeleteImmediately(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { + ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).contains { !$0.isLane } } } diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index ba7fc94..a7bff2b 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -744,7 +744,7 @@ public struct LegacyTombstone: Sendable, Equatable { /// /// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a /// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is -/// carried as its two identity components rather than as a URL, `BoardStore.liveItem`'s convention, +/// carried as its two identity components rather than as a URL, `ItemPath`'s convention, /// so the write derives its path from the store's *current* root. public struct LooseCardFiles: Sendable, Equatable { public let laneID: ItemID diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 1f11162..8c81141 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -1391,12 +1391,18 @@ public enum BoardWriter: Sendable { try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) } - // MARK: - Tombstone (retiring) + // MARK: - Tombstone (retired, awaiting removal) // // The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the - // app's delete is the physical move above, and no `deleted:` key is ever written again. The - // three calls below are kept only while their callers are still being moved across — the - // migration removes the last keys any of them could act on, and they go with the last consumer. + // app's delete is the physical move above, and no `deleted:` key is ever written again. + // + // **These have no app callers left.** Every consumer moved across with the store swap — the + // delete is `deleteCardToTrash`, the lane delete is `removeLane`, the restore is an ordinary + // `moveItem`, and the legacy keys are handled by the two `migrate…` calls above. They are kept + // here for exactly one more beat because `purgeItem` below is still live (Delete Immediately's + // board-side purge) and the three read as one family; the pair and + // `stripTombstonedChildren` go together in the trash's cleanup pass, with the suites that + // still pin their byte-level behaviour. /// Tombstones a lane or card in place: writes `deleted: ` into its own `index.md` — /// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index c1dbb5e..4d3cbdd 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -141,14 +141,14 @@ struct OpenCardCommand: View { return store.isEditingInline || soleSelectedCard != nil } - /// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned - /// selection all answer `nil` — "everything edit-shaped is disabled on tombstoned selections" - /// (04 ▸ The trash), and a card window is tied to one card. + /// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash + /// selection all answer `nil` — "everything edit-shaped is disabled on trash selections … Open + /// Card, Rename, Style…" (04 ▸ The trash), and a card window is tied to one card. private var soleSelectedCard: ItemID? { guard let store else { return nil } let selection = store.selection - guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first, - BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil + guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first, + BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil else { return nil } return id } @@ -161,8 +161,8 @@ struct OpenCardCommand: View { // holds it — and re-checked after, because one of those paths is *the lane vanished*. let lane = placeholder.laneID let created = store.commitPlaceholder() - if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) { - store.select([lane], liveness: .live) + if store.snapshot.lanes.contains(where: { $0.id == lane }) { + store.select([lane], in: .board) } if let created { open(created) } return @@ -170,7 +170,7 @@ struct OpenCardCommand: View { if let editor = store.transient.renameEditor { let target = editor.targetID - let isCard = BoardStore.liveItem(target, in: store.snapshot)?.cardID != nil + let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil store.commitRename() if isCard { open(target) } return @@ -269,8 +269,8 @@ struct MoveLaneCommands: View { private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? { guard let store, store.acceptsBoardMutations else { return nil } let selection = store.selection - guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil } - let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil } + let lanes = SelectionGrammar.lanes(in: store.snapshot) // A card id is in no lane order, so this is also the "not a lane" test. guard let from = lanes.firstIndex(of: id) else { return nil } let to = from + delta @@ -382,9 +382,9 @@ struct BoardInfoCommand: View { /// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could /// never be renamed at all (04-interactions.md ▸ Selection). /// -/// Validation is the sole-selected-live-item rule — card or lane, either kind, exactly one. A -/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned -/// selections" (04 ▸ The trash), which `ItemReferenceSet`'s liveness side answers directly. +/// Validation is the sole-selected-board-item rule — card or lane, either kind, exactly one. A +/// trash selection never enables it: "everything edit-shaped is disabled on trash selections" +/// (04 ▸ The trash), which `ItemReferenceSet`'s container answers directly. struct BoardRenameCommand: View { @FocusedValue(\.boardStore) private var store @@ -400,8 +400,8 @@ struct BoardRenameCommand: View { private var renameTarget: (id: ItemID, title: String?)? { guard let store, store.acceptsBoardMutations else { return nil } let selection = store.selection - guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first, - let item = BoardStore.liveItem(id, in: store.snapshot) + guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first, + let item = BoardStore.boardItem(id, in: store.snapshot) else { return nil } return (id: id, title: item.title) } @@ -441,7 +441,7 @@ struct BoardStyleCommand: View { guard let store, store.acceptsBoardMutations else { return nil } let selection = store.selection guard !selection.isEmpty else { return .board } - guard selection.liveness == .live else { return nil } + guard selection.container == .board else { return nil } // Re-resolved against the snapshot on the way in, so the session starts out holding only // items that render — the same universe its own reload rule will hold it to. let live = selection.resolved(against: store.snapshot).ids @@ -498,8 +498,8 @@ struct LaneWidthCommands: View { private var selectedLanes: [Lane] { guard let store, store.acceptsBoardMutations else { return [] } let selection = store.selection - guard selection.liveness == .live, !selection.isEmpty else { return [] } - return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted } + guard selection.container == .board, !selection.isEmpty else { return [] } + return store.snapshot.lanes.filter { selection.ids.contains($0.id) } } /// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 014572e..5ab7060 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -140,7 +140,7 @@ struct BoardDropContext { DragLocality.isSameBoard(proposal.boardRoot, store.rootURL), let laneID = proposal.laneID else { return } - guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return } + guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return } session.propose(nil) } @@ -159,7 +159,7 @@ struct BoardDropContext { func retargetLanes() { guard session.isDraggingLanes, let cursor = stripCursor() else { return } let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) - let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) } + let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) } let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) } let slot = DropSlotMath.laneSlot( cursorX: cursor.x, @@ -181,14 +181,14 @@ struct BoardDropContext { /// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll). func retargetCards(inLane laneID: ItemID) { guard session.isDraggingCards, let cursor = globalCursor() else { return } - guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { + guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else { revalidateProposal() return } guard let grid = registry.grids[laneID] else { return } let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) - let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) } + let rendered = lane.cards.filter { !hidden.contains($0.id) } let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } let placement = MasonryPlacement( columnCount: grid.columns, @@ -218,7 +218,7 @@ struct BoardDropContext { /// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract. func retargetCardsFromStrip() { guard session.isDraggingCards, let cursor = stripCursor() else { return } - let lanes = store.snapshot.lanes.filter { !$0.isDeleted } + let lanes = store.snapshot.lanes let index = LaneLayoutMath.laneIndex( atX: cursor.x, unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) }, @@ -251,7 +251,7 @@ struct BoardDropContext { guard let sourceRoot = session.sourceRoot else { return false } return TrashDrop.accepts( kind: session.kind, - side: session.side, + container: session.container, isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL), operation: session.resolveOperation(destinationRoot: store.rootURL), isTrashShown: store.transient.isTrashVisible, @@ -338,14 +338,14 @@ struct BoardDropContext { /// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it. func retargetFile(inLane laneID: ItemID, info: DropInfo) { guard acceptsFileDrop(info), let cursor = globalCursor(), - let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }), + let lane = store.snapshot.lanes.first(where: { $0.id == laneID }), let grid = registry.grids[laneID] else { session.proposeFile(nil) return } - let rendered = lane.cards.filter { !$0.isDeleted } + let rendered = lane.cards let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } let placement = MasonryPlacement( columnCount: grid.columns, @@ -399,7 +399,7 @@ struct BoardDropContext { session.proposeFile(nil) return } - let lanes = store.snapshot.lanes.filter { !$0.isDeleted } + let lanes = store.snapshot.lanes let index = LaneLayoutMath.laneIndex( atX: cursor.x, unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) }, @@ -498,7 +498,7 @@ struct BoardDropContext { /// set's size (DRAG-REORDER.md § The drop commits). /// /// **One of the containers is not a destination but a verb.** A proposal naming the trash commits - /// a tombstone — the same write ⌫ performs, through the same `BoardWriter.deleteItem` in the same + /// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` in the same /// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk /// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28). /// @@ -547,15 +547,16 @@ struct BoardDropContext { case .cards: if target.isTrash { // **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled - // 2026-07-28): "release tombstones the dragged card(s), exactly the ⌫ tombstone". + // 2026-07-28): "release moves the dragged card(s) into `.trash/`" — exactly the ⌫ + // delete. // // The gate is re-asked here rather than trusted from the hover, because the one input // that can change between them arrives through no callback at all: ⌥ pressed after - // the proposal stood would otherwise tombstone an original the copy grammar had just + // the proposal stood would otherwise delete an original the copy grammar had just // promised to leave alone. A refusal cancels — items return, nothing is written. guard TrashDrop.accepts( kind: kind, - side: session.side, + container: session.container, isWithinBoard: within, operation: operation, isTrashShown: store.transient.isTrashVisible, @@ -571,27 +572,20 @@ struct BoardDropContext { cancelDrop() return false } - switch (session.side, within) { - case (.live, true): + // **The trash side needs no branch of its own any more** (04-interactions.md ▸ The + // trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a + // trash card into one of its own board's lanes is an ordinary move to the drop + // position"). `moveCards`/`copyCards` resolve their members in either container, so a + // restore *is* the within-board move and a cross-board restore *is* the ordinary + // arrival — which is exactly what retiring the restore-specific machinery bought. + if within { if operation == .copy { store.copyCards(Set(ids), toLane: laneID, at: target.index) } else { store.moveCards(Set(ids), toLane: laneID, at: target.index) } - case (.live, false): + } else { store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index) - case (.trashed, true): - // Drag-to-restore, and its ⌥ twin. "Dropping a tombstoned card into one of its own - // board's lanes restores it at the drop position"; ⌥ is the copy-out instead — a - // live copy lands and the tombstoned original stays (04-interactions.md ▸ The trash, - // "⌘C, ⌥-drag … always yield live copies"). - if operation == .copy { - store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index) - } else { - store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index) - } - case (.trashed, false): - store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index) } } diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 20dd756..bba5ec8 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -240,7 +240,7 @@ struct BoardView: View { guard ClickModifier.current == .plain else { return } store.clearSelection() } - .simultaneousGesture(marqueeControl.gesture(side: .live)) + .simultaneousGesture(marqueeControl.gesture(in: .board)) } /// The lanes and the drag's shadows, plus the trash column when it is shown. @@ -269,7 +269,7 @@ struct BoardView: View { if isTrashVisible { // Trailing, always — the quasi-lane has no position of its own to lose, which is // also why it never appears in the drop proposal's inputs (those are built from - // `liveLanes`) and why the terminal slot clamps in front of it. + // `boardLanes`) and why the terminal slot clamps in front of it. TrashLaneView( store: store, confirmations: confirmations, @@ -427,11 +427,11 @@ struct BoardView: View { } } - /// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** — - /// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane - /// (a later card), and a lane that is not on the board consumes none of the window's width. - private var liveLanes: [Lane] { - store.snapshot.lanes.filter { !$0.isDeleted } + /// The lanes the strip lays out, in snapshot order — every lane the board has. Deleting a lane + /// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in + /// the snapshot is a lane on the board, with no hidden state to filter for. + private var boardLanes: [Lane] { + store.snapshot.lanes } // MARK: - Trash @@ -443,16 +443,15 @@ struct BoardView: View { store.transient.isTrashVisible } - /// The trash's rows as the column is showing them — the shown trash "participates in the filter - /// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen - /// (`TrashLaneView.entries` applies the identical predicate to the identical rows). + /// The trash's cards as the column is showing them — the shown trash's cards "participate in + /// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is + /// on screen (`TrashLaneView` applies the identical predicate to the identical cards). /// /// Read by the three keyboard destinations that reach into the column — the arrow origin's - /// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the + /// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a card the /// filter took away. - private var trashEntries: [TrashEntry] { - let filter = store.searchFilter - return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) } + private var trashCards: [ItemID] { + SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter) } // MARK: - The drag @@ -506,7 +505,7 @@ struct BoardView: View { /// (`arrivingLaneUnits`). private func standardWidth(stripWidth: CGFloat) -> CGFloat { if resize.isActive { return resize.standard } - var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0) + var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0) units += arrivingLaneUnits return LaneLayoutMath.standardWidth( stripWidth: stripWidth, @@ -569,7 +568,7 @@ struct BoardView: View { private var stripSlots: [StripSlot] { let session = appModel.dragSession let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) - var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane) + var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane) guard let index = stripProposal else { return slots } let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) } slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count)) @@ -596,10 +595,10 @@ struct BoardView: View { } guard !store.isEditingInline, !store.isReadOnly else { return .ignored } let selection = store.selection - guard selection.liveness == .live, + guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first, - let target = BoardStore.liveItem(id, in: store.snapshot) + let target = BoardStore.boardItem(id, in: store.snapshot) else { return .ignored } if target.cardID == nil { @@ -614,9 +613,11 @@ struct BoardView: View { /// 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. + /// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same + /// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28), and the delete + /// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu + /// items sharing a chord. Put Back — the reason the bare key had to stay off the trash — is + /// retired with the tombstone model. /// /// 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. @@ -631,7 +632,7 @@ struct BoardView: View { } guard !store.isEditingInline, !store.isReadOnly else { return .ignored } let selection = store.selection - guard selection.liveness == .live, !selection.isEmpty else { return .ignored } + guard !selection.isEmpty else { return .ignored } store.deleteSelection() return .handled } @@ -716,7 +717,7 @@ struct BoardView: View { guard let origin = arrowOrigin() else { return seed(direction, mode) } return origin.isLaneDomain ? laneArrow(direction, mode, from: origin.head) - : cardArrow(direction, mode, from: origin.head, on: origin.side) + : cardArrow(direction, mode, from: origin.head, in: origin.container) } private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? { @@ -738,33 +739,32 @@ struct BoardView: View { /// Select All and a foreign reload leave the arrows somewhere sensible without any of them /// having to name a cursor. /// - /// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk - /// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The - /// per-kind lists are the *range*'s business, not the walk's. + /// The **trash's list is its cards**, top to bottom — there are no lane entries to interleave + /// any more (03-board-ui.md § Trash: "Cards only"). /// /// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the /// fallback lands on the last *visible* member rather than on a card the query hid. - private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? { + private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? { let selection = store.selection guard !selection.isEmpty else { return nil } let isLaneDomain: Bool let list: [ItemID] - switch selection.liveness { - case .live: + switch selection.container { + case .board: guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil } isLaneDomain = kind == .lane - list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter) - case .trashed: + list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter) + case .trash: isLaneDomain = false - list = trashEntries.map(\.id) + list = trashCards } if let head = store.transient.selectionHead, list.contains(head) { - return (head, selection.liveness, isLaneDomain) + return (head, selection.container, isLaneDomain) } guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil } - return (last, selection.liveness, isLaneDomain) + return (last, selection.container, isLaneDomain) } /// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) — @@ -779,8 +779,8 @@ struct BoardView: View { if mode == .jump, direction == .left || direction == .right { return jumpToEndLane(direction) } - guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled } - replaceSelection(with: first, on: .live) + guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled } + replaceSelection(with: first, in: .board) return .handled } @@ -790,7 +790,7 @@ struct BoardView: View { _ direction: NavigationMath.Direction, _ mode: ArrowMode, from head: ItemID, - on side: Liveness + in container: ItemContainer ) -> KeyPress.Result { switch mode { case .step: step(direction, from: head) @@ -798,7 +798,7 @@ struct BoardView: View { case .jump: switch direction { case .left, .right: jumpToEndLane(direction) - case .up, .down: jumpWithinContainer(direction, from: head, on: side) + case .up, .down: jumpWithinContainer(direction, from: head, in: container) } } } @@ -817,7 +817,7 @@ struct BoardView: View { ), let next = marqueeTargets.targets[nextID] else { return .handled } - replaceSelection(with: next.id, on: next.side) + replaceSelection(with: next.id, in: next.container) return .handled } @@ -837,7 +837,7 @@ struct BoardView: View { among: marqueeTargets.all ), let next = marqueeTargets.targets[nextID], - next.side == origin.side, + next.container == origin.container, next.kind == origin.kind else { return .handled } @@ -849,13 +849,13 @@ struct BoardView: View { from: anchor, to: next.id, kind: next.kind, - on: next.side, - in: store.snapshot, + in: next.container, + snapshot: store.snapshot, // The span is the *filtered* board's, so a range under a search collects exactly the - // rows between the two endpoints that are on screen (04 § Search: "ranges … read it"). + // cards between the two endpoints that are on screen (04 § Search: "ranges … read it"). filter: store.searchFilter ) else { return .handled } - store.select(ids, liveness: next.side, anchor: anchor, head: next.id) + store.select(ids, in: next.container, anchor: anchor, head: next.id) return .handled } @@ -869,30 +869,30 @@ struct BoardView: View { private func jumpWithinContainer( _ direction: NavigationMath.Direction, from head: ItemID, - on side: Liveness + in container: ItemContainer ) -> KeyPress.Result { - let container: [ItemID] + let siblings: [ItemID] var lane: ItemID? - switch side { - case .trashed: - container = trashEntries.map(\.id) - case .live: + switch container { + case .trash: + siblings = trashCards + case .board: guard let home = store.snapshot.lanes.first(where: { lane in - !lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted } + lane.cards.contains { $0.id == head } }) else { return .handled } lane = home.id // The container is what the lane is *showing*: a jump to "the lane's first card" under // a search means its first surviving card, not one the filter animated out. let filter = store.searchFilter - container = home.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id) + siblings = home.cards.filter { filter.matches($0) }.map(\.id) } - guard let target = direction == .up ? container.first : container.last else { return .handled } + guard let target = direction == .up ? siblings.first : siblings.last else { return .handled } if direction == .up, target == head, let lane, store.selection.ids == [head] { - replaceSelection(with: lane, on: .live) + replaceSelection(with: lane, in: .board) return .handled } - replaceSelection(with: target, on: side) + replaceSelection(with: target, in: container) return .handled } @@ -904,11 +904,11 @@ struct BoardView: View { /// the jump falls through to the last lane. Empty lanes are scanned past in both directions — /// a jump that landed nowhere because the end lane happens to be empty would be a dead key. private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result { - if direction == .right, isTrashVisible, let first = trashEntries.first { - replaceSelection(with: first.id, on: .trashed) + if direction == .right, isTrashVisible, let first = trashCards.first { + replaceSelection(with: first, in: .trash) return .handled } - let lanes = liveLanes + let lanes = boardLanes let filter = store.searchFilter // A lane the search emptied is scanned past exactly as an empty one is — the jump lands on // the first lane that is *showing* a card, which is what the user can see. @@ -916,7 +916,7 @@ struct BoardView: View { ? Self.firstCard(scanning: lanes.reversed(), filter: filter) : Self.firstCard(scanning: lanes, filter: filter) guard let target else { return .handled } - replaceSelection(with: target, on: .live) + replaceSelection(with: target, in: .board) return .handled } @@ -937,7 +937,7 @@ struct BoardView: View { _ mode: ArrowMode, from head: ItemID ) -> KeyPress.Result { - let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + let lanes = SelectionGrammar.lanes(in: store.snapshot) guard let index = lanes.firstIndex(of: head) else { return .handled } switch (direction, mode) { @@ -945,30 +945,30 @@ struct BoardView: View { let next = index + (direction == .left ? -1 : 1) guard lanes.indices.contains(next) else { return .handled } if mode == .step { - replaceSelection(with: lanes[next], on: .live) + replaceSelection(with: lanes[next], in: .board) } else { let anchor = store.transient.selectionAnchor ?? head guard let ids = SelectionGrammar.range( from: anchor, to: lanes[next], kind: .lane, - on: .live, - in: store.snapshot + in: .board, + snapshot: store.snapshot ) else { return .handled } - store.select(ids, liveness: .live, anchor: anchor, head: lanes[next]) + store.select(ids, in: .board, anchor: anchor, head: lanes[next]) } case (.left, .jump), (.right, .jump): guard let target = direction == .left ? lanes.first : lanes.last else { return .handled } - replaceSelection(with: target, on: .live) + replaceSelection(with: target, in: .board) case (.down, .step), (.down, .jump): - guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else { + guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else { return .handled } - let cards = lane.cards.filter { !$0.isDeleted } + let cards = lane.cards guard let target = mode == .jump ? cards.last : cards.first else { return .handled } - replaceSelection(with: target.id, on: .live) + replaceSelection(with: target.id, in: .board) case (.up, _), (.down, .extend): // Nothing above the lane domain, and no vertical range within it. @@ -980,8 +980,8 @@ struct BoardView: View { // MARK: Shared /// A jump's and a plain step's shared landing: one item, both cursors on it. - private func replaceSelection(with id: ItemID, on side: Liveness) { - store.select([id], liveness: side, anchor: id, head: id) + private func replaceSelection(with id: ItemID, in container: ItemContainer) { + store.select([id], in: container, anchor: id, head: id) } /// The first rendered card of the first lane that has one — the scan every "first/last lane" @@ -994,7 +994,7 @@ struct BoardView: View { filter: SearchFilter = .inactive ) -> ItemID? { for lane in lanes { - if let card = lane.cards.first(where: { !$0.isDeleted && filter.matches($0) }) { return card.id } + if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id } } return nil } diff --git a/Kanban/UI/Board/DragPayload.swift b/Kanban/UI/Board/DragPayload.swift index 19d74b1..1174aae 100644 --- a/Kanban/UI/Board/DragPayload.swift +++ b/Kanban/UI/Board/DragPayload.swift @@ -46,20 +46,6 @@ enum DragKind: String, Codable, Sendable, Equatable { /// stray drop into a text editor does something sane rather than nothing. struct DragPayload: Codable, Sendable, Equatable { - /// Which side of the live/tombstoned boundary the drag started on — a trash row's drag is a card - /// drag from the trashed side, and 04-interactions.md ▸ The trash gives it its own rules - /// (restore within the board, copy-out across boards). - enum Side: String, Codable, Sendable, Equatable { - case live - case trashed - - init(_ liveness: Liveness) { - self = liveness == .live ? .live : .trashed - } - - var liveness: Liveness { self == .live ? .live : .trashed } - } - /// One dragged item: its UUID, its folder on disk, and its title for the text representation. struct Item: Codable, Sendable, Equatable { var id: String @@ -71,7 +57,11 @@ struct DragPayload: Codable, Sendable, Equatable { var boardRoot: String var kind: DragKind - var side: Side + + /// Which container the drag started in — a trash card's drag is a card drag from `.trash`, which + /// is the whole of what makes its within-board drop a restore (04-interactions.md ▸ The trash). + /// `ItemContainer` is `String`-backed and `Codable` precisely so it can ride a pasteboard. + var container: ItemContainer /// The dragged items **in flatten order** — "lane `order` first, then card `order`" /// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than @@ -97,10 +87,10 @@ struct DragPayload: Codable, Sendable, Equatable { try? JSONEncoder().encode(self) } - init(boardRoot: URL, kind: DragKind, side: Liveness, items: [Item]) { + init(boardRoot: URL, kind: DragKind, container: ItemContainer, items: [Item]) { self.boardRoot = boardRoot.path self.kind = kind - self.side = Side(side) + self.container = container self.items = items } diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index b5fd3bd..3bb0459 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -15,16 +15,17 @@ struct DropTarget: Equatable, Sendable { /// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be /// written down at all. /// - /// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the - /// snapshot — it is `TrashModel.entries` derived from it — so there is nothing to put in a - /// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`). + /// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card + /// folders directly — same shape as a lane's children, no `index.md` of its own" + /// (01-storage-format.md § Deletion), so there is nothing to put in a `lane`, and its index is + /// not a position the pointer chose either (see `TrashDrop`). enum Container: Equatable, Sendable { - /// The **lane strip**: the index counts live lanes with the dragged run removed. + /// The **lane strip**: the index counts the board's lanes with the dragged run removed. case strip /// That lane's **masonry**: the index is a position in its logical card order /// (DRAG-REORDER.md § The card masonry). case lane(ItemID) - /// The **trash quasi-lane**, which a live card drag proposes into to delete it + /// The **trash column**, which a board card drag proposes into to delete it /// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row. case trash } @@ -57,8 +58,9 @@ enum TrashDrop { /// The row the shadow takes, always: **the topmost**. /// - /// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted` - /// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop + /// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank + /// above the current top" (04-interactions.md ▸ The trash), so a fresh delete genuinely lands on + /// top. The drop /// therefore still lands exactly where the shadow shows — the one positional promise every other /// drop in this app makes — while being the only proposal on the board the *pointer* does not /// choose. @@ -70,7 +72,7 @@ enum TrashDrop { /// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's /// slot list has never contained the quasi-lane, so this is belt over braces; it is written down /// because a guard that is only true by construction is one refactor from being false.) - /// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out; + /// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out; /// dropping it back where it came from writes nothing. /// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card /// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the @@ -82,14 +84,14 @@ enum TrashDrop { /// - **The mutating-gesture rule**, like every other write the pointer can start. static func accepts( kind: DragKind?, - side: Liveness, + container: ItemContainer, isWithinBoard: Bool, operation: TransferOperation, isTrashShown: Bool, acceptsMutations: Bool ) -> Bool { guard isTrashShown, acceptsMutations else { return false } - guard kind == .cards, side == .live, isWithinBoard else { return false } + guard kind == .cards, container == .board, isWithinBoard else { return false } return operation == .move } } @@ -207,14 +209,14 @@ enum DragLocality { /// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a /// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but /// its home is the clipboard (▸ Clipboard, Lane paste). - /// - **A trash row's drag is copy-out grammar** (▸ The trash). Within its own board it is the - /// restore — a move, no badge; across boards the default is the live copy that leaves the - /// tombstoned original in place, exactly as ⌘C out of the trash behaves. ⌘ forces the true - /// restore-move either way, and ⌥ forces the live copy either way ("⌘C, ⌥-drag, and the - /// cross-board drag default always yield *live* copies"). + /// - **A trash card's drag is the restore** (▸ The trash). Within its own board it is "an + /// ordinary move to the drop position"; across boards the default is the copy that leaves the + /// original in the source trash, and "⌘-drag forces the true cross-board restore-move". Both + /// fall out of the ordinary locality rule with no trash clause at all, which is the pivot's + /// whole point. static func operation( kind: DragKind, - side: Liveness, + container: ItemContainer, isWithinBoard: Bool, modifiers: NSEvent.ModifierFlags ) -> TransferOperation { @@ -227,7 +229,7 @@ enum DragLocality { if forcesMove { return .move } if forcesCopy { return .copy } - _ = side // the side changes which commit runs, never which operation the badge shows + _ = container // the container changes which commit runs, never which operation the badge shows return isWithinBoard ? .move : .copy } } @@ -272,9 +274,9 @@ final class DragSession { /// means here rather than a separate flag. private(set) var kind: DragKind? - /// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed` - /// side, and that is the whole of what makes it one (04-interactions.md ▸ The trash). - private(set) var side: Liveness = .live + /// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`, + /// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash). + private(set) var container: ItemContainer = .board /// The dragged items in **flatten order** — the order they will land in. private(set) var members: [ItemID] = [] @@ -358,8 +360,8 @@ final class DragSession { /// The items to **leave out of the resting layout** on the board rooted at `root`. /// - /// Only the source board hides anything, and only for a live-side session: a trash row's drag - /// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a + /// Only the source board hides anything, and only for a board-side session: a trash card's drag + /// carries items that render in the trash column, not in any lane's masonry, so no lane loses a /// card to it. /// /// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md § @@ -368,7 +370,7 @@ final class DragSession { /// originals reappear when the write lands — the hold keeps them lifted for that round trip, so /// the arrangement on screen is the one the release proposed and stays still until the echo. func hiddenMembers(onBoardRooted root: URL) -> Set { - guard isActive, side == .live, let sourceRoot, + guard isActive, container == .board, let sourceRoot, DragLocality.isSameBoard(root, sourceRoot) else { return [] } return memberSet @@ -473,27 +475,27 @@ final class DragSession { // MARK: Lifecycle - /// Begins a card session — live faces or trash rows. + /// Begins a card session — board faces or trash cards. /// /// - Parameters: - /// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's - /// own sorted order for a trash-row drag). + /// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the + /// trash's own order for a trash-card drag). /// - heights: their measured heights, captured **before** the pickup transition starts. func beginCards( _ members: [ItemID], folders: [URL], heights: [CGFloat], - side: Liveness, + container: ItemContainer, source: BoardStore ) { - begin(kind: .cards, members: members, folders: folders, side: side, source: source) + begin(kind: .cards, members: members, folders: folders, container: container, source: source) cardHeights = heights laneUnits = [] } /// Begins a lane session. func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) { - begin(kind: .lanes, members: members, folders: folders, side: .live, source: source) + begin(kind: .lanes, members: members, folders: folders, container: .board, source: source) laneUnits = units cardHeights = [] } @@ -502,7 +504,7 @@ final class DragSession { kind: DragKind, members: [ItemID], folders: [URL], - side: Liveness, + container: ItemContainer, source: BoardStore ) { endHold() @@ -510,14 +512,14 @@ final class DragSession { self.members = members self.memberSet = Set(members) self.folders = folders - self.side = side + self.container = container self.sourceStore = source self.sourceRoot = source.rootURL self.proposal = nil self.operation = .move // The reload-resolved drag set: vanished members leave it silently, which is what // `survivors` reads and what "an emptied drag cancels itself" is stated in terms of. - source.transient.dragMembers = ItemReferenceSet(ids: memberSet, liveness: side) + source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container) armWatchdog() } @@ -538,7 +540,7 @@ final class DragSession { guard let kind, let sourceRoot else { return operation } let resolved = DragLocality.operation( kind: kind, - side: side, + container: container, isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot), modifiers: NSEvent.modifierFlags ) diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 96d438b..de5fa23 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -126,7 +126,7 @@ struct LaneView: View { // the drag off until the pointer actually moves, so a click is never a drag. .onTapGesture { store.click( - SelectionTarget(id: lane.id, kind: .lane, side: .live), + SelectionTarget(id: lane.id, kind: .lane, container: .board), modifier: .current, togglesOnRepeat: true ) @@ -232,17 +232,17 @@ struct LaneView: View { /// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept). /// Right-clicking something outside the selection acts on what was clicked. private var styleTarget: StyleTarget { - guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else { + guard store.selection.container == .board, store.selection.ids.contains(lane.id) else { return .items([lane.id]) } return .items(store.selection.ids) } /// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set` - /// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here + /// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here /// on the live side). private var targetIDs: Set { - guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else { + guard store.selection.container == .board, store.selection.ids.contains(lane.id) else { return [lane.id] } return store.selection.ids @@ -352,20 +352,20 @@ struct LaneView: View { private func startLaneDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let selection = store.selection - let ids: Set = selection.liveness == .live + let ids: Set = selection.container == .board && selection.ids.contains(lane.id) && selection.ids.count > 1 ? selection.ids : [lane.id] - let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) } + let members = store.snapshot.lanes.filter { ids.contains($0.id) } guard !members.isEmpty else { return NSItemProvider() } let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .lanes, - side: .live, + container: .board, items: members.map { DragPayload.Item( id: $0.id.rawValue, @@ -405,7 +405,7 @@ struct LaneView: View { private var draggedLaneCount: Int { let selection = store.selection - guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 } + guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 } return selection.ids.count } @@ -544,7 +544,7 @@ struct LaneView: View { // shares (04-interactions.md § Selection), and the modifier grammar on top of it. .onTapGesture { store.click( - SelectionTarget(id: lane.id, kind: .lane, side: .live), + SelectionTarget(id: lane.id, kind: .lane, container: .board), modifier: .current, togglesOnRepeat: true ) @@ -552,7 +552,7 @@ struct LaneView: View { // The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous // so the taps above stay instant; the band's own begin guard is what keeps a drag that // started on a card face out of it (`MarqueeControl`). - .simultaneousGesture(marquee.gesture(side: .live)) + .simultaneousGesture(marquee.gesture(in: .board)) // The same menu the header carries — "one menu, invoked on the header or lane empty // space alike" (03-board-ui.md § Lane, settled). .contextMenu { laneMenu } @@ -688,7 +688,7 @@ struct LaneView: View { renaming: ItemID? ) -> [Card] { cards.filter { card in - guard !card.isDeleted, !hidden.contains(card.id) else { return false } + guard !hidden.contains(card.id) else { return false } return filter.matches(card) || card.id == renaming } } @@ -696,7 +696,7 @@ struct LaneView: View { // MARK: - Selection private var isSelected: Bool { - store.selection.liveness == .live && store.selection.ids.contains(lane.id) + store.selection.container == .board && store.selection.ids.contains(lane.id) } /// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet — @@ -922,7 +922,7 @@ private struct CardFaceView: View { // Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is // `SelectionGrammar`'s, reached through the store's one funnel. .onTapGesture { - store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current) + store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current) } // "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection). // @@ -948,7 +948,7 @@ private struct CardFaceView: View { drops.registry.update(height: height, for: card.id) } .onDisappear { drops.registry.removeHeight(card.id) } - .marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry) + .marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry) .contextMenu { cardMenu } .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) { StyleEditorPopover(store: store, recents: appModel.styleRecents) @@ -960,7 +960,7 @@ private struct CardFaceView: View { /// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop). /// /// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** — - /// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of + /// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of /// it, which is also the order the drop inserts in. A card outside the selection drags alone. /// /// Refused under the read-only lock and while an inline editor is focused, like every other @@ -969,7 +969,7 @@ private struct CardFaceView: View { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let snapshot = store.snapshot let selection = store.selection - let ids: Set = selection.liveness == .live + let ids: Set = selection.container == .board && selection.ids.contains(card.id) && selection.ids.count > 1 ? selection.ids @@ -978,21 +978,21 @@ private struct CardFaceView: View { // Flatten order, and the lane each member currently lives in — the folder path's middle // component. var lanesByCard: [ItemID: ItemID] = [:] - var titles: [ItemID: String?] = [:] - for lane in snapshot.lanes where !lane.isDeleted { - for member in lane.cards where !member.isDeleted && ids.contains(member.id) { + var titles: [ItemID: String] = [:] + for lane in snapshot.lanes { + for member in lane.cards where ids.contains(member.id) { lanesByCard[member.id] = lane.id titles[member.id] = member.title.value } } - let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) } + let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) } guard !ordered.isEmpty else { return NSItemProvider() } let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .cards, - side: .live, + container: .board, items: ordered.compactMap { id in guard let laneID = lanesByCard[id] else { return nil } return DragPayload.Item( @@ -1001,7 +1001,7 @@ private struct CardFaceView: View { .appendingPathComponent(laneID.rawValue, isDirectory: true) .appendingPathComponent(id.rawValue, isDirectory: true) .path, - title: titles[id] ?? nil + title: titles[id] ) } ) @@ -1012,7 +1012,7 @@ private struct CardFaceView: View { // replica, and its lingering "last measured frame" would mis-size the shadow and the // span-cap (03-board-ui.md § Motion). heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight }, - side: .live, + container: .board, source: store ) return payload.itemProvider() @@ -1021,7 +1021,7 @@ private struct CardFaceView: View { /// The image travelling under the cursor: this card's face at its real size, fanned with ghosts /// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion). private var dragReplica: some View { - let count = store.selection.liveness == .live && store.selection.ids.contains(card.id) + let count = store.selection.container == .board && store.selection.ids.contains(card.id) ? max(1, store.selection.ids.count) : 1 return ZStack { @@ -1098,17 +1098,17 @@ private struct CardFaceView: View { /// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete /// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked. private var styleTarget: StyleTarget { - guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else { + guard store.selection.container == .board, store.selection.ids.contains(card.id) else { return .items([card.id]) } return .items(store.selection.ids) } /// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set` - /// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here + /// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here /// on the live side). private var targetIDs: Set { - guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else { + guard store.selection.container == .board, store.selection.ids.contains(card.id) else { return [card.id] } return store.selection.ids @@ -1205,7 +1205,7 @@ private struct CardFaceView: View { // MARK: - Selection and rename plumbing private var isSelected: Bool { - store.selection.liveness == .live && store.selection.ids.contains(card.id) + store.selection.container == .board && store.selection.ids.contains(card.id) } /// Whether an external Finder file drag is hovering **this** card — the attach highlight @@ -1330,14 +1330,14 @@ private struct NewCardStubView: View { /// /// The lane is read before the commit, because every discard path clears the overlay that holds /// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting - /// something that renders nowhere would break the homogeneous-by-liveness invariant until the + /// something that renders nowhere would break the one-container invariant until the /// next reload swept it away. @discardableResult private func commit() -> ItemID? { let lane = store.transient.newCardPlaceholder?.laneID let id = store.commitPlaceholder() - if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) { - store.select([lane], liveness: .live) + if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) { + store.select([lane], in: .board) } return id } diff --git a/Kanban/UI/Board/MarqueeSession.swift b/Kanban/UI/Board/MarqueeSession.swift index 89e46f0..117b2a9 100644 --- a/Kanban/UI/Board/MarqueeSession.swift +++ b/Kanban/UI/Board/MarqueeSession.swift @@ -30,7 +30,7 @@ final class MarqueeSession { private(set) var current: CGPoint = .zero /// The side of the live/trash boundary this band selects on, frozen at `begin`. - private(set) var side: Liveness = .live + private(set) var container: ItemContainer = .board /// How far the pointer must travel before a drag on empty space becomes a band. Larger than the /// lane header's threshold because this gesture arms on *any* empty surface, and a click that @@ -51,10 +51,10 @@ final class MarqueeSession { ) } - func begin(at point: CGPoint, side: Liveness) { + func begin(at point: CGPoint, in container: ItemContainer) { origin = point current = point - self.side = side + self.container = container } func update(to point: CGPoint) { @@ -81,9 +81,9 @@ final class MarqueeSession { /// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in /// flight, and a foreign reload — the frames simply re-register. /// -/// **Lanes are never registered.** The band selects cards, and trash rows on the trash side; a lane -/// has no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands -/// across lanes" made structural rather than filtered. +/// **Lanes are never registered.** The band selects cards — board cards or trash cards; a lane has +/// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across +/// lanes" made structural rather than filtered. /// /// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs /// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding diff --git a/Kanban/UI/Board/NewCardTarget.swift b/Kanban/UI/Board/NewCardTarget.swift index 4436d3c..e3520c9 100644 --- a/Kanban/UI/Board/NewCardTarget.swift +++ b/Kanban/UI/Board/NewCardTarget.swift @@ -41,7 +41,7 @@ enum NewCardTarget { /// the two can never disagree. /// /// - Parameters: - /// - selection: the board's current selection, liveness side included. A `.trashed` selection + /// - selection: the board's current selection, container included. A `.trash` selection /// "never anchors creation" and is treated exactly as an empty one — the settled precedent /// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live /// disk-lane never leaks in as 'the selected card's lane'"). @@ -53,7 +53,7 @@ enum NewCardTarget { lastActiveLaneID: ItemID?, snapshot: BoardModel ) -> Resolution? { - let lanes = snapshot.lanes.filter { !$0.isDeleted } + let lanes = snapshot.lanes guard !lanes.isEmpty else { return nil } if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) { @@ -86,21 +86,21 @@ enum NewCardTarget { /// which falls out of never looking at the trashed side at all), and a stale one whose ids name /// nothing the board renders. static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? { - guard selection.liveness == .live, !selection.ids.isEmpty else { return nil } + guard selection.container == .board, !selection.ids.isEmpty else { return nil } // The snapshot's lanes and cards are already in display order, so the flatten order is one // walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only // one of the two branches ever fires within a walk; a sole selection is simply the degenerate // one-member case of the same rule. var anchor: Resolution? - for lane in snapshot.lanes where !lane.isDeleted { + for lane in snapshot.lanes { // A selected lane: creation appends at its bottom, Return consistency; paste lands after // the lane itself. if selection.ids.contains(lane.id) { anchor = Resolution(laneID: lane.id, anchorCardID: nil) } // A selected card: its lane, immediately after it — paste-anchor consistency. - for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) { + for card in lane.cards where selection.ids.contains(card.id) { anchor = Resolution(laneID: lane.id, anchorCardID: card.id) } } diff --git a/Kanban/UI/Board/PasteTarget.swift b/Kanban/UI/Board/PasteTarget.swift index db38b64..5caee3d 100644 --- a/Kanban/UI/Board/PasteTarget.swift +++ b/Kanban/UI/Board/PasteTarget.swift @@ -50,10 +50,10 @@ enum PasteTarget { lastActiveLaneID: lastActiveLaneID, snapshot: snapshot ), - let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID && !$0.isDeleted }) + let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID }) else { return nil } - let rendered = lane.cards.filter { !$0.isDeleted } + let rendered = lane.cards // `insertionIndex` answers `nil` for "append", which is `rendered.count` — the same position // said two ways, and the creation path's own degradation for an anchor that has since gone. let index = BoardStore.insertionIndex(after: resolution.anchorCardID, among: rendered) ?? rendered.count @@ -64,7 +64,7 @@ enum PasteTarget { /// included, because lane paste "stays enabled and lands at the board's right end" whatever the /// board holds. That is what makes it the other way out of a board with no lanes. static func lanes(selection: ItemReferenceSet, snapshot: BoardModel) -> Int { - let lanes = snapshot.lanes.filter { !$0.isDeleted } + let lanes = snapshot.lanes guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot), let position = lanes.firstIndex(where: { $0.id == anchor.laneID }) else { diff --git a/Kanban/UI/Board/SelectionClicks.swift b/Kanban/UI/Board/SelectionClicks.swift index 0128938..e660c88 100644 --- a/Kanban/UI/Board/SelectionClicks.swift +++ b/Kanban/UI/Board/SelectionClicks.swift @@ -47,24 +47,25 @@ struct MarqueeControl { /// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than /// by gesture priority is what keeps the two from fighting, and it stays correct as the /// masonry reflows. - /// - **The side is fixed at the origin** — 04-interactions.md ▸ The trash's rule, stored in the - /// session so a band dragged across the boundary keeps its meaning. + /// - **The container is fixed at the origin** — 04-interactions.md ▸ The trash's rule ("the + /// rubber band stays on the side it started on"), stored in the session so a band dragged + /// across the boundary keeps its meaning. /// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the /// band, so the selection follows the cursor both ways. An empty band clears rather than /// leaving the last non-empty one standing. /// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The /// lock's scope), and no `isEditingInline` guard either — a click-away mid-rename already /// commits through the field's own focus loss. - func gesture(side: Liveness) -> some Gesture { + func gesture(in container: ItemContainer) -> some Gesture { DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace)) .onChanged { value in if !session.isActive { guard !registry.contains(value.startLocation) else { return } - session.begin(at: value.startLocation, side: side) + session.begin(at: value.startLocation, in: container) } session.update(to: value.location) guard let rect = session.rect else { return } - let ids = MarqueeMath.selection(rect: rect, targets: registry.all, side: session.side) + let ids = MarqueeMath.selection(rect: rect, targets: registry.all, in: session.container) if ids.isEmpty { store.clearSelection() } else { @@ -74,7 +75,7 @@ struct MarqueeControl { // Both are spelled out rather than defaulted, because `select`'s sole-member // default would otherwise pick one up the moment a band happened to sweep // exactly one card. - store.select(ids, liveness: session.side, anchor: nil, head: nil) + store.select(ids, in: session.container, anchor: nil, head: nil) } } .onEnded { _ in session.end() } @@ -111,7 +112,7 @@ extension View { func marqueeTarget( _ id: ItemID, kind: SelectionKind, - side: Liveness, + container: ItemContainer, in registry: MarqueeTargetRegistry ) -> some View { // The space name is read here, on the main actor, rather than inside the measuring closure: @@ -120,7 +121,7 @@ extension View { return onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .named(space)) } action: { frame in - registry.update(MarqueeTarget(id: id, kind: kind, side: side, frame: frame)) + registry.update(MarqueeTarget(id: id, kind: kind, container: container, frame: frame)) } .onDisappear { registry.remove(id) } } diff --git a/Kanban/UI/Board/TrashCommands.swift b/Kanban/UI/Board/TrashCommands.swift index 3d205f3..e0a0574 100644 --- a/Kanban/UI/Board/TrashCommands.swift +++ b/Kanban/UI/Board/TrashCommands.swift @@ -21,8 +21,8 @@ final class TrashConfirmations { /// 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. + /// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on a + /// card that has since gone — the writer treats an absent folder as success. private(set) var pending: Pending? struct Pending: Identifiable, Equatable { @@ -30,20 +30,46 @@ final class TrashConfirmations { 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. + /// What the confirmation is standing in front of. Three cases, because the three commands + /// have genuinely different scopes and two different writes: the trash's own staged Delete, + /// Delete Immediately (which skips the trash from either container), and Empty Trash (which + /// names the whole container and re-derives its targets at the moment it runs). enum Action: Equatable { + case deleteTrashCards(Set) case purge(Set) case emptyTrash } } + /// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the + /// trash side owes and the board side does not. + /// + /// A board selection goes straight through: moving a card into the trash and deleting a lane are + /// both recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither + /// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where + /// the loss is real": `purgeIsUnrecoverable` decides, exactly as it does for Delete Immediately. + /// + /// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and + /// nothing else — the two can never disagree about which write a ⌘⌫ performs. + func requestDelete(in store: BoardStore) { + guard store.selection.container == .trash, store.purgeIsUnrecoverable else { + store.deleteSelection() + return + } + guard let prompt = TrashModel.purgePrompt( + for: store.selection.ids, + in: .trash, + snapshot: store.snapshot, + unrecoverable: true + ) else { return } + pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids)) + } + /// Raises Delete Immediately's alert — **or purges outright** where the loss is not real. /// /// The mode check is the one thing that decides between the two, and it lives on the store as a /// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one - /// expression rather than two call sites. + /// expression rather than three call sites. func requestPurge(of ids: Set, in store: BoardStore) { guard store.purgeIsUnrecoverable else { store.deleteImmediately(ids) @@ -51,7 +77,8 @@ final class TrashConfirmations { } guard let prompt = TrashModel.purgePrompt( for: ids, - in: store.snapshot, + in: store.selection.container, + snapshot: store.snapshot, unrecoverable: true ) else { return } pending = Pending(prompt: prompt, action: .purge(ids)) @@ -73,6 +100,7 @@ final class TrashConfirmations { guard let pending else { return } self.pending = nil switch pending.action { + case let .deleteTrashCards(ids): store.deleteTrashCards(ids) case let .purge(ids): store.deleteImmediately(ids) case .emptyTrash: store.emptyTrash() } @@ -96,22 +124,17 @@ extension FocusedValues { } } -// MARK: - File ▸ Delete / Put Back / Delete Immediately / Empty Trash… +// MARK: - File ▸ Delete / Delete Immediately / Empty Trash… /// The File menu's trash rows (11-command-nexus.md). /// -/// ### The ⌘⌫ chord twins +/// ### One Delete, staged by place /// -/// 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. +/// **Put Back is retired with the tombstone model** (04-interactions.md ▸ The map, resettled +/// 2026-07-28): "File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent +/// routing". The ⌘⌫ chord therefore has exactly one owner, its validation is one predicate +/// (`TrashModel.canDelete`), and which write it performs is decided by the selection's *container* +/// inside the store rather than by AppKit picking whichever of two items happened to be enabled. struct TrashCommands: View { @FocusedValue(\.boardStore) private var store @@ -119,24 +142,18 @@ struct TrashCommands: View { var body: some View { Button("Delete") { - store?.deleteSelection() + guard let store, let confirmations else { return } + confirmations.requestDelete(in: store) } .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) + .disabled(!canDelete || confirmations == nil) Button("Delete Immediately") { guard let store, let confirmations else { return } confirmations.requestPurge(of: store.selection.ids, in: store) } .keyboardShortcut(.delete, modifiers: [.option, .command]) - .disabled(!canActOnTrash || confirmations == nil) + .disabled(!canDeleteImmediately || confirmations == nil) Button("Empty Trash…") { guard let store, let confirmations else { return } @@ -146,29 +163,29 @@ struct TrashCommands: View { .disabled(!canEmptyTrash) } - /// A live, non-empty selection on a board that accepts writes. + /// A non-empty selection that still names something, on a board that accepts writes — both + /// stagings at once, which is what having one item means. 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 { + /// A **card** selection, in either container — "skips the trash from anywhere" + /// (11-command-nexus.md). + private var canDeleteImmediately: Bool { guard let store, store.acceptsBoardMutations else { return false } - return TrashModel.canActOnTrash(selection: store.selection, in: store.snapshot) + return TrashModel.canDeleteImmediately(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"). + /// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu + /// validation's non-empty reads `.trash/`, not the filtered view"). /// - /// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is + /// The visibility clause is 04-interactions.md's, stated for the whole column: "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) + return !store.snapshot.trash.isEmpty } } @@ -197,12 +214,10 @@ struct ShowTrashCommand: View { /// 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. + /// **Hiding drops a trash selection** (04-interactions.md ▸ The trash: "hiding it clears a + /// selection of trash cards — nothing invisible stays selected, so the toggle-off drops the + /// selection rather than leave commands enabled against rows nobody can see"). A *board* + /// selection is untouched — the board it names is still right there. /// /// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's /// Show Trash item is this same command with a different face (03-board-ui.md ▸ Toolbar: "toggle @@ -237,7 +252,7 @@ extension BoardStore { // very animation, and a selection that cleared outside it would be the highlight easing // on its own — which 03 § Motion rules out ("the selection highlight rides whatever // transaction is active"). - if !shown, selection.liveness == .trashed { + if !shown, selection.container == .trash { clearSelection() } } diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index af0e4c0..f2a97c0 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -4,63 +4,55 @@ import SwiftUI // MARK: - TrashLaneView -/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in -/// (03-board-ui.md § Trash). +/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in +/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash). /// -/// ### A pure view, and a quasi-lane +/// ### A rendering of `snapshot.trash`, 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: +/// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column +/// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use +/// and sorted by `order` like any lane's children. Newest-first falls out of the ranks (every +/// arrival mints one above the current top), so there is no timestamp sort and no entry type here at +/// all. 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 drop proposal's slot list by construction, since `BoardView` builds that from the -/// snapshot's live lanes; -/// - it is **no destination for a move** — "no move or paste ever targets the trash" -/// (04-interactions.md ▸ The trash). It is a destination for exactly one thing, below; +/// snapshot's lanes; /// - it has **no new-card button**: nothing is created in the trash. /// -/// ### The one drop it takes: the delete gesture +/// ### The drop it takes, and the drag it starts /// -/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash, settled -/// 2026-07-28) — "the drag becomes the pointer's delete gesture; release tombstones the dragged -/// card(s), exactly the ⌫ tombstone". So the column does declare an `onDrop` -/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this** -/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would -/// be a transfer-and-delete compound), ⌥ is not (copying into the trash is not a thing), and hidden -/// the column is not rendered at all, so it has no region to enter. Every one of those refusals hands -/// the session back to the strip's own logic, which is exactly what happened here before this target -/// existed — so nothing about the column's behaviour changed except the gesture that is new -/// (`TrashDrop`). +/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the +/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`. +/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the +/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one +/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost +/// row**, because every arrival mints a rank above the current top. /// -/// It diverges from every other drop on the board in one way, and the sort is what makes the -/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by -/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly -/// where the shadow shows. +/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an +/// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it +/// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an +/// ordinary move out … there is no restore-specific machinery and no Put Back" (03 § Trash). /// -/// **Finder file drops stay inert** — "attachment import on tombstoned cards is inert" (▸ The trash) -/// — and now say so directly: the delegate clears the file highlight over the column rather than -/// relying on the strip resolving to no lane. +/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) — +/// and say so directly: the delegate clears the file highlight over the column. /// /// ### 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. +/// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out +/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row. /// -/// ### What is still a later card's +/// ### What is still phase 3's /// -/// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like -/// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads -/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the -/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with -/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The -/// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in -/// and out, ⇧-arrows inert at both the liveness and the kind boundary — so nothing in this file -/// implements it beyond keeping every row drawn and registered (see `rows`). +/// The column renders the materialized trash correctly and its selection, drag, drop, filter and +/// context menu all speak the new container vocabulary — but its *visual* treatment is still the +/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is +/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the +/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card. struct TrashLaneView: View { let store: BoardStore @@ -71,13 +63,13 @@ struct TrashLaneView: View { /// does. let confirmations: TrashConfirmations - /// The board window's drop machinery — a row's drag is an ordinary card session on the - /// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash). + /// The board window's drop machinery — a card's drag is an ordinary card session in the + /// **trash** container (`DragSession`, 04-interactions.md ▸ The trash). let drops: BoardDropContext - /// The strip's rubber band. The column's empty space is its third surface, on the **trashed** - /// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸ - /// The trash) — and every row registers its frame into the same registry. + /// The strip's rubber band. The column's empty space is its third surface, in the **trash** + /// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The + /// trash) — and every row registers its frame into the same registry. let marquee: MarqueeControl /// Reduce Motion, for the row transition below — 10-accessibility.md names the trash @@ -91,9 +83,9 @@ struct TrashLaneView: View { private let rowSpacing: CGFloat = 6 /// The height a shadow row holds open. A trash row's height is content-driven (one or two title - /// lines, plus a lane entry's count line) and the cards being proposed have no row yet to be - /// measured, so the shadow is drawn at the nominal single-line plate — `LaneDropRegistry`'s own - /// answer to the same question, in this column's smaller idiom. + /// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at + /// the nominal single-line plate — `LaneDropRegistry`'s own answer to the same question, in this + /// column's smaller idiom. private let nominalRowHeight: CGFloat = 32 var body: some View { @@ -113,16 +105,16 @@ struct TrashLaneView: View { .onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops)) } - /// The rows the column shows. + /// The cards the column shows. /// - /// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so - /// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards` - /// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — and the - /// count badge follows for free, because it reads this same value. Hidden, the column renders - /// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all. - private var entries: [TrashEntry] { + /// **Shown, the trash's cards "participate in the filter exactly like any other card"** + /// (03-board-ui.md § Trash — "the point of the pivot"), 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. Hidden, the column renders nothing and registers + /// nothing, so "hidden trash is invisible to search" needs no code at all. + private var cards: [Card] { let filter = store.searchFilter - return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) } + return store.snapshot.trash.filter { filter.matches($0) } } // MARK: - The delete gesture's landing @@ -140,7 +132,7 @@ struct TrashLaneView: View { /// keeps the arrangement the release proposed on screen for that round trip, exactly as every /// other container's does (`CommittedHold`). private var slots: [TrashSlot] { - var result = entries.map(TrashSlot.entry) + var result = cards.map(TrashSlot.card) guard let proposal else { return result } let run = (0.. String { "entry:\(item.rawValue)" } } @@ -381,145 +362,113 @@ private struct DiagonalHatch: Shape { // 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. +/// One trash row: a compact, dimmed plate carrying the card's symbol and title. /// /// **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…. 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 { +/// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's — see +/// `TrashLaneView`.) +private struct TrashCardRow: View { let store: BoardStore - let entry: TrashEntry + let card: Card let confirmations: TrashConfirmations let drops: BoardDropContext - /// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed - /// side (`View.marqueeTarget`). + /// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash + /// container (`View.marqueeTarget`). let registry: MarqueeTargetRegistry - /// **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 rather than refused — no session, no image, no snap-back. A click - /// still selects either way. - @ViewBuilder var body: some View { - if entry.isLaneEntry { - plate - } else { - plate.onDrag(startRowDrag, preview: { dragReplica }) - } + plate.onDrag(startRowDrag, preview: { dragReplica }) } private var plate: some View { rowFace // The row being dragged out dims in place — the source stays visible in the trash, // because a restore is not a removal until the write lands. - .opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1) - // The deferred cut wears the same dim wherever it lands, so the treatment is stated for - // every surface a `pendingCut` could name rather than for two of the three. In practice - // it never fires here: ⌘X is disabled on tombstoned selections (04-interactions.md ▸ The - // trash), and a pending cut is homogeneous by liveness — a reload that tombstones a cut - // card *ejects* it from the set rather than moving it to the other side. - .cutTreatment(of: entry.id, in: store) + .opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1) + // The deferred cut wears the same dim wherever it lands. It genuinely fires here now: + // "⌘X works — cut in the trash, paste into a lane is the keyboard-native restore" + // (04-interactions.md ▸ The trash, resettled 2026-07-28). + .cutTreatment(of: card.id, in: store) .contentShape(Rectangle()) .onTapGesture { select() } - .marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry) + .marqueeTarget(card.id, kind: .card, container: .trash, in: registry) .contextMenu { menu } } - /// This entry as the shared plate draws it — appearance only, no gesture, no context menu and + /// This card as the shared plate draws it — appearance only, no gesture, no context menu and /// crucially no marquee registration, which is what makes it safe for the drag replica to render /// (see `TrashRowPlate`). private var rowFace: some View { TrashRowPlate( - symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback), - title: entry.title, - subtitle: laneSubtitle, + symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card), + title: card.title.value, isSelected: isSelected ) } - private var symbolFallback: String { - entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card - } - - /// "N cards" — what Put Back brings back with a tombstoned lane, not how many folders sit inside - /// it (`TrashModel.entries`' returning-count rule). Card entries have no second line. - private var laneSubtitle: String? { - guard case let .lane(_, returning) = entry else { return nil } - return "\(returning) card\(returning == 1 ? "" : "s")" - } - // MARK: - Selection private var isSelected: Bool { - store.selection.liveness == .trashed && store.selection.ids.contains(entry.id) + store.selection.container == .trash && store.selection.ids.contains(card.id) } - /// A click selects this row on the **trashed** side, through the same grammar the board's + /// A click selects this card in the **trash** container, through the same grammar the board's /// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`). /// - /// The row's kind travels with the click, and that is what keeps the trash's second homogeneity - /// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a - /// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat` - /// — click-again-to-unselect is the lane's behaviour, not a row's. + /// The container travels with the click, and that is what keeps the one remaining homogeneity + /// boundary true: a ⌘-click across it replaces rather than mixing (04-interactions.md ▸ The + /// trash). There is no kind axis inside the trash any more — lanes are never trashed. No + /// `togglesOnRepeat` — click-again-to-unselect is the lane's behaviour, not a card's. /// /// **A double click is two of these and nothing more**: no editor, no card window, no timer. private func select() { store.click( - SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed), + SelectionTarget(id: card.id, kind: .card, container: .trash), modifier: .current ) } // MARK: - Drag out - /// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**, - /// which is the whole of what makes it a restore rather than a move (04-interactions.md ▸ The - /// trash; `DragLocality.operation`). + /// Begins the card's drag out of the trash — an ordinary **card session in the trash container**, + /// which is the whole of what makes it a restore (04-interactions.md ▸ The trash; + /// `DragLocality.operation`). /// /// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the - /// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every - /// other card drop uses. What a release *means* differs by locality and modifier, and that lives - /// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out, - /// across boards a live copy with ⌘ forcing the true restore-move. + /// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic + /// every other card drop uses, committed by the same `moveCards`. /// - /// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order — the - /// order the rows are drawn in, which is the only relative order a set of tombstones has. + /// **Multi-drag carries the whole trash selection**, in the column's own order — the order the + /// rows are drawn in, which is `order` ascending like any lane's. /// /// Refused under the read-only lock and while an inline editor is focused, like every other /// mutating gesture. private func startRowDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let selection = store.selection - let ids: Set = selection.liveness == .trashed - && selection.ids.contains(entry.id) + let ids: Set = selection.container == .trash + && selection.ids.contains(card.id) && selection.ids.count > 1 ? selection.ids - : [entry.id] + : [card.id] - // Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed - // selection is simply not carried. (The selection is homogeneous by kind anyway — this is - // belt over braces.) - let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot) - .compactMap { candidate in - guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil } - return (card.id, laneID, card.title.value) - } + let rows = store.snapshot.trash.filter { ids.contains($0.id) } guard !rows.isEmpty else { return NSItemProvider() } let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .cards, - side: .trashed, + container: .trash, items: rows.map { DragPayload.Item( id: $0.id.rawValue, - folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path, - title: $0.title + folder: ItemPath.trashCard($0.id).folder(under: root).path, + title: $0.title.value ) } ) @@ -527,7 +476,7 @@ private struct TrashEntryRow: View { rows.map(\.id), folders: payload.folders, heights: rows.map { _ in LaneDropRegistry.nominalCardHeight }, - side: .trashed, + container: .trash, source: store ) return payload.itemProvider() @@ -536,7 +485,7 @@ private struct TrashEntryRow: View { /// The image under the cursor: the row as it is drawn, fanned with a count badge for a /// multi-drag — the card replica's treatment, at a trash row's size. private var dragReplica: some View { - let count = store.selection.liveness == .trashed && store.selection.ids.contains(entry.id) + let count = store.selection.container == .trash && store.selection.ids.contains(card.id) ? max(1, store.selection.ids.count) : 1 return ZStack { @@ -549,22 +498,18 @@ private struct TrashEntryRow: View { .padding(12) } - // MARK: - The trash entry's context menu + // MARK: - The trash card's context menu - /// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a - /// trash entry, and no others. + /// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no + /// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder"). /// + /// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash). /// 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. + /// enabled on trash 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") { + Button("Delete") { confirmations.requestPurge(of: targetIDs, in: store) } .disabled(!store.acceptsBoardMutations) @@ -578,17 +523,16 @@ private struct TrashEntryRow: View { /// 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. + /// header apply to Style…. private var targetIDs: Set { - guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else { - return [entry.id] + guard store.selection.container == .trash, store.selection.ids.contains(card.id) else { + return [card.id] } return store.selection.ids } private var targetFolders: [URL] { - TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot) + ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot) .map { $0.folder(under: store.rootURL) } } } diff --git a/KanbanTests/AppModelTests.swift b/KanbanTests/AppModelTests.swift index 9783b3a..ac213b1 100644 --- a/KanbanTests/AppModelTests.swift +++ b/KanbanTests/AppModelTests.swift @@ -24,22 +24,12 @@ private func makeMixedBoard() throws -> WriterFixture { 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)", Item.rich(order: "2048", title: "Second")) - try fixture.item( - "\(Ident.lane1)/\(Ident.card3)", - "---\nschema: 1\norder: 3072\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" - ) - try fixture.item( - Ident.lane2, - "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" - ) - try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried")) - try fixture.item( - "\(Ident.lane2)/\(Ident.indexless)", - "---\nschema: 1\norder: 2048\ntitle: Also buried\n---\nbody\n" - ) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Archive")) - try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Doing")) + // The trash: cards in a sibling container, never lanes (03-board-ui.md § Trash). + try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Gone")) + try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "2048", title: "Also gone")) return fixture } @@ -76,54 +66,35 @@ struct AppModelTests { // MARK: Live-only counts - @Test("The recents counts are live items only, at both levels") - func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws { + @Test("The recents counts are working items only — the trash is an errand, not inventory") + func liveCountsExcludeTheTrash() throws { let fixture = try makeMixedBoard() defer { fixture.tearDown() } let snapshot = try BoardLoader.load(boardRoot: fixture.root).model - // The snapshot itself keeps everything — tombstones are what the trash renders — so this is a - // genuine filter, not a property of the load. - #expect(snapshot.lanes.count == 3) - #expect(snapshot.lanes.flatMap(\.cards).count == 5) + // The snapshot itself keeps everything — the trash is a sibling container — so this is a + // genuine exclusion, not a property of the load. + #expect(snapshot.lanes.count == 2) + #expect(snapshot.trash.count == 2) let counts = AppModel.liveCounts(of: snapshot) - #expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size") - #expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane") + #expect(counts.lanes == 2) + // 02 § Per-board app state, re-grounded 2026-07-28: "cards in `.trash/` don't count; the row + // advertises the board's working size". The walk reads `snapshot.lanes` and the trash is + // `snapshot.trash`, so the exclusion is by construction and none could be forgotten. + #expect(counts.cards == 2) } - @Test("A board with nothing live counts zero rather than declining to answer") + @Test("A board with nothing on it counts zero rather than declining to answer") func liveCountsOfAnEmptyBoard() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) - try fixture.item( - Ident.lane1, - "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" - ) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Buried")) + try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Gone")) let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model) #expect(counts.lanes == 0) - #expect(counts.cards == 0) - } - - @Test("A malformed deleted: still counts as deleted") - func liveCountsFollowPresenceNotValidity() 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)", - "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: yesterday\n---\nbody\n" - ) - - // The presence of the key is what encodes deletion intent (`Card.isDeleted`), so an - // unparseable timestamp hides the card here exactly as it hides it on the board. - let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model) - #expect(counts.lanes == 1) - #expect(counts.cards == 0) + #expect(counts.cards == 0, "a board whose only content is trash advertises no working size") } // MARK: Display name @@ -241,7 +212,7 @@ struct AppModelTests { #expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session") let record = try #require(model.boardRegistry.record(id: recordID)) - #expect(record.laneCount == 2, "the counts the welcome row will show are the live ones") + #expect(record.laneCount == 2, "the counts the welcome row will show are the working ones") #expect(record.cardCount == 2) #expect(record.isOpenNow == false) #expect(model.boardRegistry.restorables().isEmpty) diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index 306314a..16f2371 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -364,7 +364,7 @@ struct BoardStoreTests { #expect(!ran) // Reading stays live throughout — keeping the last-good snapshot is the point of the lock. - store.select([ItemID(rawValue: Ident.card2)], liveness: .live) + store.select([ItemID(rawValue: Ident.card2)], in: .board) #expect(store.selection.ids.count == 1) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired")) @@ -421,50 +421,48 @@ struct BoardStoreTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) + store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)]) - #expect(store.selection.liveness == .live) + #expect(store.selection.container == .board) } - @Test("A liveness flip is a vanish: a selected card tombstoned externally leaves the selection") - func selectionEjectsALivenessFlip() async throws { + @Test("A container crossing is a vanish: a selected card trashed externally leaves the selection") + func selectionEjectsAContainerCrossing() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) + store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() - // Still in the snapshot — the trash renders it — but no longer on the selection's side of - // the boundary, so the homogeneous-by-liveness invariant survives a foreign edit. - let flipped = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 } - #expect(flipped?.isDeleted == true) + // Still in the snapshot — the trash renders it — but no longer in the selection's container, + // so 04's one-container invariant survives a foreign edit (02-architecture.md's reload rule, + // resettled 2026-07-28: "re-resolution matches UUID *and* container side"). + #expect(store.snapshot.trash.map(\.id.rawValue) == [Ident.card1]) #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)]) } - @Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective") - func selectionEjectsCardsUnderATombstonedLane() async throws { + @Test("Deleting a lane externally takes its cards out of the selection with it") + func selectionEjectsCardsUnderARemovedLane() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) + store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board) - try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Lane one")) + // A lane delete is physical now (03-board-ui.md § Trash), so the cards genuinely go with it + // — no ancestor walk needed, and none left to do: presence is the whole test. + try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() - // The cards' own flags never changed, but their lane's did — and liveness is - // ancestor-walked (02, settled): the cards render nowhere once 03 collapses the lane to - // a single trash entry, and nothing invisible may stay selected. - let survivor = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 } - #expect(survivor?.isDeleted == false, "the card's own flag is untouched") + #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 } == nil) #expect(store.selection.ids.isEmpty) } @@ -473,7 +471,7 @@ struct BoardStoreTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live) + store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) @@ -481,9 +479,9 @@ struct BoardStoreTests { await store.awaitQuiescence() #expect(store.selection.ids.isEmpty) - #expect(store.selection.liveness == .live, "the side survives even when the membership does not") + #expect(store.selection.container == .board, "the container survives even when the membership does not") - store.select([ItemID(rawValue: Ident.lane1)], liveness: .live) + store.select([ItemID(rawValue: Ident.lane1)], in: .board) store.clearSelection() #expect(store.selection == .empty) } diff --git a/KanbanTests/CardAttachmentsTests.swift b/KanbanTests/CardAttachmentsTests.swift index 655c984..c72032f 100644 --- a/KanbanTests/CardAttachmentsTests.swift +++ b/KanbanTests/CardAttachmentsTests.swift @@ -191,13 +191,12 @@ struct RemoveAttachmentStoreTests { try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02])) let store = try BoardStore(rootURL: fixture.root) - store.removeAttachment(named: "shot.png", fromCard: card2) // tombstoned + store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.lane2)) // a lane store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.indexless)) // no such card store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane store.removeAttachment(named: "", fromCard: card1) // no name #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"]) - #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["shot.png"]) #expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure") } diff --git a/KanbanTests/CardWindowFateTests.swift b/KanbanTests/CardWindowFateTests.swift index 7876210..8c30ec8 100644 --- a/KanbanTests/CardWindowFateTests.swift +++ b/KanbanTests/CardWindowFateTests.swift @@ -3,14 +3,18 @@ import Testing @testable import Kanban /// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still -/// name a card? Four answers, three of which are "no" for different reasons, and the one that is -/// easiest to get wrong — a live card under a tombstoned lane — is invisible in the card's own data. -/// So the decision is a pure function and this is its suite; nothing here needs a window. +/// name a card **on the board**? The decision is a pure function and this is its suite; nothing here +/// needs a window. +/// +/// **The walk got simpler with the materialized trash** (05-card-window.md ▸ Deletion & lifecycle, +/// resettled 2026-07-28): "entering the trash counts as deleted", and a trashed card's folder has +/// physically left its lane — so "is it under one of this board's lanes" is the whole question, and +/// the tombstone era's ancestor walk is gone. // MARK: - Fixtures -/// - lane 1 (live): one live card, one tombstoned card -/// - lane 2 (**tombstoned**): one live card, whose own flag is clear +/// - lane 1: one card +/// - the trash: one card, moved there by a delete @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() @@ -18,16 +22,7 @@ private func makeBoard() throws -> WriterFixture { try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) - try fixture.item( - "\(Ident.lane1)/\(Ident.card2)", - "---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" - ) - - try fixture.item( - Ident.lane2, - "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" - ) - try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried alive")) + try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Gone")) return fixture } @@ -62,30 +57,29 @@ struct CardWindowFateTests { #expect(laneTitle(fate) == "Todo") } - @Test("A tombstoned card dismisses its window") - func aTombstonedCardDismisses() throws { + @Test("A card in the trash dismisses its window") + func aTrashedCardDismisses() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try BoardLoader.load(boardRoot: fixture.root).model - // ⌫ on the board closes the card's open window — "a tombstone counts as deleted" - // (05-card-window.md). The card is still in the snapshot; the trash renders it. - #expect(snapshot.lanes[0].cards.contains { $0.id.rawValue == Ident.card2 }) + // ⌫ on the board closes the card's open window — "entering the trash counts as deleted" + // (05-card-window.md). The card is still in the snapshot; the trash column renders it. + #expect(snapshot.trash.contains { $0.id.rawValue == Ident.card2 }) #expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses) } - @Test("A live card under a tombstoned lane dismisses too — liveness is ancestor-walked") - func aLaneTombstoneDismissesItsCards() throws { + @Test("Deleting a card's lane dismisses its window, because the card is gone with it") + func aLaneDeleteDismissesItsCards() throws { let fixture = try makeBoard() defer { fixture.tearDown() } + try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) let snapshot = try BoardLoader.load(boardRoot: fixture.root).model - // The card's own flag says nothing is wrong. Its lane's does, and 03-board-ui.md collapses a - // tombstoned lane to one restorable trash entry — so the card renders nowhere, and a window - // onto something that renders nowhere is the case this walk exists for. - let buried = try #require(snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.first) - #expect(!buried.isDeleted) - #expect(CardWindowHost.cardWindowFate(cardID: Ident.card3, in: snapshot) == .dismisses) + // "Deleting the card's *lane* deletes the card with it — the window dismisses because the + // card is gone" (05). A lane delete is physical, so this needs no ancestor walk: the card + // is simply not in the snapshot. + #expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses) } @Test("A card that is not in this board's snapshot dismisses — the cross-board move") diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index 3fd47c3..7c0e973 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -52,21 +52,21 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2) let clipboardCard3 = ItemID(rawValue: Ident.card3) let clipboardCard4 = ItemID(rawValue: Ident.card4) -func tombstonedItem(order: String, title: String) -> String { +/// An ordinary card body, for the trash's resident. +func trashResidentItem(order: String, title: String) -> String { """ --- schema: 1 title: \(title) order: \(order) created: 2026-01-01T09:00:00Z - deleted: 2026-03-03T09:00:00Z --- \(title) body. """ } -/// Two lanes: `lane1` holds three live cards and one tombstoned one, `lane2` holds a single card. +/// Two lanes: `lane1` holds two cards, `lane2` holds a single card, and one card sits in the trash. /// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the /// fallback lost exactly two files" both assertable. @MainActor @@ -78,9 +78,9 @@ func makeClipboardBoard() throws -> WriterFixture { try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8)) try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8)) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) - try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstonedItem(order: "3072", title: "Trashed")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) + try fixture.item(".trash/\(Ident.card3)", trashResidentItem(order: "1024", title: "Trashed")) return fixture } @@ -140,7 +140,7 @@ struct ClipboardManifestTests { copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true), kind: .card, - side: .live, + container: .board, entries: [entry(Ident.card1)] ) let data = try #require(manifest.encoded()) @@ -153,7 +153,7 @@ struct ClipboardManifestTests { copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), kind: .card, - side: .live, + container: .board, entries: [entry(Ident.card1)] ) manifest.version = ClipboardManifest.currentVersion + 1 @@ -167,7 +167,7 @@ struct ClipboardManifestTests { copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), kind: .card, - side: .live, + container: .board, entries: [] ) let data = try #require(manifest.encoded()) @@ -200,7 +200,7 @@ struct ClipboardManifestTests { copyID: "abc", boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), kind: .card, - side: .live, + container: .board, entries: [titled, untitled] ) #expect(manifest.plainText == "First\nUntitled") @@ -218,7 +218,7 @@ struct ClipboardCopyTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() @@ -238,12 +238,12 @@ struct ClipboardCopyTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1, clipboardCard2], liveness: .live) + harness.store.select([clipboardCard1, clipboardCard2], in: .board) harness.clipboard.copy(from: harness.store) let manifest = try #require(harness.clipboard.payload) #expect(manifest.kind == .card) - #expect(manifest.side == .live) + #expect(manifest.container == .board) #expect(manifest.rootURL.path == harness.fixture.root.path) // Flatten order — lane `order`, then card `order`. #expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2]) @@ -258,7 +258,7 @@ struct ClipboardCopyTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) let manifest = try #require(harness.clipboard.payload) @@ -266,12 +266,12 @@ struct ClipboardCopyTests { #expect(manifest.entries[0].index == onDisk) } - @Test("A lane copy embeds its live cards and leaves the tombstoned one out") - func laneEntryEmbedsLiveCards() async throws { + @Test("A lane copy embeds exactly its cards — the trash is board-level, so none is nested") + func laneEntryEmbedsItsCards() async throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardLane1], liveness: .live) + harness.store.select([clipboardLane1], in: .board) harness.clipboard.copy(from: harness.store) let manifest = try #require(harness.clipboard.payload) @@ -281,17 +281,17 @@ struct ClipboardCopyTests { #expect(manifest.entries[0].lostAttachmentCount == 2) } - @Test("A trashed selection copies out, side recorded") - func trashedSide() async throws { + @Test("A trash selection copies out, container recorded") + func trashContainerRecorded() async throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } harness.store.transient.isTrashVisible = true - harness.store.select([clipboardCard3], liveness: .trashed) + harness.store.select([clipboardCard3], in: .trash) harness.clipboard.copy(from: harness.store) let manifest = try #require(harness.clipboard.payload) - #expect(manifest.side == .trashed) + #expect(manifest.container == .trash) #expect(manifest.entries.map(\.id) == [Ident.card3]) } @@ -310,12 +310,12 @@ struct ClipboardCopyTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() let first = try #require(harness.clipboard.payload?.copyID) - harness.store.select([clipboardCard2], liveness: .live) + harness.store.select([clipboardCard2], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() let second = try #require(harness.clipboard.payload?.copyID) @@ -365,7 +365,7 @@ struct ClipboardSweepTests { withIntermediateDirectories: true ) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() @@ -378,7 +378,7 @@ struct ClipboardSweepTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() #expect(try harness.stagedCopyIDs().count == 1) @@ -402,7 +402,7 @@ struct ClipboardTakeoverTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) #expect(harness.clipboard.payload != nil) @@ -417,7 +417,7 @@ struct ClipboardTakeoverTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) let before = harness.pasteboard.changeCount harness.clipboard.refresh() @@ -432,7 +432,7 @@ struct ClipboardTakeoverTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.cut(from: harness.store) #expect(harness.store.transient.pendingCut.ids == [clipboardCard1]) @@ -453,11 +453,11 @@ struct ClipboardCutTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1, clipboardCard4], liveness: .live) + harness.store.select([clipboardCard1, clipboardCard4], in: .board) harness.clipboard.cut(from: harness.store) #expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4]) - #expect(harness.store.transient.pendingCut.liveness == .live) + #expect(harness.store.transient.pendingCut.container == .board) } @Test("A second copy voids the pending cut — its pasteboard entry has been overwritten") @@ -465,21 +465,21 @@ struct ClipboardCutTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.cut(from: harness.store) #expect(!harness.store.transient.pendingCut.isEmpty) - harness.store.select([clipboardCard2], liveness: .live) + harness.store.select([clipboardCard2], in: .board) harness.clipboard.copy(from: harness.store) #expect(harness.store.transient.pendingCut.isEmpty) } - @Test("Deletion voids per item: a tombstoned cut member leaves the pending set on reload") + @Test("Deletion voids per item: a deleted cut member leaves the pending set on reload") func deletionVoidsPerItem() async throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1, clipboardCard2], liveness: .live) + harness.store.select([clipboardCard1, clipboardCard2], in: .board) harness.clipboard.cut(from: harness.store) harness.store.delete([clipboardCard1]) @@ -502,19 +502,22 @@ struct ClipboardAvailabilityTests { defer { harness.tearDown() } #expect(harness.clipboard.canCopy(from: harness.store) == false) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) #expect(harness.clipboard.canCopy(from: harness.store)) } - @Test("Copy works on a trashed selection; cut does not") - func trashIsCopyOutOnly() throws { + /// 04-interactions.md ▸ The trash, resettled 2026-07-28: "⌘X **works** (it was disabled under + /// the tombstone model): cut in the trash, paste into a lane is the keyboard-native restore, an + /// ordinary folder move." + @Test("Both copy and cut work on a trash selection — cut is the keyboard restore") + func trashTakesCopyAndCut() throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } harness.store.transient.isTrashVisible = true - harness.store.select([clipboardCard3], liveness: .trashed) + harness.store.select([clipboardCard3], in: .trash) #expect(harness.clipboard.canCopy(from: harness.store)) - #expect(harness.clipboard.canCut(from: harness.store) == false) + #expect(harness.clipboard.canCut(from: harness.store)) } @Test("The read-only lock blocks cut but never copy") @@ -522,7 +525,7 @@ struct ClipboardAvailabilityTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.store.enterVanishedRootLock() #expect(harness.clipboard.canCopy(from: harness.store)) #expect(harness.clipboard.canCut(from: harness.store) == false) @@ -534,7 +537,7 @@ struct ClipboardAvailabilityTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First") @@ -549,7 +552,7 @@ struct ClipboardAvailabilityTests { defer { harness.tearDown() } #expect(harness.clipboard.canPaste(into: harness.store) == false) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) #expect(harness.clipboard.canPaste(into: harness.store)) } @@ -573,11 +576,11 @@ struct ClipboardAvailabilityTests { let sourceStore = try BoardStore(rootURL: source.root) let emptyStore = try BoardStore(rootURL: empty.root) - sourceStore.select([clipboardCard1], liveness: .live) + sourceStore.select([clipboardCard1], in: .board) clipboard.copy(from: sourceStore) #expect(clipboard.canPaste(into: emptyStore) == false) - sourceStore.select([clipboardLane1], liveness: .live) + sourceStore.select([clipboardLane1], in: .board) clipboard.copy(from: sourceStore) #expect(clipboard.canPaste(into: emptyStore)) } @@ -600,7 +603,7 @@ struct PasteTargetTests { let model = try snapshot(fixture) let target = PasteTarget.cards( - selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live), + selection: ItemReferenceSet(ids: [clipboardCard1], container: .board), lastActiveLaneID: nil, snapshot: model ) @@ -614,11 +617,11 @@ struct PasteTargetTests { let model = try snapshot(fixture) let target = PasteTarget.cards( - selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live), + selection: ItemReferenceSet(ids: [clipboardLane1], container: .board), lastActiveLaneID: nil, snapshot: model ) - // Two rendered cards — the tombstoned third is not in the layout. + // Two rendered cards. #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2)) } @@ -629,7 +632,7 @@ struct PasteTargetTests { let model = try snapshot(fixture) let target = PasteTarget.cards( - selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live), + selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], container: .board), lastActiveLaneID: nil, snapshot: model ) @@ -637,18 +640,18 @@ struct PasteTargetTests { #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1)) } - @Test("A tombstoned selection never anchors: it behaves as nothing selected") - func tombstonedSelectionNeverAnchors() throws { + @Test("A trash selection never anchors: it behaves as nothing selected") + func trashSelectionNeverAnchors() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } let model = try snapshot(fixture) let target = PasteTarget.cards( - selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed), + selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash), lastActiveLaneID: clipboardLane2, snapshot: model ) - // The last-active lane, appended — never `card3`'s live disk-lane. + // The last-active lane, appended — the trash is never the destination. #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1)) } @@ -679,7 +682,7 @@ struct PasteTargetTests { let model = try snapshot(fixture) #expect(PasteTarget.lanes( - selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live), + selection: ItemReferenceSet(ids: [clipboardLane1], container: .board), snapshot: model ) == 1) } @@ -691,12 +694,12 @@ struct PasteTargetTests { let model = try snapshot(fixture) #expect(PasteTarget.lanes( - selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live), + selection: ItemReferenceSet(ids: [clipboardCard1], container: .board), snapshot: model ) == 1) } - @Test("Nothing (or something tombstoned) selected lands a lane at the board's right end") + @Test("Nothing (or a trash selection) lands a lane at the board's right end") func rightEnd() throws { let fixture = try makeClipboardBoard() defer { fixture.tearDown() } @@ -704,7 +707,7 @@ struct PasteTargetTests { #expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2) #expect(PasteTarget.lanes( - selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed), + selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash), snapshot: model ) == 2) } diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index 92329e2..0851245 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -15,11 +15,11 @@ import Testing @Suite("DragPayload") struct DragPayloadTests { - private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload { + private static func payload(kind: DragKind = .cards, container: ItemContainer = .board) -> DragPayload { DragPayload( boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true), kind: kind, - side: side, + container: container, items: [ DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"), DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil) @@ -30,8 +30,8 @@ struct DragPayloadTests { @Test("A payload round-trips through its JSON representation unchanged") func roundTrip() throws { for kind in [DragKind.cards, .lanes] { - for side in [Liveness.live, .trashed] { - let original = Self.payload(kind: kind, side: side) + for container in [ItemContainer.board, .trash] { + let original = Self.payload(kind: kind, container: container) let data = try #require(original.encoded()) #expect(DragPayload(data: data) == original) } @@ -62,10 +62,10 @@ struct DragPayloadTests { #expect(Self.payload().plainText == "First\nUntitled") } - @Test("The side survives the round trip, because it is what makes a trash drag a trash drag") - func sideSurvives() throws { - let data = try #require(Self.payload(side: .trashed).encoded()) - #expect(DragPayload(data: data)?.side.liveness == .trashed) + @Test("The container survives the round trip, because it is what makes a trash drag a trash drag") + func containerSurvives() throws { + let data = try #require(Self.payload(container: .trash).encoded()) + #expect(DragPayload(data: data)?.container == .trash) } } @@ -95,25 +95,25 @@ struct DragLocalityTests { /// The Finder volume model: within a board a drag rearranges, between boards it transfers. @Test("Locality picks the default — within is a move, across is a copy") func theDefault() { - #expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move) - #expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy) - #expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy) + #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: none) == .move) + #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy) + #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: none) == .copy) } @Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default") func modifiersOverride() { - #expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy) - #expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move) + #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: option) == .copy) + #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: command) == .move) // The no-ops. - #expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move) - #expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy) + #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: command) == .move) + #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: option) == .copy) } @Test("⌘ wins over ⌥ when both are held") func commandWinsOverOption() { // Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks. #expect(DragLocality.operation( - kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move) + kind: .cards, container: .board, isWithinBoard: false, modifiers: [.option, .command]) == .move) } /// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there: @@ -122,13 +122,13 @@ struct DragLocalityTests { func laneDragsNeverCopyWithinTheirBoard() { for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] { #expect( - DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move, + DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: true, modifiers: modifiers) == .move, "a within-board lane drag is a reorder whatever is held" ) } // Across boards the lane obeys the ordinary grammar again. - #expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy) - #expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move) + #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: option) == .copy) + #expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: command) == .move) } /// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its @@ -137,11 +137,11 @@ struct DragLocalityTests { /// live copy either way. @Test("A trash row drags as a restore at home and as a copy-out abroad") func trashDragDefaults() { - #expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move) - #expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy) + #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: none) == .move) + #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: false, modifiers: none) == .copy) #expect(DragLocality.operation( - kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move) - #expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy) + kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move) + #expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: option) == .copy) } } @@ -158,7 +158,7 @@ struct TrashDropTests { /// The accepted session, with one clause at a time knocked out by the cases. private func accepts( kind: DragKind? = .cards, - side: Liveness = .live, + container: ItemContainer = .board, isWithinBoard: Bool = true, operation: TransferOperation = .move, isTrashShown: Bool = true, @@ -166,7 +166,7 @@ struct TrashDropTests { ) -> Bool { TrashDrop.accepts( kind: kind, - side: side, + container: container, isWithinBoard: isWithinBoard, operation: operation, isTrashShown: isTrashShown, @@ -196,12 +196,12 @@ struct TrashDropTests { #expect(!accepts(kind: nil)) } - /// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes - /// nothing, so it never proposes. - @Test("A trash row dropped back on the trash is refused") - func theTrashedSideIsRefused() { - #expect(!accepts(side: .trashed)) - #expect(!accepts(side: .trashed, isWithinBoard: false)) + /// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it + /// never proposes. + @Test("A trash card dropped back on the trash is refused") + func theTrashContainerIsRefused() { + #expect(!accepts(container: .trash)) + #expect(!accepts(container: .trash, isWithinBoard: false)) } /// "No move or paste ever targets the trash": a foreign card delivered into this board's trash @@ -340,7 +340,7 @@ struct DropSettleTests { .appendingPathComponent($0.id.rawValue, isDirectory: true) }, heights: members.map { _ in 44 }, - side: .live, + container: .board, source: store ) } diff --git a/KanbanTests/DragWriteTests.swift b/KanbanTests/DragWriteTests.swift index d5c815e..76a472d 100644 --- a/KanbanTests/DragWriteTests.swift +++ b/KanbanTests/DragWriteTests.swift @@ -16,20 +16,6 @@ import Testing // MARK: - Fixtures -private func tombstoned(order: String, title: String) -> String { - """ - --- - schema: 1 - title: \(title) - order: \(order) - created: 2026-01-01T09:00:00Z - deleted: 2026-03-03T09:00:00Z - --- - \(title) body. - - """ -} - /// Three cards in the first lane, one in the second — enough room for a run of two to insert /// between siblings without either end being the answer. @MainActor @@ -55,12 +41,12 @@ private enum Foreign { static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" } -/// The board every cross-board test drags *out of*: one lane holding a live card and a tombstoned -/// one, so the lane-copy rule has something to strip and the restore rules have a row to carry. +/// The board every cross-board test drags *out of*: one lane holding two cards, plus a card sitting +/// in the source board's own `.trash/` for the cross-board restore cases. /// -/// `colliding` puts the lane and its live card under identities the **destination** already holds, -/// which is the import boundary's whole question; the tombstoned card keeps its foreign identity -/// either way, so a colliding arrival can prove the degradation is per folder. +/// `colliding` puts the lane and its first card under identities the **destination** already holds, +/// which is the import boundary's whole question; the second card keeps its foreign identity either +/// way, so a colliding arrival can prove the degradation is per folder. @MainActor private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture { let fixture = try WriterFixture() @@ -69,7 +55,8 @@ private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture { let cardName = colliding ? Ident.card1 : Foreign.card try fixture.item(laneName, Item.rich(order: "1024", title: "Imported")) try fixture.item("\(laneName)/\(cardName)", Item.rich(order: "1024", title: "Travelling")) - try fixture.item("\(laneName)/\(Foreign.trashed)", tombstoned(order: "2048", title: "Trashed")) + try fixture.item("\(laneName)/\(Foreign.second)", Item.rich(order: "2048", title: "Second")) + try fixture.item(".trash/\(Foreign.trashed)", Item.rich(order: "1024", title: "Trashed")) return fixture } @@ -90,14 +77,14 @@ private func loaded(_ fixture: WriterFixture) throws -> BoardModel { /// A lane's rendered card titles, in display order. private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } - return lane.cards.filter { !$0.isDeleted }.compactMap(\.title.value) + return lane.cards.compactMap(\.title.value) } /// A lane's rendered card folder names, in display order — identity, where titles would not /// distinguish an original from its copy. private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } - return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue) + return lane.cards.map(\.id.rawValue) } private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue { @@ -183,18 +170,16 @@ struct MoveCardsTests { #expect(store.banners.oneShots.isEmpty) } - @Test("A destination that is gone, tombstoned, or empty of members writes nothing") + @Test("A destination that is gone, or a set that names nothing, writes nothing") func noOps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) let store = try BoardStore(rootURL: fixture.root) let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") - store.moveCards([card1], toLane: lane3, at: 0) // tombstoned lane store.moveCards([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane store.moveCards([], toLane: lane2, at: 0) // nothing dragged - store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing live + store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing there #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp) #expect(try titles(lane2, in: fixture) == ["Fourth"]) @@ -315,14 +300,13 @@ struct CopyCardsTests { func noOps() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) let store = try BoardStore(rootURL: fixture.root) - store.copyCards([card1], toLane: lane3, at: 0) + store.copyCards([card1], toLane: ItemID(rawValue: Ident.lane3), at: 0) store.copyCards([], toLane: lane2, at: 0) #expect(try titles(lane2, in: fixture) == ["Fourth"]) - #expect(try fixture.entryNames(Ident.lane3) == ["index.md"]) + #expect(!fixture.exists(Ident.lane3)) #expect(store.banners.oneShots.isEmpty) } } @@ -411,14 +395,13 @@ struct ReceiveCardsTests { defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } - try destination.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) let store = try BoardStore(rootURL: destination.root) store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], - operation: .copy, toLane: lane3, at: 0) + operation: .copy, toLane: ItemID(rawValue: Ident.lane3), at: 0) store.receiveCards([], operation: .copy, toLane: lane2, at: 0) - #expect(try destination.entryNames(Ident.lane3) == ["index.md"]) + #expect(!destination.exists(Ident.lane3)) #expect(try titles(lane2, in: destination) == ["Fourth"]) #expect(source.exists("\(Foreign.lane)/\(Foreign.card)")) #expect(store.banners.oneShots.isEmpty) @@ -524,8 +507,12 @@ struct MoveLanesTests { @Suite("BoardStore ▸ receiveLanes") struct ReceiveLanesTests { - @Test("A lane copy transfers the content and strips the tombstoned cards") - func laneCopyStripsTombstones() throws { + /// 04-interactions.md ▸ Drag and drop, resettled 2026-07-28: "A lane carries exactly its cards — + /// the trash is board-level (`.trash/`), so there is nothing lane-nested to strip or carry: copy + /// and ⌘-drag move alike transfer the lane's folder as it is; the old tombstone-stripping rule is + /// retired with the tombstone model." + @Test("A lane copy transfers the whole lane, minting fresh identities at every level") + func laneCopyRemintsThroughout() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() @@ -538,19 +525,15 @@ struct ReceiveLanesTests { #expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"]) let arrived = try #require(model.lanes.first) #expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level") - #expect(arrived.cards.map(\.title.value) == ["Travelling"], - "trash isn't content — the tombstoned card did not come") - #expect(arrived.cards.allSatisfy { !$0.isDeleted }) - #expect(arrived.cards[0].id.rawValue != Foreign.card, "a copied lane's cards are new cards") - - // The tombstoned original stays recoverable in the source board. - #expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)")) - #expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:")) + #expect(arrived.cards.map(\.title.value) == ["Travelling", "Second"], "nothing to strip") + #expect(arrived.cards.allSatisfy { $0.id.rawValue != Foreign.card }, + "a copied lane's cards are new cards") + #expect(model.trash.isEmpty, "the source's trash is board-level and never travels with a lane") #expect(store.banners.oneShots.isEmpty) } - @Test("A lane move carries its tombstoned cards whole, into the destination's trash") - func laneMoveCarriesTombstones() throws { + @Test("A lane move carries its cards whole, identity and all") + func laneMoveCarriesItsCards() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() @@ -563,10 +546,9 @@ struct ReceiveLanesTests { #expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane], "identity travels, and the drop position is honoured") let arrived = try #require(model.lanes.last) - #expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.trashed]) - #expect(arrived.cards[1].isDeleted, "the tombstone came along as-is") - #expect(TrashModel.entries(of: model).map(\.id) == [ItemID(rawValue: Foreign.trashed)], - "and it renders in the destination's trash") + #expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.second], + "a lane carries exactly its cards: nothing lane-nested to strip or carry") + #expect(model.trash.isEmpty, "and nothing arrived in the destination's own trash") #expect(!source.exists(Foreign.lane)) #expect(store.banners.oneShots.isEmpty) } @@ -591,7 +573,7 @@ struct ReceiveLanesTests { let arrivedCards = arrived.cards.map(\.id.rawValue) #expect(arrivedCards.count == 2) #expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too") - #expect(arrivedCards[1] == Foreign.trashed, "and nothing else was — degradation is per folder") + #expect(arrivedCards[1] == Foreign.second, "and nothing else was — degradation is per folder") #expect(try titles(lane1, in: destination) == ["First", "Second", "Third"], "the residents kept their identities and their ranks") } @@ -609,26 +591,37 @@ struct ReceiveLanesTests { } } -// MARK: - Drag to restore, positionally +// MARK: - Restore is an ordinary move out -/// A lane holding a live card, a tombstoned one, and another live one — so a restore has somewhere -/// to land that is neither the head nor the tail. +/// A lane holding two cards, plus one card in the board's `.trash/` — so a restore has somewhere to +/// land that is neither the head nor the tail. +/// +/// **There is no tombstone anywhere in it.** "Restoring is an ordinary move out: drag a trash card +/// into any lane at any position … there is no restore-specific machinery and no Put Back" +/// (03-board-ui.md § Trash, resettled 2026-07-28), so the fixture is an ordinary board plus a +/// reserved folder. @MainActor private func makeTrashBoard() 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.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) + try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed")) return fixture } +/// Restoring out of the trash — **through `moveCards` and `copyCards`, with no method of its own**. +/// +/// That is the claim these tests exist to pin. The tombstone model needed `restoreByDrag` and +/// `receiveRestoredCards` because a restore had to clear a key as well as place a rank; a +/// materialized trash makes it "an ordinary move" (03-board-ui.md § Trash), so the ordinary drop +/// commits take a trash-side source and the retired pair is gone. @MainActor -@Suite("BoardStore ▸ positional drag-to-restore") -struct RestoreByDragPositionTests { +@Suite("BoardStore ▸ restore by move-out") +struct RestoreByMoveOutTests { @Test("The drop position sets the restored card's order") func dropPositionSetsTheOrder() throws { @@ -636,76 +629,89 @@ struct RestoreByDragPositionTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - // Index 0 among the lane's two live cards: ahead of both, not back at its recorded 2048. - store.restoreByDrag(cardID: card2, intoLane: lane1, at: 0) + // Index 0 among the lane's two cards: ahead of both. + store.moveCards([card2], toLane: lane1, at: 0) #expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"]) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0)) - #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "same lane never moves a folder") - #expect(TrashModel.isEmpty(try loaded(fixture))) + #expect(!fixture.exists(".trash/\(Ident.card2)"), "the folder physically left the trash") + #expect(try loaded(fixture).trash.isEmpty) #expect(store.banners.oneShots.isEmpty) } - @Test("A cross-lane restore lands at the drop position, not at the bottom") - func crossLanePositional() throws { - let fixture = try makeTrashBoard() - defer { fixture.tearDown() } - let store = try BoardStore(rootURL: fixture.root) - - store.restoreByDrag(cardID: card2, intoLane: lane2, at: 0) - - #expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"]) - #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) - #expect(try order(fixture, "\(Ident.lane2)/\(Ident.card2)") == .valid(0)) - let text = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)") - #expect(!text.contains("deleted:")) - } - @Test("An out-of-range index clamps to the lane's bottom") func indexClamps() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.restoreByDrag(cardID: card2, intoLane: lane1, at: 99) + store.moveCards([card2], toLane: lane1, at: 99) #expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"]) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096)) } - @Test("A multi-row restore lands the run contiguously, in drop order, in one bracket") + @Test("The identity travels, exactly as it does for any move") + func identityTravels() throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.moveCards([card2], toLane: lane2, at: 0) + + #expect(try ids(lane2, in: fixture) == [Ident.card2, Ident.card4]) + #expect(!fixture.exists(".trash/\(Ident.card2)")) + // Nothing about the restored card says it was ever deleted: there is no key to remove, + // because there is no key. + #expect(!(try fixture.indexText("\(Ident.lane2)/\(Ident.card2)").contains("deleted:"))) + } + + @Test("A multi-card restore lands the run contiguously, in one bracket") func multiRestore() 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")) - try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "TrashedA")) - try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "TrashedB")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) + // The trash's own order is its display order — `card3` above `card2`, newest-first. + try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "TrashedA")) + try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "TrashedB")) let store = try BoardStore(rootURL: fixture.root) - store.restoreByDrag(cardIDs: [card3, card2], intoLane: lane2, at: 0) + store.moveCards([card3, card2], toLane: lane2, at: 0) #expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"], - "the payload's order is the landing order") - #expect(TrashModel.isEmpty(try loaded(fixture))) - #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) - #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)")) + "the trash's own order is the landing order") + #expect(try loaded(fixture).trash.isEmpty) #expect(store.banners.oneShots.isEmpty) } - @Test("A row that is not a trash row is skipped, and an empty list writes nothing") - func skipsWhatIsNotARow() throws { + @Test("⌥ copies out and leaves the original in the trash") + func copyOutLeavesTheOriginal() throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.copyCards([card2], toLane: lane2, at: 0) + + #expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"]) + let landed = try #require(try ids(lane2, in: fixture).first) + #expect(landed != Ident.card2, "a copy out of the trash is still a copy") + #expect(fixture.exists(".trash/\(Ident.card2)"), "the original stays in the trash") + #expect(try loaded(fixture).trash.count == 1) + } + + @Test("A card that is not in the trash and an empty list both write nothing") + func skipsWhatIsNotThere() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") - // `card1` is live — it has no trash row to drag — and `indexless` names nothing. - store.restoreByDrag(cardIDs: [card1, ItemID(rawValue: Ident.indexless)], intoLane: lane2, at: 0) - store.restoreByDrag(cardIDs: [], intoLane: lane2, at: 0) + store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) + store.moveCards([], toLane: lane2, at: 0) #expect(try titles(lane2, in: fixture) == ["Fourth"]) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched) @@ -713,52 +719,49 @@ struct RestoreByDragPositionTests { } } +/// The cross-board half — **`receiveCards`, with no restore variant** (04-interactions.md ▸ The +/// trash: "Dropped on *another* board it follows the copy default — a live copy lands there, the +/// original stays in the source trash; ⌘-drag forces the true cross-board restore-move"). @MainActor -@Suite("BoardStore ▸ receiveRestoredCards") -struct ReceiveRestoredCardsTests { +@Suite("BoardStore ▸ cross-board restore") +struct CrossBoardRestoreTests { - @Test("A cross-board restore-copy lands live and leaves the source tombstone standing") - func restoreCopyStripsDeletedAndKeepsTheOriginal() throws { + @Test("A cross-board copy out of the trash lands a fresh identity and leaves the original") + func copyLeavesTheOriginal() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) - store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")], - operation: .copy, toLane: lane2, at: 0) + store.receiveCards([source.url(".trash/\(Foreign.trashed)")], + operation: .copy, toLane: lane2, at: 0) #expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"]) - let landedIDs = try ids(lane2, in: destination) - let landed = try #require(landedIDs.first) + let landed = try #require(try ids(lane2, in: destination).first) #expect(landed != Foreign.trashed, "a copy out of the trash is still a copy") let text = try destination.indexText("\(Ident.lane2)/\(landed)") - #expect(!text.contains("deleted:"), "`deleted:` is stripped on paste/duplicate/drop") #expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork") - // The tombstoned original stays recoverable in the source board's trash. - #expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)")) - #expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:")) + #expect(source.exists(".trash/\(Foreign.trashed)"), "the original stays in the source trash") #expect(store.banners.oneShots.isEmpty) } - @Test("A cross-board restore-move clears the tombstone and the source loses the folder") - func restoreMoveClearsTheTombstone() throws { + @Test("A ⌘-drag move out of another board's trash carries the identity and empties it") + func moveCarriesTheIdentity() throws { let destination = try makeBoard() defer { destination.tearDown() } let source = try makeSourceBoard() defer { source.tearDown() } let store = try BoardStore(rootURL: destination.root) - store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")], - operation: .move, toLane: lane2, at: 1) + store.receiveCards([source.url(".trash/\(Foreign.trashed)")], + operation: .move, toLane: lane2, at: 1) #expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels") - let text = try destination.indexText("\(Ident.lane2)/\(Foreign.trashed)") - #expect(!text.contains("deleted:")) - #expect(!source.exists("\(Foreign.lane)/\(Foreign.trashed)"), "the tombstone left the source") - #expect(TrashModel.isEmpty(try loaded(source))) - #expect(TrashModel.isEmpty(try loaded(destination))) + #expect(!source.exists(".trash/\(Foreign.trashed)"), "the card left the source trash") + #expect(try loaded(source).trash.isEmpty) + #expect(try loaded(destination).trash.isEmpty) #expect(store.banners.oneShots.isEmpty) } } diff --git a/KanbanTests/FileDropWriteTests.swift b/KanbanTests/FileDropWriteTests.swift index 25fc6d6..d2da54d 100644 --- a/KanbanTests/FileDropWriteTests.swift +++ b/KanbanTests/FileDropWriteTests.swift @@ -184,27 +184,25 @@ struct ImportAttachmentsToCardTests { #expect(store.banners.oneShots.isEmpty) } - @Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing") + @Test("A trashed or vanished target writes nothing") func inertTargets() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let sources = try DropSources() defer { sources.tearDown() } - // A tombstoned card, and a live card under a tombstoned lane — effective liveness is - // ancestor-walked, so both render nowhere and both are inert. - try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second")) - try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) - try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried")) + // "Finder file drops on trash cards are inert" (04 ▸ The trash), and a card whose lane was + // deleted is simply not there. + try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2) let store = try BoardStore(rootURL: fixture.root) let shot = try sources.file("shot.png") - store.importAttachments([shot], toCard: card2) // tombstoned card - store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // under a tombstoned lane + store.importAttachments([shot], toCard: card2) // a trash card + store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // its lane is gone store.importAttachments([shot], toCard: ItemID(rawValue: Ident.indexless)) // no such card store.importAttachments([shot], toCard: lane1) // a lane, not a card store.importAttachments([], toCard: card1) // nothing dropped - #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments")) + #expect(!fixture.exists(".trash/\(Ident.card2)/attachments")) #expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)/attachments")) #expect(!fixture.exists("\(Ident.lane1)/attachments")) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments")) @@ -387,21 +385,20 @@ struct CreateCardsFromFilesTests { #expect(store.banners.oneShots.isEmpty) } - @Test("A tombstoned, vanished, or empty destination writes nothing") + @Test("A deleted, vanished, or empty destination writes nothing") func inertDestinations() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let sources = try DropSources() defer { sources.tearDown() } - try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) let store = try BoardStore(rootURL: fixture.root) let shot = try sources.file("shot.png") - store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // tombstoned + store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // no such lane store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped - #expect(try fixture.entryNames(Ident.lane3) == ["index.md"]) + #expect(!fixture.exists(Ident.lane3)) #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"]) #expect(store.banners.oneShots.isEmpty) } diff --git a/KanbanTests/InlineEditWriteTests.swift b/KanbanTests/InlineEditWriteTests.swift index 579e6ae..1d279f2 100644 --- a/KanbanTests/InlineEditWriteTests.swift +++ b/KanbanTests/InlineEditWriteTests.swift @@ -220,21 +220,21 @@ struct InlineRenameWriteTests { #expect(store.banners.oneShots.isEmpty) } - @Test("A commit at a card under a tombstoned lane writes nothing — liveness is effective") - func targetUnderATombstonedLaneWritesNothing() throws { + @Test("A commit at a card that entered the trash writes nothing") + func targetInTheTrashWritesNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - // The lane carries the tombstone; the card's own flag is untouched, and it renders nowhere - // regardless (03-board-ui.md collapses the lane to one trash entry). - try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) + // "Entering the trash is a vanish from the board; nothing is ever written into a vanished + // folder" (04 ▸ Grammar). + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) let store = try BoardStore(rootURL: fixture.root) - let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + let before = try fixture.indexData(".trash/\(Ident.card1)") store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Never lands") store.commitRename() - #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) + #expect(try fixture.indexData(".trash/\(Ident.card1)") == before) #expect(store.banners.oneShots.isEmpty) } diff --git a/KanbanTests/KeyboardGrammarTests.swift b/KanbanTests/KeyboardGrammarTests.swift index d764d94..755722a 100644 --- a/KanbanTests/KeyboardGrammarTests.swift +++ b/KanbanTests/KeyboardGrammarTests.swift @@ -75,9 +75,9 @@ private func target( width: CGFloat = 100, height: CGFloat = 100, kind: SelectionKind = .card, - side: Liveness = .live + container: ItemContainer = .board ) -> MarqueeTarget { - MarqueeTarget(id: id, kind: kind, side: side, frame: CGRect(x: x, y: y, width: width, height: height)) + MarqueeTarget(id: id, kind: kind, container: container, frame: CGRect(x: x, y: y, width: width, height: height)) } /// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them — the smallest @@ -145,7 +145,7 @@ struct NavigationMathTests { @Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate") func predicateRestrictsCandidates() { - let trashed = target(card2, x: 0, y: 100, side: .trashed) + let trashed = target(card2, x: 0, y: 100, container: .trash) let live = target(card3, x: 0, y: 400) let all = [trashed, live] @@ -154,7 +154,7 @@ struct NavigationMathTests { "a plain arrow walks across the boundary" ) #expect( - NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.side == .live }) == card3 + NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3 ) } } @@ -216,10 +216,10 @@ struct SuccessorTests { defer { fixture.tearDown() } let snapshot = try load(fixture) - #expect(SelectionGrammar.successor(afterDeleting: [card2], in: snapshot) == card3) - #expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2) + #expect(SelectionGrammar.successor(afterDeleting: [card2], snapshot: snapshot) == card3) + #expect(SelectionGrammar.successor(afterDeleting: [card1], snapshot: snapshot) == card2) #expect( - SelectionGrammar.successor(afterDeleting: [card1, card2], in: snapshot) == card3, + SelectionGrammar.successor(afterDeleting: [card1, card2], snapshot: snapshot) == card3, "a block's successor is the first survivor after its last member" ) } @@ -230,8 +230,8 @@ struct SuccessorTests { defer { fixture.tearDown() } let snapshot = try load(fixture) - #expect(SelectionGrammar.successor(afterDeleting: [card4], in: snapshot) == card3) - #expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: snapshot) == card2) + #expect(SelectionGrammar.successor(afterDeleting: [card4], snapshot: snapshot) == card3) + #expect(SelectionGrammar.successor(afterDeleting: [card3, card4], snapshot: snapshot) == card2) } @Test("A survivor between the members is found forwards first") @@ -242,7 +242,7 @@ struct SuccessorTests { // Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes // repeated ⌫ keep moving down rather than bouncing back up the lane. - #expect(SelectionGrammar.successor(afterDeleting: [card1, card3], in: snapshot) == card4) + #expect(SelectionGrammar.successor(afterDeleting: [card1, card3], snapshot: snapshot) == card4) } @Test("An emptied container selects nothing") @@ -251,10 +251,10 @@ struct SuccessorTests { defer { fixture.tearDown() } let snapshot = try load(fixture) - #expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], in: snapshot) == nil) - #expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil) + #expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], snapshot: snapshot) == nil) + #expect(SelectionGrammar.successor(afterDeleting: [], snapshot: snapshot) == nil) #expect( - SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], in: snapshot) == nil, + SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], snapshot: snapshot) == nil, "ids naming nothing name no container either" ) } @@ -267,7 +267,7 @@ struct SuccessorTests { // card5 is later than card2 in flatten order (lane `order`, then card `order`), so the // container is lane2 — the same "last member" anchor ⌘N and paste already share. - #expect(SelectionGrammar.successor(afterDeleting: [card2, card5], in: snapshot) == card6) + #expect(SelectionGrammar.successor(afterDeleting: [card2, card5], snapshot: snapshot) == card6) } @Test("Lanes follow the same rule in the live lane order") @@ -276,9 +276,9 @@ struct SuccessorTests { defer { fixture.tearDown() } let snapshot = try load(fixture) - #expect(SelectionGrammar.successor(afterDeleting: [lane1], in: snapshot) == lane2) - #expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor") - #expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: snapshot) == nil) + #expect(SelectionGrammar.successor(afterDeleting: [lane1], snapshot: snapshot) == lane2) + #expect(SelectionGrammar.successor(afterDeleting: [lane3], snapshot: snapshot) == lane2, "the last lane's predecessor") + #expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], snapshot: snapshot) == nil) } @Test("⌫ selects the successor immediately, before the reload echoes the tombstone back") @@ -287,7 +287,7 @@ struct SuccessorTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card2], liveness: .live) + store.select([card2], in: .board) store.deleteSelection() #expect(store.selection.ids == [card3]) @@ -306,7 +306,7 @@ struct SuccessorTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card5, card6], liveness: .live) + store.select([card5, card6], in: .board) store.deleteSelection() #expect(store.selection.isEmpty) @@ -324,13 +324,13 @@ struct SelectionHeadTests { func headDefaults() throws { let state = TransientBoardState() - state.select([card1], liveness: .live) + state.select([card1], in: .board) #expect(state.selectionHead == card1) - state.select([card1, card2], liveness: .live) + state.select([card1, card2], in: .board) #expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor") - state.select([card1, card2], liveness: .live, anchor: card1, head: card2) + state.select([card1, card2], in: .board, anchor: card1, head: card2) #expect(state.selectionAnchor == card1) #expect(state.selectionHead == card2, "an explicit head is kept whatever the count") @@ -346,9 +346,9 @@ struct SelectionHeadTests { let snapshot = try load(fixture) let outcome = SelectionGrammar.click( - SelectionTarget(id: card3, kind: .card, side: .live), + SelectionTarget(id: card3, kind: .card, container: .board), modifier: .shift, - selection: ItemReferenceSet(ids: [card1], liveness: .live), + selection: ItemReferenceSet(ids: [card1], container: .board), anchor: card1, snapshot: snapshot ) @@ -364,7 +364,7 @@ struct SelectionHeadTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card1, card2], liveness: .live, anchor: card1, head: card2) + store.select([card1, card2], in: .board, anchor: card1, head: card2) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) store.handleWatcherEvent(.treeChanged(.foreign)) @@ -375,25 +375,16 @@ struct SelectionHeadTests { #expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree") } - @Test("A liveness flip is a vanish for the head too") - func resolveDropsALivenessFlippedHead() async throws { + @Test("A container crossing is a vanish for the head too") + func resolveDropsAContainerCrossedHead() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card1], liveness: .live) + store.select([card1], in: .board) #expect(store.transient.selectionHead == card1) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", """ - --- - schema: 1 - title: First - order: 1024 - deleted: 2026-03-03T09:00:00Z - --- - First body. - - """) + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() @@ -416,7 +407,7 @@ struct SortWriteTests { let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") - store.select([card3], liveness: .live) + store.select([card3], in: .board) store.sortSelection(.up) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4]) @@ -435,7 +426,7 @@ struct SortWriteTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card2, card4], liveness: .live) + store.select([card2, card4], in: .board) store.sortSelection(.up) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3]) @@ -447,7 +438,7 @@ struct SortWriteTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card1, card2], liveness: .live) + store.select([card1, card2], in: .board) store.sortSelection(.down) #expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4]) @@ -467,7 +458,7 @@ struct SortWriteTests { #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2]) - store.select([card2], liveness: .live) + store.select([card2], in: .board) store.sortSelection(.up) #expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1]) @@ -482,16 +473,16 @@ struct SortWriteTests { #expect(store.sortPlan(.up) == nil, "nothing selected") - store.select([lane1], liveness: .live) + store.select([lane1], in: .board) #expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes") - store.select([card2, card5], liveness: .live) + store.select([card2, card5], in: .board) #expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow") - store.select([card1], liveness: .trashed) + store.select([card1], in: .trash) #expect(store.sortPlan(.up) == nil, "a tombstoned selection") - store.select([card1], liveness: .live) + store.select([card1], in: .board) #expect(store.sortPlan(.up) == nil, "already at the top") #expect(store.sortPlan(.down) != nil, "but the other direction is live") } @@ -513,7 +504,7 @@ struct MoveLaneConventionTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + let lanes = SelectionGrammar.lanes(in: store.snapshot) #expect(lanes == [lane1, lane2, lane3]) let from = try #require(lanes.firstIndex(of: lane2)) @@ -527,7 +518,7 @@ struct MoveLaneConventionTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + let lanes = SelectionGrammar.lanes(in: store.snapshot) let from = try #require(lanes.firstIndex(of: lane1)) store.moveLane(lane1, toIndex: from + 1) diff --git a/KanbanTests/LooseFileRelocationTests.swift b/KanbanTests/LooseFileRelocationTests.swift index 7af6151..69edd0c 100644 --- a/KanbanTests/LooseFileRelocationTests.swift +++ b/KanbanTests/LooseFileRelocationTests.swift @@ -665,9 +665,9 @@ struct LooseFilePasteTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) - target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try arrivedCard(in: destination) @@ -693,9 +693,9 @@ struct LooseFilePasteTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) - target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try arrivedCard(in: destination) @@ -718,9 +718,9 @@ struct LooseFilePasteTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) - target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value #expect(harness.fixture.exists("\(cardPath)/notes.txt")) @@ -735,7 +735,7 @@ struct LooseFilePasteTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([ItemID(rawValue: Ident.lane1)], liveness: .live) + harness.store.select([ItemID(rawValue: Ident.lane1)], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.paste(into: target)?.value @@ -760,7 +760,7 @@ struct LooseFilePasteTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() // The snapshot goes missing between the copy and the paste — 04's degraded paste. @@ -768,7 +768,7 @@ struct LooseFilePasteTests { try FileManager.default.removeItem(at: harness.staging.appendingPathComponent(staged)) } - target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try arrivedCard(in: destination) diff --git a/KanbanTests/NewCardTargetTests.swift b/KanbanTests/NewCardTargetTests.swift index 212ea90..53f8600 100644 --- a/KanbanTests/NewCardTargetTests.swift +++ b/KanbanTests/NewCardTargetTests.swift @@ -38,7 +38,6 @@ private func makeBoard() throws -> WriterFixture { try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) 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: "Archive")) return fixture } @@ -71,14 +70,14 @@ struct NewCardTargetTests { // "With a card selected, the new card is created in that card's lane, immediately after it // (paste-anchor consistency)." - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], liveness: .live)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1)) - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], liveness: .live)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board)) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3)) // The last card in a lane is still an anchor here — "after the last card" and "at the // bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`). - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], liveness: .live)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], container: .board)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2)) } @@ -89,7 +88,7 @@ struct NewCardTargetTests { let snapshot = try BoardLoader.load(boardRoot: fixture.root).model // "With a lane selected, appended at its bottom (Return consistency)." - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], liveness: .live)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], container: .board)) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) } @@ -107,25 +106,25 @@ struct NewCardTargetTests { #expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) #expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) - // A *tombstoned* lane is not a target either — it renders nowhere, and the trash is never a - // creation destination. + // A lane that has been deleted is not a target either — a lane delete is physical, so the + // memory simply names nothing. #expect(resolve(snapshot, lastActive: lane3) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) } - @Test("A tombstoned selection never anchors creation — it behaves as nothing selected") - func aTombstonedSelectionNeverAnchors() throws { + @Test("A trash selection never anchors creation — it behaves as nothing selected") + func aTrashSelectionNeverAnchors() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try BoardLoader.load(boardRoot: fixture.root).model - // Settled in 04 ▸ The map, on the ⌘N rule's own wording: "a **tombstoned** selection, which - // never anchors creation". The trash-side lane is a real lane on disk with a live sibling - // list — the rule must not let its identity leak in as a target. - let trashed = ItemReferenceSet(ids: [lane3], liveness: .trashed) - #expect(resolve(snapshot, selection: trashed, lastActive: lane2) + // 04 ▸ The map, on the ⌘N rule's own wording: "with nothing selected — or a **trash** + // selection, which never anchors creation". A trashed card's board-side lane must not leak + // in as a target. + let inTrash = ItemReferenceSet(ids: [card1], container: .trash) + #expect(resolve(snapshot, selection: inTrash, lastActive: lane2) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) - #expect(resolve(snapshot, selection: trashed) + #expect(resolve(snapshot, selection: inTrash) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) } @@ -140,21 +139,22 @@ struct NewCardTargetTests { // target — New Card, Return-creation, and Paste with a card payload disable via menu // validation until a lane exists." #expect(resolve(empty) == nil) - #expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], liveness: .live)) == nil) + #expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil) #expect(resolve(empty, lastActive: lane1) == nil) } - @Test("A board whose every lane is tombstoned is a zero-lane board") - func everyLaneTombstonedIsAlsoZeroLane() throws { - let fixture = try WriterFixture() + @Test("A board whose every lane has been deleted is a zero-lane board") + func everyLaneDeletedIsAlsoZeroLane() throws { + let fixture = try makeBoard() defer { fixture.tearDown() } - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) + for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) { + try FileManager.default.removeItem(at: fixture.url(lane)) + } let snapshot = try BoardLoader.load(boardRoot: fixture.root).model - // The lanes are still in the snapshot — the trash renders them — but none is on the board, - // and "every lane deleted" is the design's own second reading of the zero-lane case. - #expect(snapshot.lanes.count == 1) + // "Every lane deleted" is the design's own second reading of the zero-lane case — and a lane + // delete is physical now, so it is literally the same board as the hand-made one above. + #expect(snapshot.lanes.isEmpty) #expect(resolve(snapshot) == nil) } @@ -167,15 +167,15 @@ struct NewCardTargetTests { // "A multi-selection anchors at its last member in flatten order (lane `order`, then card // `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so // creation follows card3 — the last-active lane never enters into it. - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], liveness: .live), lastActive: lane1) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], container: .board), lastActive: lane1) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3)) // Within one lane the flatten order is card order: card2 sorts after card1. - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], container: .board), lastActive: lane2) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2)) // A multi-LANE selection appends to the last selected lane's bottom. - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], liveness: .live)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], container: .board)) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) } @@ -187,7 +187,7 @@ struct NewCardTargetTests { // A selection naming something the board does not render — the reload that drops it has not // landed yet — must not refuse the creation the user just asked for. - #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live)) + #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) } } diff --git a/KanbanTests/PasteWriteTests.swift b/KanbanTests/PasteWriteTests.swift index 607a8b5..15a3eef 100644 --- a/KanbanTests/PasteWriteTests.swift +++ b/KanbanTests/PasteWriteTests.swift @@ -61,9 +61,9 @@ struct PasteFromStagingTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try #require(try pastedIDs(destinationLane, in: destination).last) @@ -85,9 +85,9 @@ struct PasteFromStagingTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try #require(try pastedIDs(destinationLane, in: destination).last) @@ -107,9 +107,9 @@ struct PasteFromStagingTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value #expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"]) @@ -123,13 +123,13 @@ struct PasteFromStagingTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value target.handleWatcherEvent(.treeChanged(.appMediated)) await target.awaitQuiescence() - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value let ids = try pastedIDs(destinationLane, in: destination) @@ -142,7 +142,7 @@ struct PasteFromStagingTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.paste(into: harness.store)?.value @@ -159,15 +159,15 @@ struct PasteFromStagingTests { @Suite("Paste ▸ lanes") struct PasteLaneTests { - @Test("A pasted lane copy takes fresh GUIDs throughout and strips tombstoned cards") - func laneCopyStripsTombstones() async throws { + @Test("A pasted lane copy takes fresh GUIDs throughout and carries exactly its cards") + func laneCopyRemintsThroughout() async throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let destination = try makeDestination() defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardLane1], liveness: .live) + harness.store.select([clipboardLane1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.paste(into: target)?.value @@ -176,12 +176,11 @@ struct PasteLaneTests { let arrived = try #require(model.lanes.last) #expect(arrived.id.rawValue != Ident.lane1) #expect(arrived.title.value == "Todo") - // The tombstoned card is gone — "the copy transfers content, and trash isn't content". + // "A lane carries exactly its cards — the trash is board-level, so there is nothing + // lane-nested to strip or carry" (04 ▸ Drag and drop, resettled 2026-07-28). #expect(arrived.cards.count == 2) - #expect(arrived.cards.allSatisfy { !$0.isDeleted }) #expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 }) - // The tombstoned original is still recoverable where it always was. - #expect(try lane(clipboardLane1, in: harness.fixture)?.cards.count == 3) + #expect(try pasted(harness.fixture).trash.count == 1, "and the source's trash is untouched") } @Test("A lane paste with nothing selected lands at the board's right end") @@ -192,7 +191,7 @@ struct PasteLaneTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardLane1], liveness: .live) + harness.store.select([clipboardLane1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.paste(into: target)?.value @@ -208,30 +207,30 @@ struct PasteLaneTests { try empty.item("", Item.board) let target = try BoardStore(rootURL: empty.root) - harness.store.select([clipboardLane1], liveness: .live) + harness.store.select([clipboardLane1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.paste(into: target)?.value #expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"]) } - @Test("A lane cut-move carries its tombstoned cards whole") - func laneCutMoveCarriesTombstones() async throws { + @Test("A lane cut-move carries its cards whole, identity and all") + func laneCutMoveCarriesItsCards() async throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let destination = try makeDestination() defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardLane1], liveness: .live) + harness.store.select([clipboardLane1], in: .board) harness.clipboard.cut(from: harness.store) await harness.clipboard.paste(into: target)?.value let model = try pasted(destination) let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 }) - // Identity travelled, and the tombstone landed in the destination's trash. - #expect(arrived.cards.count == 3) - #expect(arrived.cards.contains { $0.isDeleted }) + // Identity travelled, and every card came with it — nothing to strip. + #expect(arrived.cards.count == 2) + #expect(model.trash.isEmpty, "the source board's trash is board-level and stays there") // The lane left the source board entirely. #expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2]) } @@ -241,7 +240,7 @@ struct PasteLaneTests { let harness = try makeClipboardHarness() defer { harness.tearDown() } - harness.store.select([clipboardLane1], liveness: .live) + harness.store.select([clipboardLane1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.paste(into: harness.store)?.value @@ -259,8 +258,8 @@ struct PasteLaneTests { @Suite("Paste ▸ from the trash") struct PasteFromTrashTests { - @Test("A card copied out of the trash arrives live") - func cardArrivesLive() async throws { + @Test("A card copied out of the trash is an ordinary copy of an ordinary card") + func cardCopiesOutOrdinarily() async throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let destination = try makeDestination() @@ -268,46 +267,39 @@ struct PasteFromTrashTests { let target = try BoardStore(rootURL: destination.root) harness.store.transient.isTrashVisible = true - harness.store.select([clipboardCard3], liveness: .trashed) + harness.store.select([clipboardCard3], in: .trash) harness.clipboard.copy(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value #expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"]) - // And the tombstoned original stays in the source trash — copy-out, never a move. - #expect(try lane(clipboardLane1, in: harness.fixture)? - .cards.first { $0.id == clipboardCard3 }?.isDeleted == true) + // The original stays in the source trash — a copy is a copy (04 ▸ The trash: "⌘C copies a + // trash card; a live copy lands wherever pasted, like copying out of Finder's Trash"). + #expect(try pasted(harness.fixture).trash.map(\.id) == [clipboardCard3]) + // And nothing had to be stripped on arrival: a trashed card carries no key at all. + let landed = try #require(try lane(destinationLane, in: destination)?.cards.last) + #expect(landed.deleted.isMissing) } - @Test("A lane entry copied out of the trash arrives live, its tombstoned interior cards stripped") - func laneEntryStripsBothWays() async throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - try fixture.item("", Item.board) - // A tombstoned lane holding one plain card and one that carries its own tombstone. - try fixture.item(Ident.lane1, tombstonedItem(order: "1024", title: "Archive")) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Kept")) - try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstonedItem(order: "2048", title: "Gone")) - try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) - - let harness = try ClipboardHarness(fixture: fixture) - defer { try? FileManager.default.removeItem(at: harness.staging) } - let destination = try makeDestination() - defer { destination.tearDown() } - let target = try BoardStore(rootURL: destination.root) + /// **Cut in the trash, paste into a lane, is the keyboard-native restore** (04-interactions.md + /// ▸ The trash, resettled 2026-07-28) — "an ordinary folder move", which is exactly what the + /// armed cut already does. + @Test("Cut in the trash and paste into a lane is the keyboard restore — the folder moves") + func cutFromTheTrashIsTheKeyboardRestore() async throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } harness.store.transient.isTrashVisible = true - harness.store.select([clipboardLane1], liveness: .trashed) - harness.clipboard.copy(from: harness.store) - await harness.clipboard.paste(into: target)?.value + harness.store.select([clipboardCard3], in: .trash) + harness.clipboard.cut(from: harness.store) + harness.store.select([clipboardLane2], in: .board) + await harness.clipboard.paste(into: harness.store)?.value - let model = try pasted(destination) - let arrived = try #require(model.lanes.last) - #expect(arrived.isDeleted == false) - #expect(arrived.title.value == "Archive") - #expect(arrived.cards.count == 1) - #expect(arrived.cards.first?.title.value == "Kept") - #expect(arrived.cards.first?.isDeleted == false) + let model = try pasted(harness.fixture) + #expect(model.trash.isEmpty, "the folder left the trash") + let lane = try #require(model.lanes.first { $0.id == clipboardLane2 }) + #expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move") + #expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed") } } @@ -337,9 +329,9 @@ struct PasteSearchAndStalenessTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) // "First" would be hidden by this query — exactly the card that must not arrive invisible. target.searchQuery = "resident" await harness.clipboard.paste(into: target)?.value @@ -356,7 +348,7 @@ struct PasteSearchAndStalenessTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardLane2], liveness: .live) + harness.store.select([clipboardLane2], in: .board) harness.clipboard.copy(from: harness.store) target.searchQuery = "resident" await harness.clipboard.paste(into: target)?.value @@ -373,10 +365,10 @@ struct PasteSearchAndStalenessTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) harness.pasteboard.takeOver() - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) target.searchQuery = "resident" // `refresh()` at the front of the paste sees the moved changeCount, so there is no payload @@ -395,9 +387,9 @@ struct PasteSearchAndStalenessTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) target.searchQuery = "resident" // The gesture passed validation; the takeover lands while the task is still waiting on the @@ -427,9 +419,9 @@ struct PasteCutTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.cut(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value // Identity travelled. @@ -450,13 +442,13 @@ struct PasteCutTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.cut(from: harness.store) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value target.handleWatcherEvent(.treeChanged(.appMediated)) await target.awaitQuiescence() - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value let ids = try pastedIDs(destinationLane, in: destination) @@ -474,12 +466,12 @@ struct PasteCutTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.cut(from: harness.store) // The source board closes — its store goes, and with it the cut's arming. harness.store.transient.pendingCut = .empty - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value // A copy: a fresh identity at the destination, and the original still at home. @@ -496,22 +488,21 @@ struct PasteCutTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1, clipboardCard2], liveness: .live) + harness.store.select([clipboardCard1, clipboardCard2], in: .board) harness.clipboard.cut(from: harness.store) - // One of the two is tombstoned before the paste: the reload ejects it from the pending cut. + // One of the two is deleted before the paste: the reload ejects it from the pending cut. harness.store.delete([clipboardCard1]) harness.store.handleWatcherEvent(.treeChanged(.appMediated)) await harness.store.awaitQuiescence() #expect(harness.store.transient.pendingCut.ids == [clipboardCard2]) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value #expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2]) - // The tombstoned one stayed behind, in the source board's trash. - #expect(try lane(clipboardLane1, in: harness.fixture)? - .cards.first { $0.id == clipboardCard1 }?.isDeleted == true) + // The deleted one stayed behind, in the source board's trash. + #expect(try pasted(harness.fixture).trash.map(\.id).contains(clipboardCard1)) } @Test("A cut emptied down to nothing is simply void — a paste copies instead") @@ -522,14 +513,14 @@ struct PasteCutTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.cut(from: harness.store) harness.store.delete([clipboardCard1]) harness.store.handleWatcherEvent(.treeChanged(.appMediated)) await harness.store.awaitQuiescence() #expect(harness.store.transient.pendingCut.isEmpty) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value // The staged snapshot is still there, so the paste is a copy — content intact. @@ -551,7 +542,7 @@ struct PasteFallbackTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() // The snapshot goes — a swept tree, a full disk, an unreadable container. @@ -560,7 +551,7 @@ struct PasteFallbackTests { at: harness.staging.appendingPathComponent(copyID, isDirectory: true) ) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try #require(try pastedIDs(destinationLane, in: destination).last) @@ -585,7 +576,7 @@ struct PasteFallbackTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardCard1], liveness: .live) + harness.store.select([clipboardCard1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() let copyID = try #require(harness.clipboard.payload?.copyID) @@ -593,7 +584,7 @@ struct PasteFallbackTests { at: harness.staging.appendingPathComponent(copyID, isDirectory: true) ) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value #expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"]) @@ -608,7 +599,7 @@ struct PasteFallbackTests { let target = try BoardStore(rootURL: destination.root) // `card2` has no attachments, so a fallback loses nothing at all. - harness.store.select([clipboardCard2], liveness: .live) + harness.store.select([clipboardCard2], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() let copyID = try #require(harness.clipboard.payload?.copyID) @@ -616,7 +607,7 @@ struct PasteFallbackTests { at: harness.staging.appendingPathComponent(copyID, isDirectory: true) ) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value #expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"]) @@ -631,7 +622,7 @@ struct PasteFallbackTests { defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) - harness.store.select([clipboardLane1], liveness: .live) + harness.store.select([clipboardLane1], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() let copyID = try #require(harness.clipboard.payload?.copyID) @@ -643,14 +634,14 @@ struct PasteFallbackTests { let arrived = try #require(try pasted(destination).lanes.last) #expect(arrived.title.value == "Todo") - // The two live cards, and not the tombstoned third. + // Both of the lane's cards. #expect(arrived.cards.count == 2) #expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"]) #expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"]) } - @Test("A trash-sourced fallback still strips `deleted:` at materialization") - func trashedFallbackStripsDeleted() async throws { + @Test("A trash-sourced fallback materializes an ordinary card — there is no key to strip") + func trashedFallbackIsOrdinary() async throws { let harness = try makeClipboardHarness() defer { harness.tearDown() } let destination = try makeDestination() @@ -658,7 +649,7 @@ struct PasteFallbackTests { let target = try BoardStore(rootURL: destination.root) harness.store.transient.isTrashVisible = true - harness.store.select([clipboardCard3], liveness: .trashed) + harness.store.select([clipboardCard3], in: .trash) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() let copyID = try #require(harness.clipboard.payload?.copyID) @@ -666,7 +657,7 @@ struct PasteFallbackTests { at: harness.staging.appendingPathComponent(copyID, isDirectory: true) ) - target.select([destinationLane], liveness: .live) + target.select([destinationLane], in: .board) await harness.clipboard.paste(into: target)?.value #expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"]) diff --git a/KanbanTests/RawSourceTests.swift b/KanbanTests/RawSourceTests.swift index 88a6b1b..c7f11c0 100644 --- a/KanbanTests/RawSourceTests.swift +++ b/KanbanTests/RawSourceTests.swift @@ -423,18 +423,18 @@ struct RawSourceStoreTests { func aTombstonedCardIsRefused() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - try BoardWriter.deleteItem(at: fixture.url(cardPath)) + try fixture.move(cardPath, toTrash: Ident.card1) let store = try BoardStore(rootURL: fixture.root) - let before = try fixture.indexData(cardPath) + let before = try fixture.indexData(".trash/\(Ident.card1)") let card = ItemID(rawValue: Ident.card1) - // 05 ▸ Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply writes - // the *whole* pre-tombstone `index.md` and would silently undelete the card — a foreign - // delete is never reverted by a stale buffer." The Edit buffer's flush is the opposite rule, - // deliberately, which is why the two walks differ (`liveItem` vs `cardBodyTarget`). + // 05 ▸ Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply would + // write a whole stale `index.md` over the trashed card — a delete is never fought by a stale + // buffer." The Edit buffer's flush is the opposite rule, deliberately, which is why the two + // walks differ (`boardItem` vs `cardBodyTarget`). #expect(store.readCardSource(inCard: card) == .vanished) #expect(store.applyCardSource(inCard: card, text: handEditedSource) == .vanished) - #expect(try fixture.indexData(cardPath) == before) + #expect(try fixture.indexData(".trash/\(Ident.card1)") == before) } @Test("A card that is not in the board at all is vanished too") diff --git a/KanbanTests/SearchFilterTests.swift b/KanbanTests/SearchFilterTests.swift index b1b9215..3ef4016 100644 --- a/KanbanTests/SearchFilterTests.swift +++ b/KanbanTests/SearchFilterTests.swift @@ -105,18 +105,8 @@ private func makeTrashBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane.")) - try fixture.item( - "\(Ident.lane1)/\(Ident.card1)", - tombstoned(order: "1024", title: "Fix login", body: "Auth.", deleted: "2026-03-05T10:00:00Z") - ) - try fixture.item( - "\(Ident.lane1)/\(Ident.card2)", - tombstoned(order: "2048", title: "Polish", body: "Wording.", deleted: "2026-03-05T06:00:00Z") - ) - try fixture.item( - More.laneX, - tombstoned(order: "2048", title: "Old login lane", body: "Retired.", deleted: "2026-03-05T08:00:00Z") - ) + try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth.")) + try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording.")) return fixture } @@ -238,17 +228,17 @@ struct SearchFilterPredicateTests { #expect(!SearchFilter(query: "csv").matches(unrelated)) } - @Test("A lane matches by its own title and body — the trash's rows, not the board's lanes") - func lanesMatchByTheirOwnText() throws { + @Test("There is no lane predicate: the filter is a card predicate, end to end") + func lanesAreNeverFiltered() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let model = try load(fixture) - let todo = try #require(model.lanes.first { $0.id == lane1 }) - #expect(SearchFilter(query: "todo").matches(todo)) - #expect(SearchFilter(query: "inbox").matches(todo)) - // A lane is not matched through its cards: `card1` says "login", the lane does not. - #expect(!SearchFilter(query: "login").matches(todo)) + // 04 § Search filters *cards*. Under the tombstone model the trash held lane *entries* that + // had to be matched like rows, which is why `matches(_: Lane)` existed; lanes are never + // trashed now, so the overload went with them and every lane is always visible. + let visible = SearchFilter(query: "nothing-matches-this").visibleIDs(in: model, container: .board) + #expect(visible == Set(model.lanes.map(\.id))) } @Test("The visible universe keeps every live lane and only the matching cards") @@ -257,7 +247,7 @@ struct SearchFilterPredicateTests { defer { fixture.tearDown() } let model = try load(fixture) - let visible = SearchFilter(query: "login").visibleIDs(in: model, on: .live) + let visible = SearchFilter(query: "login").visibleIDs(in: model, container: .board) // Lanes are never hidden by a card query — a lane the filter empties is still a lane on the // board, so a lane selection survives a query that empties its body. #expect(visible.isSuperset(of: [lane1, lane2, lane3])) @@ -277,12 +267,9 @@ struct SearchFilterOrderTests { defer { fixture.tearDown() } let model = try load(fixture) - #expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5]) - #expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3]) - #expect(SelectionGrammar.order( - of: .card, - on: .live, - in: model, + #expect(SelectionGrammar.boardCards(in: model) == [card1, card2, card3, card4, card5]) + #expect(SelectionGrammar.boardCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3]) + #expect(SelectionGrammar.order(of: .card, in: .board, snapshot: model, filter: SearchFilter(query: "login") ) == [card1, card3]) } @@ -295,10 +282,7 @@ struct SearchFilterOrderTests { // `lane3`'s only card misses the query, and the lane is still in the order: the width // division is layout, and a `0` badge is the honest report. - #expect(SelectionGrammar.order( - of: .lane, - on: .live, - in: model, + #expect(SelectionGrammar.order(of: .lane, in: .board, snapshot: model, filter: SearchFilter(query: "login") ) == [lane1, lane2, lane3]) } @@ -310,16 +294,11 @@ struct SearchFilterOrderTests { let model = try load(fixture) // Unfiltered, card1 → card3 sweeps card2 up with it. - #expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, on: .live, in: model) + #expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model) == [card1, card2, card3]) // Filtered, the span between the same two endpoints is the two that are on screen. - #expect(SelectionGrammar.range( - from: card1, - to: card3, - kind: .card, - on: .live, - in: model, + #expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model, filter: SearchFilter(query: "login") ) == [card1, card3]) } @@ -330,34 +309,31 @@ struct SearchFilterOrderTests { defer { fixture.tearDown() } let model = try load(fixture) - #expect(SelectionGrammar.range( - from: card1, - to: card2, - kind: .card, - on: .live, - in: model, + #expect(SelectionGrammar.range(from: card1, to: card2, kind: .card, in: .board, snapshot: model, filter: SearchFilter(query: "login") ) == nil) } - @Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text") - func trashEntriesNarrow() throws { + @Test("Trash cards participate in the filter exactly like any other card") + func trashCardsNarrow() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let model = try load(fixture) - #expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2]) - #expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX]) + #expect(SelectionGrammar.trashCards(in: model) == [card1, card2]) + #expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1]) + // And there is no lane list in the trash at all — "Cards only. Lanes are never trashed". + #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model).isEmpty) + } - let filter = SearchFilter(query: "login") - #expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1]) - // The lane row matches on its *own* title, not on the card buried inside it. - #expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX]) - #expect(SelectionGrammar.trashEntries( - of: .lane, - in: model, - filter: SearchFilter(query: "polish") - ).isEmpty) + @Test("The trash's visible universe is its matching cards") + func trashUniverseNarrows() throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + #expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1]) + #expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2]) } @Test("The delete successor is drawn from what the lane is showing") @@ -367,12 +343,12 @@ struct SearchFilterOrderTests { let model = try load(fixture) // Unfiltered, deleting the untitled card lands on its lane's next card. - #expect(SelectionGrammar.successor(afterDeleting: [card3], in: model) == card4) + #expect(SelectionGrammar.successor(afterDeleting: [card3], snapshot: model) == card4) // Under `login`, card4 is hidden — and there is nothing else visible in that lane, so the // honest answer is nothing rather than a card the query animated out. #expect(SelectionGrammar.successor( afterDeleting: [card3], - in: model, + snapshot: model, filter: SearchFilter(query: "login") ) == nil) } @@ -405,11 +381,11 @@ struct SearchFilterStoreTests { let store = try BoardStore(rootURL: fixture.root) store.transient.isTrashVisible = true - store.select([card2], liveness: .trashed) + store.select([card2], in: .trash) store.selectAll() #expect(store.selection.ids == [card1, card2]) - store.select([card1], liveness: .trashed) + store.select([card1], in: .trash) store.searchQuery = "login" store.selectAll() #expect(store.selection.ids == [card1]) @@ -421,7 +397,7 @@ struct SearchFilterStoreTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card1, card2], liveness: .live, anchor: card1, head: card2) + store.select([card1, card2], in: .board, anchor: card1, head: card2) store.searchQuery = "login" #expect(store.selection.ids == [card1]) @@ -437,7 +413,7 @@ struct SearchFilterStoreTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([lane3], liveness: .live) + store.select([lane3], in: .board) store.searchQuery = "login" #expect(store.selection.ids == [lane3]) } @@ -449,12 +425,12 @@ struct SearchFilterStoreTests { let store = try BoardStore(rootURL: fixture.root) store.searchQuery = "login" - store.select([card1], liveness: .live) + store.select([card1], in: .board) store.clearSearch() #expect(store.searchQuery.isEmpty) #expect(store.selection.ids == [card1]) - #expect(SelectionGrammar.liveCards(in: store.snapshot, filter: store.searchFilter).count == 5) + #expect(SelectionGrammar.boardCards(in: store.snapshot, filter: store.searchFilter).count == 5) } @Test("A reload landing under an active query re-applies the filter to the selection") @@ -464,7 +440,7 @@ struct SearchFilterStoreTests { let store = try BoardStore(rootURL: fixture.root) store.searchQuery = "login" - store.select([card1, card3], liveness: .live, anchor: card1, head: card3) + store.select([card1, card3], in: .board, anchor: card1, head: card3) // An agent edits the title out of the match. The card is still there — this is not a vanish, // so only the *filter's* universe can eject it. @@ -502,7 +478,7 @@ struct SearchFilterStoreTests { let store = try BoardStore(rootURL: fixture.root) store.searchQuery = "login" - store.select([card1], liveness: .live) + store.select([card1], in: .board) store.transient.beginRename(of: card1, currentTitle: "Fix login") #expect(store.searchQuery == "login") @@ -526,7 +502,7 @@ struct SearchFilterStoreTests { let store = try BoardStore(rootURL: fixture.root) store.searchQuery = "login" - store.select([card1], liveness: .live) + store.select([card1], in: .board) store.transient.beginRename(of: card1, currentTitle: "Fix login") store.transient.updateRenameDraft("Fix login again") @@ -544,7 +520,7 @@ struct SearchFilterStoreTests { // `Todo` shows both its cards under this query, so the successor is the visible neighbour. store.searchQuery = "the" - store.select([card1], liveness: .live) + store.select([card1], in: .board) store.delete([card1]) #expect(store.selection.ids == [card2]) } @@ -572,7 +548,7 @@ struct SearchFilterExemptionTests { let filter = SearchFilter(query: "login") // Every live lane is in the visible universe, `Done` — which holds only a miss — included. - let visible = filter.visibleIDs(in: model, on: .live) + let visible = filter.visibleIDs(in: model, container: .board) #expect(visible.isSuperset(of: [lane1, lane2, lane3])) #expect(!visible.contains(card5)) @@ -623,7 +599,7 @@ struct SearchFilterExemptionTests { let store = try BoardStore(rootURL: fixture.root) store.searchQuery = "login" - store.select([card1], liveness: .live) + store.select([card1], in: .board) store.transient.beginRename(of: card1, currentTitle: "Fix login") store.transient.updateRenameDraft("Fix login thoroughly") @@ -644,7 +620,7 @@ struct SearchFilterExemptionTests { #expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly") } - @Test("A vanish still discards the editor — the carve-out is the filter's, not liveness's") + @Test("A vanish still discards the editor — the carve-out is the filter's, not the container's") func aVanishStillDiscardsTheEditor() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -652,10 +628,7 @@ struct SearchFilterExemptionTests { store.searchQuery = "login" store.transient.beginRename(of: card1, currentTitle: "Fix login") - try fixture.item( - "\(Ident.lane1)/\(Ident.card1)", - tombstoned(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.") - ) + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) await reload(store) #expect(store.transient.renameEditor == nil) diff --git a/KanbanTests/SelectionGrammarTests.swift b/KanbanTests/SelectionGrammarTests.swift index e9d8dd7..a15bd52 100644 --- a/KanbanTests/SelectionGrammarTests.swift +++ b/KanbanTests/SelectionGrammarTests.swift @@ -8,40 +8,27 @@ import Testing /// /// The grammar is written as a pure function precisely so it can be tested like one: a click, a /// selection, an anchor and a snapshot in, a selection and an anchor out — no window, no gesture, no -/// modifier flags. The boards underneath are **real loads off real temp trees**, because every rule -/// here reads `isDeleted`, card ordering, or `TrashModel`'s sort, and a hand-built `BoardModel` -/// would let all three drift from what the loader actually produces. +/// modifier flags. The boards underneath are **real loads off real temp trees**, because the rules +/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift +/// from what the loader actually produces. +/// +/// **Two homogeneity axes, not three** (resettled 2026-07-28): cards XOR lanes, and board XOR trash. +/// The third — card entries XOR lane entries *inside* the trash — retired with the lane entries it +/// separated, because lanes are never trashed. /// /// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`. // MARK: - Fixtures -/// More literal identities than `Ident` offers: a three-lane range needs five cards, and the trash's -/// interleaving needs entries whose folder names are distinguishable in the sort's tie-break. +/// More literal identities than `Ident` offers: a three-lane range needs five cards. private enum More { static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" - static let laneY = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" } -private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { - """ - --- - schema: 1 - title: \(title) - order: \(order) - deleted: \(deleted) - --- - \(title) body. - - """ -} - -/// Three live lanes, five live cards and one tombstoned card — enough that a flatten-order range -/// crosses two lane boundaries and has something to *skip* on the way. +/// Three lanes and five cards — enough that a flatten-order range crosses two lane boundaries. /// -/// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own -/// `deleted:` and is in none of it. +/// Flatten order is `[card1, card2, card3, card4, card5]`. @MainActor private func makeLiveBoard() throws -> WriterFixture { let fixture = try WriterFixture() @@ -51,27 +38,23 @@ private func makeLiveBoard() throws -> WriterFixture { try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) 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.lane2)/\(Ident.card4)", tombstoned(order: "2048", title: "Fourth")) + try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth")) try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth")) return fixture } -/// A trash whose two row kinds **interleave**, which is the only shape that can prove a range skips. -/// -/// `TrashModel`'s sort is newest `deleted` first, so the entry order is -/// `[card1, laneX, card2, laneY, card3]` — a card range from `card1` to `card3` has two lane rows -/// sitting inside its span, and a lane range from `laneX` to `laneY` has a card row inside its own. +/// A board with one lane and three cards in its `.trash/` — the container the trash-side grammar +/// walks, in `order` display order (`[card1, card2, card3]`, newest first by ordinary ranks). @MainActor private func makeTrashBoard() 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)", tombstoned(order: "1024", title: "First", deleted: "2026-03-05T10:00:00Z")) - try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second", deleted: "2026-03-05T08:00:00Z")) - try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "Third", deleted: "2026-03-05T06:00:00Z")) - try fixture.item(More.laneX, tombstoned(order: "2048", title: "Archive", deleted: "2026-03-05T09:00:00Z")) - try fixture.item(More.laneY, tombstoned(order: "3072", title: "Old", deleted: "2026-03-05T07:00:00Z")) + try fixture.item("\(Ident.lane1)/\(More.card6)", Item.rich(order: "1024", title: "Live")) + try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "256", title: "First")) + try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "512", title: "Second")) + try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) return fixture } @@ -83,19 +66,18 @@ private let card2 = ItemID(rawValue: Ident.card2) private let card3 = ItemID(rawValue: Ident.card3) private let card4 = ItemID(rawValue: Ident.card4) private let card5 = ItemID(rawValue: More.card5) -private let laneX = ItemID(rawValue: More.laneX) -private let laneY = ItemID(rawValue: More.laneY) +private let card6 = ItemID(rawValue: More.card6) private func load(_ fixture: WriterFixture) throws -> BoardModel { try BoardLoader.load(boardRoot: fixture.root).model } -private func target(_ id: ItemID, _ kind: SelectionKind, _ side: Liveness = .live) -> SelectionTarget { - SelectionTarget(id: id, kind: kind, side: side) +private func target(_ id: ItemID, _ kind: SelectionKind, _ container: ItemContainer = .board) -> SelectionTarget { + SelectionTarget(id: id, kind: kind, container: container) } -private func set(_ ids: Set, _ side: Liveness = .live) -> ItemReferenceSet { - ItemReferenceSet(ids: ids, liveness: side) +private func set(_ ids: Set, _ container: ItemContainer = .board) -> ItemReferenceSet { + ItemReferenceSet(ids: ids, container: container) } /// One click, with the grammar's own defaults filled in. @@ -123,7 +105,7 @@ private func click( @Suite("SelectionGrammar ▸ order") struct SelectionOrderTests { - @Test("Live cards flatten lane order first, then card order — tombstones excluded") + @Test("Board cards flatten lane order first, then card order") func flattenOrder() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } @@ -131,22 +113,21 @@ struct SelectionOrderTests { // "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, // top-to-bottom)" — the multi-drag order (04-interactions.md ▸ Drag and drop). - #expect(SelectionGrammar.liveCards(in: snapshot) == [card1, card2, card3, card5]) - #expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3]) + #expect(SelectionGrammar.boardCards(in: snapshot) == [card1, card2, card3, card4, card5]) + #expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3]) } - @Test("Trash order lists are one sort, filtered to one kind") - func trashOrderPerKind() throws { + @Test("The trash's list is its cards, in `order`; its lane list is empty by construction") + func trashOrder() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) - // The single ordering the quasi-lane shows, newest first. - #expect(TrashModel.entries(of: snapshot).map(\.id) == [card1, laneX, card2, laneY, card3]) - // Each kind's list is that ordering with the other kind's rows dropped — which is exactly - // what makes a ⇧-range step over them (04-interactions.md ▸ The trash). - #expect(SelectionGrammar.trashEntries(of: .card, in: snapshot) == [card1, card2, card3]) - #expect(SelectionGrammar.trashEntries(of: .lane, in: snapshot) == [laneX, laneY]) + #expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3]) + #expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3]) + // "Cards only. Lanes are never trashed" — so there is no list to walk rather than a rule + // saying not to (03-board-ui.md § Trash). + #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty) } @Test("A selection's kind is derived from the snapshot, and a ghost selection has none") @@ -158,10 +139,10 @@ struct SelectionOrderTests { #expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card) #expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane) #expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil) - // A tombstoned card is on neither side's live list, and the live side is what this set says. - #expect(SelectionGrammar.kind(of: set([card4]), in: snapshot) == nil) + // A set claiming a container that does not hold its members answers nothing. + #expect(SelectionGrammar.kind(of: set([card1], .trash), in: snapshot) == nil) // Members that name nothing are ignored; one that names something still answers. - #expect(SelectionGrammar.kind(of: set([card4, card1]), in: snapshot) == .card) + #expect(SelectionGrammar.kind(of: set([ItemID(rawValue: Ident.indexless), card1]), in: snapshot) == .card) } } @@ -205,9 +186,9 @@ struct PlainClickTests { #expect(card.selection == set([card1])) #expect(card.anchor == card1) - // Nor does the toggle reach across the boundary: a live click on a trashed sole selection - // of the same id is a replace. - let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trashed), anchor: lane1, in: snapshot, togglesOnRepeat: true) + // Nor does the toggle reach across the boundary: a board click on a trash-side sole + // selection of the same id is a replace. + let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trash), anchor: lane1, in: snapshot, togglesOnRepeat: true) #expect(crossed.selection == set([lane1])) } } @@ -260,24 +241,19 @@ struct CommandClickTests { #expect(ontoCard.anchor == card1) } - @Test("⌘-click across the liveness boundary replaces too") - func acrossSideReplaces() throws { + @Test("⌘-click across the container boundary replaces too") + func acrossContainerReplaces() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) - // "Selection is homogeneous by liveness … a selection never mixes live and tombstoned" - // (04 ▸ The trash). The live lane1 is the only live thing on this board. - let intoTrash = click(target(card1, .card, .trashed), .command, selection: set([lane1]), anchor: lane1, in: snapshot) - #expect(intoTrash.selection == set([card1], .trashed)) + // "A selection never mixes trash cards with board cards — a single container rule replacing + // the old liveness law" (04 ▸ The trash, resettled 2026-07-28). + let intoTrash = click(target(card1, .card, .trash), .command, selection: set([card6]), anchor: card6, in: snapshot) + #expect(intoTrash.selection == set([card1], .trash)) - let backOut = click(target(lane1, .lane), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot) - #expect(backOut.selection == set([lane1])) - - // And within the trash, the second axis: card entries XOR lane entries. - let ontoLaneEntry = click(target(laneX, .lane, .trashed), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot) - #expect(ontoLaneEntry.selection == set([laneX], .trashed)) - #expect(ontoLaneEntry.anchor == laneX) + let backOut = click(target(card6, .card), .command, selection: set([card1, card2], .trash), anchor: card2, in: snapshot) + #expect(backOut.selection == set([card6])) } @Test("⌘-click with nothing — or nothing real — selected replaces") @@ -292,7 +268,8 @@ struct CommandClickTests { // A selection whose members all name nothing the board renders counts as empty: a ⌘-click // after a foreign delete starts a fresh set rather than extending a ghost. - let fromGhost = click(target(card1, .card), .command, selection: set([card4]), anchor: card4, in: snapshot) + let ghost = ItemID(rawValue: Ident.indexless) + let fromGhost = click(target(card1, .card), .command, selection: set([ghost]), anchor: ghost, in: snapshot) #expect(fromGhost.selection == set([card1])) } } @@ -303,18 +280,16 @@ struct CommandClickTests { @Suite("SelectionGrammar ▸ ⇧-click") struct ShiftClickTests { - @Test("A ⇧-range spans the flatten order across lanes, skipping tombstones, and leaves the anchor put") + @Test("A ⇧-range spans the flatten order across lanes and leaves the anchor put") func rangeAcrossLanes() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot) - #expect(outcome.selection == set([card1, card2, card3, card5])) + #expect(outcome.selection == set([card1, card2, card3, card4, card5])) // Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin. #expect(outcome.anchor == card1) - // card4 is tombstoned and in no order list, so no range can pick it up. - #expect(!outcome.selection.ids.contains(card4)) } @Test("Direction does not matter — the range is the span between anchor and target") @@ -324,7 +299,7 @@ struct ShiftClickTests { let snapshot = try load(fixture) let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot) - #expect(backwards.selection == set([card1, card2, card3, card5])) + #expect(backwards.selection == set([card1, card2, card3, card4, card5])) #expect(backwards.anchor == card5) } @@ -350,8 +325,9 @@ struct ShiftClickTests { #expect(noAnchor.selection == set([card3])) #expect(noAnchor.anchor == card3) - // An anchor that names nothing the board renders (card4 is tombstoned). - let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, in: snapshot) + // An anchor that names nothing the board renders. + let ghost = ItemID(rawValue: Ident.indexless) + let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: ghost, in: snapshot) #expect(vanished.selection == set([card3])) #expect(vanished.anchor == card3) @@ -361,32 +337,35 @@ struct ShiftClickTests { #expect(acrossKind.selection == set([card3])) #expect(acrossKind.anchor == card3) - // Same for a side crossing: the live card list holds no trash row. - let acrossSide = click(target(card3, .card, .trashed), .shift, selection: set([card1]), anchor: card1, in: snapshot) - #expect(acrossSide.selection == set([card3], .trashed)) + // Same for a container crossing: the board's card list holds no trash card. + let acrossContainer = click(target(card3, .card, .trash), .shift, selection: set([card1]), anchor: card1, in: snapshot) + #expect(acrossContainer.selection == set([card3], .trash)) } - @Test("A trash card range steps over the lane entries inside its span") - func trashCardRangeSkipsLaneEntries() throws { + @Test("A trash range walks the column's own order") + func trashRangeWalksTheColumn() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) - // Sorted order is [card1, laneX, card2, laneY, card3]; a card range collects card rows only. - let outcome = click(target(card3, .card, .trashed), .shift, selection: set([card1], .trashed), anchor: card1, in: snapshot) - #expect(outcome.selection == set([card1, card2, card3], .trashed)) + // The column's order is `[card1, card2, card3]`; a range between the ends takes all three, + // with no kind to step over — lanes are never trashed. + let outcome = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot) + #expect(outcome.selection == set([card1, card2, card3], .trash)) #expect(outcome.anchor == card1) } - @Test("A trash lane range steps over the card entries inside its span") - func trashLaneRangeSkipsCardEntries() throws { + @Test("A range never crosses the container boundary") + func trashRangeStopsAtTheBoundary() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) - let outcome = click(target(laneY, .lane, .trashed), .shift, selection: set([laneX], .trashed), anchor: laneX, in: snapshot) - #expect(outcome.selection == set([laneX, laneY], .trashed)) - #expect(outcome.anchor == laneX) + // `card6` is on the board and `card1` is in the trash: no single order list holds both, so + // the anchor is not findable and the ⇧-click degrades to a plain one. + let outcome = click(target(card1, .card, .trash), .shift, selection: set([card6]), anchor: card6, in: snapshot) + #expect(outcome.selection == set([card1], .trash)) + #expect(outcome.anchor == card1) } } @@ -395,53 +374,46 @@ struct ShiftClickTests { @Suite("MarqueeMath") struct MarqueeMathTests { - private static func card(_ id: ItemID, _ y: CGFloat, side: Liveness = .live) -> MarqueeTarget { - MarqueeTarget(id: id, kind: .card, side: side, frame: CGRect(x: 0, y: y, width: 100, height: 40)) + private static func card(_ id: ItemID, _ y: CGFloat, container: ItemContainer = .board) -> MarqueeTarget { + MarqueeTarget(id: id, kind: .card, container: container, frame: CGRect(x: 0, y: y, width: 100, height: 40)) } - private static func laneEntry(_ id: ItemID, _ y: CGFloat) -> MarqueeTarget { - MarqueeTarget(id: id, kind: .lane, side: .trashed, frame: CGRect(x: 0, y: y, width: 100, height: 40)) - } - - @Test("On the live side the band takes intersecting cards, and only cards") - func liveSideTakesCards() { + @Test("On the board side the band takes intersecting cards, and only cards") + func boardSideTakesCards() { let targets = [ Self.card(card1, 0), Self.card(card2, 100), // A lane registered by mistake is still never swept: "click-drag rubber-bands across // lanes" (04-interactions.md § Selection) — across, not over. - MarqueeTarget(id: lane1, kind: .lane, side: .live, frame: CGRect(x: 0, y: 0, width: 200, height: 400)), + MarqueeTarget(id: lane1, kind: .lane, container: .board, frame: CGRect(x: 0, y: 0, width: 200, height: 400)), // A trash row cannot be reached by a band that began on the board. - Self.card(card3, 10, side: .trashed) + Self.card(card3, 10, container: .trash) ] - let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, side: .live) + let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, in: .board) #expect(ids == [card1, card2]) } - @Test("On the trash side the topmost intersecting row's kind wins") - func trashSideIsHomogeneousByKind() { - // Interleaved rows, the trash's own shape: card, lane, card. + /// **There is no kind rule any more.** The tombstone model interleaved card rows and lane rows + /// in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind; lanes + /// are never trashed now, so both containers hold cards and one line serves both. + @Test("On the trash side the band takes the trash's cards, and stays on its own side") + func trashSideTakesItsOwnCards() { let targets = [ - Self.card(card1, 0, side: .trashed), - Self.laneEntry(laneX, 50), - Self.card(card2, 100, side: .trashed) + Self.card(card1, 0, container: .trash), + Self.card(card2, 100, container: .trash), + Self.card(card3, 50) ] let all = CGRect(x: 0, y: 0, width: 50, height: 200) - // Begun on a card row: the lane row between the two cards is stepped over, exactly as a - // ⇧-range does (04-interactions.md ▸ The trash). - #expect(MarqueeMath.selection(rect: all, targets: targets, side: .trashed) == [card1, card2]) - - // Begun below it, so the lane row is topmost: only lane entries come back. - let lower = CGRect(x: 0, y: 60, width: 50, height: 200) - #expect(MarqueeMath.selection(rect: lower, targets: targets, side: .trashed) == [laneX]) + #expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2]) + #expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3]) } @Test("A band touching nothing selects nothing") func emptyBand() { let targets = [Self.card(card1, 0), Self.card(card2, 100)] - #expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, side: .live).isEmpty) - #expect(MarqueeMath.selection(rect: .zero, targets: [], side: .trashed).isEmpty) + #expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, in: .board).isEmpty) + #expect(MarqueeMath.selection(rect: .zero, targets: [], in: .trash).isEmpty) } } @@ -463,15 +435,15 @@ struct SelectionAnchorTests { func anchorDefaults() { let state = TransientBoardState() - state.select([card1], liveness: .live) + state.select([card1], in: .board) #expect(state.selectionAnchor == card1) // "A marquee and wholesale selections pass no anchor deliberately." - state.select([card1, card2], liveness: .live) + state.select([card1, card2], in: .board) #expect(state.selectionAnchor == nil) // An explicit anchor wins over the default in both directions. - state.select([card1, card2, card3], liveness: .live, anchor: card2) + state.select([card1, card2, card3], in: .board, anchor: card2) #expect(state.selectionAnchor == card2) state.clearSelection() @@ -484,7 +456,7 @@ struct SelectionAnchorTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card1, card2], liveness: .live, anchor: card1) + store.select([card1, card2], in: .board, anchor: card1) // A survivor of the same reload proves the rule is about the anchor, not about reloading. try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) await reload(store) @@ -497,17 +469,18 @@ struct SelectionAnchorTests { #expect(store.transient.selectionAnchor == nil) } - @Test("A liveness flip is a vanish for the anchor too") - func flippedAnchorIsDropped() async throws { + @Test("A container crossing is a vanish for the anchor too") + func crossedAnchorIsDropped() async throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card1, card2], liveness: .live, anchor: card1) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) + store.select([card1, card2], in: .board, anchor: card1) + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) await reload(store) - // "A flip is a vanish from its side of the boundary" (02-architecture.md's reload rule). + // "A container crossing is a vanish for this purpose" (02-architecture.md's reload rule, + // resettled 2026-07-28). #expect(store.selection.ids == [card2]) #expect(store.transient.selectionAnchor == nil) } @@ -520,7 +493,7 @@ struct SelectionAnchorTests { let state = TransientBoardState() // A ⌘-click that toggled the anchor's own row out leaves the anchor standing. - state.select([card2, card3], liveness: .live, anchor: card1) + state.select([card2, card3], in: .board, anchor: card1) #expect(state.selectionAnchor == card1) let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot) #expect(outcome.selection == set([card1, card2, card3])) @@ -533,17 +506,17 @@ struct SelectionAnchorTests { @Suite("BoardStore ▸ Select All") struct SelectAllTests { - @Test("Select All takes every rendered card — tombstones excluded, lanes never") + @Test("Select All takes every rendered card, and never a lane") func liveBranch() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([lane2], liveness: .live) + store.select([lane2], in: .board) store.selectAll() // "All visible cards on the board" (04-interactions.md ▸ The map). - #expect(store.selection == set([card1, card2, card3, card5])) + #expect(store.selection == set([card1, card2, card3, card4, card5])) // The lane the anchor named is not in the new set, so the anchor goes with it. #expect(store.transient.selectionAnchor == nil) } @@ -554,25 +527,23 @@ struct SelectAllTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card3], liveness: .live) + store.select([card3], in: .board) store.selectAll() #expect(store.transient.selectionAnchor == card3) } - @Test("On the trash side Select All stays within the selection's kind") - func trashBranchIsHomogeneousByKind() throws { + @Test("On the trash side Select All takes every visible trash card") + func trashBranchTakesTheColumn() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.isTrashVisible = true - store.select([card2], liveness: .trashed) + // "With the trash visible and a non-empty trash selection, Select All selects all visible + // trash cards" (04 ▸ The map, resettled 2026-07-28). There is no kind clause left to honour. + store.select([card2], in: .trash) store.selectAll() - #expect(store.selection == set([card1, card2, card3], .trashed)) - - store.select([laneX], liveness: .trashed) - store.selectAll() - #expect(store.selection == set([laneX, laneY], .trashed)) + #expect(store.selection == set([card1, card2, card3], .trash)) } @Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board") @@ -582,20 +553,20 @@ struct SelectAllTests { let store = try BoardStore(rootURL: fixture.root) // Hidden — the column is invisible to every gesture (04 ▸ The trash). - store.select([card1], liveness: .trashed) + store.select([card1], in: .trash) store.selectAll() - #expect(store.selection.liveness == .live) + #expect(store.selection.container == .board) - // Shown, but nothing tombstoned is selected. + // Shown, but nothing in the trash is selected. store.transient.isTrashVisible = true store.clearSelection() store.selectAll() - #expect(store.selection.liveness == .live) + #expect(store.selection.container == .board) - // Shown, trashed side, but the ids name no row: a guess would be worse than the board. - store.select([card5], liveness: .trashed) + // Shown, trash side, but the ids name no card there: a guess would be worse than the board. + store.select([card5], in: .trash) store.selectAll() - #expect(store.selection.liveness == .live) + #expect(store.selection.container == .board) } @Test("Select All on a board with no rendered cards clears rather than selecting an empty set") @@ -606,7 +577,7 @@ struct SelectAllTests { try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) let store = try BoardStore(rootURL: fixture.root) - store.select([lane1], liveness: .live) + store.select([lane1], in: .board) store.selectAll() #expect(store.selection.isEmpty) #expect(store.transient.selectionAnchor == nil) diff --git a/KanbanTests/StyleModelTests.swift b/KanbanTests/StyleModelTests.swift index 5b69276..11f09d9 100644 --- a/KanbanTests/StyleModelTests.swift +++ b/KanbanTests/StyleModelTests.swift @@ -109,7 +109,7 @@ struct StyleEditorSessionTests { let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .items([card1, card2])) - try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second")) + try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2) await reload(store) let session = try #require(store.transient.styleEditor) @@ -126,7 +126,7 @@ struct StyleEditorSessionTests { let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .items([card3])) - try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) + try FileManager.default.removeItem(at: fixture.url(Ident.lane2)) await reload(store) // The card's own flag never changed — its lane's did. Effective liveness is ancestor-walked, @@ -141,7 +141,7 @@ struct StyleEditorSessionTests { let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .items([card1])) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) await reload(store) #expect(store.transient.styleEditor?.target != .board) @@ -155,8 +155,8 @@ struct StyleEditorSessionTests { let store = try BoardStore(rootURL: fixture.root) store.transient.beginStyleEditor(for: .board) - try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) - try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) + try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) + try FileManager.default.removeItem(at: fixture.url(Ident.lane2)) await reload(store) #expect(store.transient.styleEditor?.target == .board) diff --git a/KanbanTests/StyleWriteTests.swift b/KanbanTests/StyleWriteTests.swift index a7b1dcd..dde1ea9 100644 --- a/KanbanTests/StyleWriteTests.swift +++ b/KanbanTests/StyleWriteTests.swift @@ -54,9 +54,9 @@ private func makeBoard() throws -> WriterFixture { try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"])) try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second")) try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"])) - try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third", keys: ["deleted: 2026-03-03T09:00:00Z"])) + try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third")) try fixture.item(Ident.lane3, Item.uneditable) - try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone", keys: ["deleted: 2026-03-03T09:00:00Z"])) + try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone")) try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden")) return fixture } @@ -256,26 +256,27 @@ struct StyleWriteTests { #expect(lane(lane1, in: model)?.background.isMissing == true) } - @Test("Vanished, tombstoned and hidden targets are skipped silently") + @Test("Vanished and trashed targets are skipped silently") func skipsTargetsThatRenderNowhere() throws { let fixture = try makeBoard() defer { fixture.tearDown() } + try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3) + try FileManager.default.removeItem(at: fixture.url(Ident.lane4)) let store = try BoardStore(rootURL: fixture.root) let log = BracketLog() log.attach(to: store) - let tombstoned = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") - let hidden = try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") + let trashedBytes = try fixture.indexData(".trash/\(Ident.card3)") - // An id that names nothing, a tombstoned card, and a live card under a tombstoned lane — - // "nothing is ever written into a vanished folder", ancestor walk included. + // An id that names nothing, and a card that has been moved to the trash — "everything + // edit-shaped is disabled on trash selections" (04 ▸ The trash), and "nothing is ever + // written into a vanished folder". store.applyStyle( to: .items([ItemID(rawValue: Ident.indexless), card3, card4]), background: .set("obsidian") ) #expect(log.begins == 0) - #expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == tombstoned) - #expect(try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") == hidden) + #expect(try fixture.indexData(".trash/\(Ident.card3)") == trashedBytes) #expect(store.banners.oneShots.isEmpty) } @@ -314,13 +315,13 @@ struct StyleWriteTests { // MARK: Subjects and levels - @Test("Subjects are the live targets in display order, with their current values") - func subjectsAreLiveTargetsInDisplayOrder() throws { + @Test("Subjects are the board's targets in display order, with their current values") + func subjectsAreBoardTargetsInDisplayOrder() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let subjects = store.styleSubjects(of: .items([card2, card1, card3, lane1])) + let subjects = store.styleSubjects(of: .items([card2, card1, lane1])) #expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom") #expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing]) #expect(subjects.last?.folder.lastPathComponent == Ident.card2) diff --git a/KanbanTests/TaskCheckboxWriteTests.swift b/KanbanTests/TaskCheckboxWriteTests.swift index 61742e4..a187631 100644 --- a/KanbanTests/TaskCheckboxWriteTests.swift +++ b/KanbanTests/TaskCheckboxWriteTests.swift @@ -328,19 +328,19 @@ struct StoreToggleTaskMarkerTests { #expect(try fixture.indexData(cardPath) == before) } - @Test("A tombstoned card's checkbox does not write") - func aTombstonedCardWritesNothing() throws { + @Test("A trashed card's checkbox does not write") + func aTrashedCardWritesNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - try BoardWriter.deleteItem(at: fixture.url(cardPath)) + try fixture.move(cardPath, toTrash: Ident.card1) let store = try BoardStore(rootURL: fixture.root) - let before = try body(of: fixture, cardPath) + let before = try body(of: fixture, ".trash/\(Ident.card1)") let offsets = markerOffsets(in: checklistBody) - // Effective liveness, ancestor-walked (`BoardStore.liveItem`): a card in the trash renders - // nowhere, so nothing may write through a preview of it. + // `BoardStore.boardItem` is the board container and only it: a trashed card does not open, + // so nothing may write through a preview of one (03 ▸ Trash's no-editing rule). store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false) - #expect(try body(of: fixture, cardPath) == before) + #expect(try body(of: fixture, ".trash/\(Ident.card1)") == before) } } diff --git a/KanbanTests/TransientBoardStateTests.swift b/KanbanTests/TransientBoardStateTests.swift index 8ef2acd..9fb668e 100644 --- a/KanbanTests/TransientBoardStateTests.swift +++ b/KanbanTests/TransientBoardStateTests.swift @@ -76,9 +76,9 @@ struct TransientBoardStateTests { // Overlapping but different: card1 is selected and cut, card2 is selected and dragged, // card3 is dragged and cut. Whatever happens to one member, two of the three sets are // always the control. - store.transient.select([card1, card2], liveness: .live) - store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live) - store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live) + store.transient.select([card1, card2], in: .board) + store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board) + store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) await reload(store) @@ -98,7 +98,7 @@ struct TransientBoardStateTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let seeded = ItemReferenceSet(ids: [card1, card2], liveness: .live) + let seeded = ItemReferenceSet(ids: [card1, card2], container: .board) // Direction one — the live search filter (04-interactions.md § Search): card1's title and // body both miss the query, so it is not in the visible set the predicate produced, and @@ -116,75 +116,71 @@ struct TransientBoardStateTests { #expect(filtered == resolved, "one primitive, two universes — the two rules are one rule") } - @Test("Tombstoning a lane ejects its cards from every referencing set — liveness is effective") - func effectiveLivenessEjectsFromEverySet() async throws { + @Test("A container crossing is a vanish for every item-referencing set") + func aContainerCrossingEjectsFromEverySet() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.transient.select([card1, card2], liveness: .live) - store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live) + store.transient.select([card1, card2], in: .board) + store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], container: .board) - try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) + // A foreign writer moves card1 and card2 into the board's trash — the move a delete is. + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) + try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2) await reload(store) - // The cards' own flags never changed, but their lane's did — and liveness is ancestor-walked - // (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and - // nothing invisible may stay selected or pending-cut. - let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 } - #expect(survivor?.isDeleted == false, "the card's own flag is untouched") + // 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches + // UUID *and* container side … a foreign move that trashes a selected board card ejects it + // from the selection (and from the pending cut)". + let trashedIDs = Set(store.snapshot.trash.map(\.id)) + #expect(trashedIDs == Set([card1, card2])) #expect(store.transient.selection.ids.isEmpty) - #expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut") + #expect(store.transient.pendingCut.ids == [card3], "card3 never moved, so card3 stays cut") } - @Test("A card with its own deleted: under a tombstoned lane is in neither universe") - func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws { + @Test("A restore ejects a trash-side set the same way, in the other direction") + func restoringEjectsATrashSideSet() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } + try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3) let store = try BoardStore(rootURL: fixture.root) - // Everything that can point at an item, all aimed at card3 — and on the *trashed* side, as - // if the user had clicked its trash row a moment before its lane went too. - store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3) - store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed) - store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed) + // Everything that can point at an item, all aimed at the trashed card. + store.transient.select([card3], in: .trash, anchor: card3, head: card3) + store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash) + store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash) store.transient.beginRename(of: card3, currentTitle: "Third") + #expect(store.snapshot.trash.map(\.id) == [card3]) - // Both flags at once: the card carries its own `deleted:` *and* an agent tombstones its lane. - try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third")) - try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) + // An agent moves it back out — a container crossing the other way. + try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3) await reload(store) - let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 }) - #expect(lane.isDeleted) - #expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk") + #expect(store.snapshot.trash.isEmpty) + #expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3)) + #expect(ItemContainer.board.ids(in: store.snapshot).contains(card3), + "presence is the whole test — the card is simply on the other side now") - // The lane's single entry subsumes it (03-board-ui.md's absolute ancestor walk), so it has - // no row — and "the trashed side has exactly one definition: the set with trash rows" - // (02-architecture.md, settled). No row, no membership, on either side. - #expect(!TrashModel.entries(of: store.snapshot).map(\.id).contains(card3)) - #expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .trashed).contains(card3)) - #expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .live).contains(card3)) - - // So every set holding it is ejected — from the trashed side here, and from the live side - // for the same reason, which the value function says directly since a set has one side. #expect(store.transient.selection.ids.isEmpty) #expect(store.transient.dragMembers.ids.isEmpty) #expect(store.transient.pendingCut.ids.isEmpty) - #expect(ItemReferenceSet(ids: [card3], liveness: .live).resolved(against: store.snapshot).isEmpty) - // And no cursor or editor survives on it: an anchor that ranges from somewhere the board - // draws nowhere would be a range the user cannot see the origin of. + // And no cursor survives on it: an anchor that ranges from a container the selection has left + // would be a range the user cannot see the origin of. #expect(store.transient.selectionAnchor == nil) #expect(store.transient.selectionHead == nil) - #expect(store.transient.renameEditor == nil) - // Menu validation agrees, which is the point of the sets and the commands reading one rule: - // neither ⌘⌫ twin offers to act on it. - let stale = ItemReferenceSet(ids: [card3], liveness: .trashed) - #expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot)) - #expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live), - in: store.snapshot)) + // The rename editor tracks the *board* container, so a card arriving back on the board keeps + // its editor — "a foreign move mid-rename is invisible" (04 ▸ Grammar). It is the departure + // into the trash that discards it, which the delete tests cover. + #expect(store.transient.renameEditor?.targetID == card3) + + // Menu validation agrees with the sets, which is the point of both reading one rule. + let stale = ItemReferenceSet(ids: [card3], container: .trash) + #expect(!TrashModel.canDelete(selection: stale, in: store.snapshot)) + #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], container: .board), + in: store.snapshot)) } @Test("Every set resolves to empty against a board whose lanes all vanished") @@ -193,9 +189,9 @@ struct TransientBoardStateTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.transient.select([card1, lane1], liveness: .live) - store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live) - store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live) + store.transient.select([card1, lane1], in: .board) + store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board) + store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board) store.transient.beginPlaceholder(inLane: lane2) try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) @@ -238,13 +234,12 @@ struct TransientBoardStateTests { store.transient.beginPlaceholder(inLane: lane2) store.transient.updateDraft("Half a title") - try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) + try FileManager.default.removeItem(at: fixture.url(Ident.lane2)) await reload(store) - // Still in the snapshot — the trash renders its single entry — but the lane the editor was - // sitting in is not on the board any more, which is the same vanish as far as an overlay - // anchored to it is concerned. - #expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true) + // A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in + // the reload" is literally the whole test. + #expect(store.snapshot.lanes.first { $0.id == lane2 } == nil) #expect(store.transient.newCardPlaceholder == nil) } @@ -376,37 +371,36 @@ struct TransientBoardStateTests { #expect(store.transient.renameEditor == nil) } - @Test("A rename whose target is tombstoned is discarded — a liveness flip is a vanish") - func renameDiscardedWhenItsTargetIsTombstoned() async throws { + @Test("A rename whose target enters the trash is discarded — a container crossing is a vanish") + func renameDiscardedWhenItsTargetIsTrashed() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.beginRename(of: card1, currentTitle: "First") - try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) await reload(store) - #expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true) + // 04 ▸ Grammar: "a target that is trashed, deleted, or gone at commit time discards the + // editor and its keystrokes silently — entering the trash is a vanish from the board". + #expect(store.snapshot.trash.map(\.id) == [card1]) #expect(store.transient.renameEditor == nil) } - @Test("A rename under a tombstoned lane is discarded too — liveness is effective") - func renameDiscardedWhenItsLaneIsTombstoned() async throws { + @Test("A rename whose lane is deleted is discarded too — the card went with it") + func renameDiscardedWhenItsLaneIsDeleted() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.beginRename(of: card1, currentTitle: "First") - try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) + try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) await reload(store) - // The card's own flag never changed; its lane's did. The ancestor walk is absolute — the - // card renders nowhere, so the editor sitting on it has no target - // (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor). - let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 } - #expect(card?.isDeleted == false) + // A lane delete is physical, so the card is simply not in the snapshot — no ancestor walk. + #expect(store.snapshot.lanes.first { $0.id == lane1 } == nil) #expect(store.transient.renameEditor == nil) } @@ -457,17 +451,17 @@ struct TransientBoardStateTests { #expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember") - store.select([lane2], liveness: .live) + store.select([lane2], in: .board) #expect(store.transient.lastActiveLaneID == lane2) // A *card* selection is its lane holding selection too — 04's "the lane that most recently // held selection or a creation". - store.select([card1], liveness: .live) + store.select([card1], in: .board) #expect(store.transient.lastActiveLaneID == lane1) // A cross-lane selection names no single lane, so it leaves the memory alone rather than // guessing at one of the two. - store.select([card1, card3], liveness: .live) + store.select([card1, card3], in: .board) #expect(store.transient.lastActiveLaneID == lane1) // Deselecting does not un-happen where the user was working: ⌘N with nothing selected is @@ -485,11 +479,11 @@ struct TransientBoardStateTests { store.transient.beginPlaceholder(inLane: lane2) #expect(store.transient.lastActiveLaneID == lane2) - try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) + try FileManager.default.removeItem(at: fixture.url(Ident.lane2)) await reload(store) - // A lane that renders nowhere is no target at all; `NewCardTarget` then falls through to - // the first lane rather than proposing the trash. + // A lane that is gone is no target at all; `NewCardTarget` then falls through to the first + // lane rather than proposing the trash. #expect(store.transient.lastActiveLaneID == nil) } diff --git a/KanbanTests/TrashModelTests.swift b/KanbanTests/TrashModelTests.swift index ac357b1..dbabe8b 100644 --- a/KanbanTests/TrashModelTests.swift +++ b/KanbanTests/TrashModelTests.swift @@ -2,476 +2,364 @@ 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. +/// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash, +/// resettled 2026-07-28 (the materialized trash). +/// +/// **The suite is much smaller than the tombstone model's was, and that is the finding.** The rows +/// no longer need deriving — the trash's contents *are* `snapshot.trash`, parsed by the same card +/// parse the lanes use and already in `order` display order — so the deterministic timestamp sort, +/// the absolute ancestor walk, and the returning-card count all went with the entries they described. +/// What remains is what the *commands* need: the two purge confirmations' phrasing, and the menu +/// validation that stages Delete by place. Plus `ItemPath`, the location vocabulary that replaced +/// `TrashModel.paths`. /// /// 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`. +/// other model suite here does it: only the loader produces `.trash` 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. +/// A few more literal identities than `Ident` offers — folder name is the display 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 card(order: String, title: String) -> String { + "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" } -private func live(order: String, title: String) -> String { - "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" +private func untitled(order: String) -> String { + "---\nschema: 1\norder: \(order)\n---\nNo title.\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) +/// One lane with two cards, and three cards in the board's trash. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(More.laneA, card(order: "1024", title: "Todo")) + try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First")) + try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second")) + // Newest-first by ordinary ranks: every arrival mints above the current top. + try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "Oldest")) + try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle")) + try fixture.item(".trash/\(More.cardF)", card(order: "256", title: "Newest")) + return fixture } -// MARK: - The sort +private let laneA = ItemID(rawValue: More.laneA) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let cardD = ItemID(rawValue: More.cardD) +private let cardE = ItemID(rawValue: More.cardE) +private let cardF = ItemID(rawValue: More.cardF) -@Suite("TrashModel ▸ sort") -struct TrashModelSortTests { +// MARK: - The contents - @Test("Dated entries sort newest first, and lane entries interleave by their own stamp") - func newestFirstWithLanesInterleaved() throws { - let fixture = try WriterFixture() +/// **There is no derivation left to test** — so what this suite pins instead is that the container +/// *is* the list, in the order the column shows it, which is the pivot's whole claim. +@MainActor +@Suite("The trash's contents are the container") +struct TrashContentsTests { + + @Test("The trash is `snapshot.trash`, newest first by ordinary ranks") + func theContainerIsTheList() throws { + let fixture = try makeBoard() 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 snapshot = try load(fixture) - let entries = TrashModel.entries(of: try load(fixture)) - #expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1]) - #expect(entries[1].isLaneEntry) + #expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"], + "03 ▸ Trash: the trash sorts by `order` like any lane, and entry is at the top") + #expect(snapshot.trash.allSatisfy { $0.deleted.isMissing }, + "there is no `deleted:` key and no timestamp sort") } - @Test("Ties on the second break by folder name, ascending") - func tiesBreakByFolderName() throws { - let fixture = try WriterFixture() + @Test("Lanes are never in it, whatever a hand-editor nests in there") + func lanesAreNeverTrashed() throws { + let fixture = try makeBoard() 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)) + // A lane-shaped nesting inside `.trash/` is a stray by construction: the container holds + // card folders directly, and the walk does not descend. + try fixture.item(".trash/\(Ident.lane3)/\(Ident.card4)", card(order: "1024", title: "Nested")) + let snapshot = try load(fixture) - let entries = TrashModel.entries(of: try load(fixture)) - #expect(ids(entries) == [Ident.card1, More.cardD, More.cardF]) + #expect(!snapshot.trash.map(\.id).contains(ItemID(rawValue: Ident.card4))) + #expect(snapshot.lanes.map(\.id) == [laneA], "and nothing in there is a lane") } - @Test("An unparseable stamp sorts oldest — after every dated entry, however old") - func unparseableSortsOldest() throws { + @Test("An absent container is an empty trash") + func absentIsEmpty() 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")) + try fixture.item(More.laneA, card(order: "1024", title: "Todo")) - 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]) + #expect(try load(fixture).trash.isEmpty) } } -// MARK: - The ancestor walk and the returning count +// MARK: - ItemPath -@Suite("TrashModel ▸ contents") -struct TrashModelContentsTests { +/// The location vocabulary that replaced `TrashModel.paths` — three cases because the board has +/// exactly three places an identity-bearing folder can be. +@MainActor +@Suite("ItemPath") +struct ItemPathTests { - /// 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("Each case resolves to the folder it names") + func foldersResolve() throws { + let root = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true) + + #expect(ItemPath.lane(laneA).folder(under: root).path == "/Boards/Work.kanban/\(More.laneA)") + #expect(ItemPath.card(lane: laneA, id: card1).folder(under: root).path + == "/Boards/Work.kanban/\(More.laneA)/\(Ident.card1)") + #expect(ItemPath.trashCard(cardD).folder(under: root).path + == "/Boards/Work.kanban/.trash/\(More.cardD)") } - @Test("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute") - func ownFlagUnderTombstonedLaneHasNoRow() throws { + @Test("The container is the case, and only a lane is a lane") + func containerAndKind() { + #expect(ItemPath.lane(laneA).container == .board) + #expect(ItemPath.card(lane: laneA, id: card1).container == .board) + #expect(ItemPath.trashCard(cardD).container == .trash) + + #expect(ItemPath.lane(laneA).isLane) + #expect(!ItemPath.card(lane: laneA, id: card1).isLane) + #expect(!ItemPath.trashCard(cardD).isLane) + } + + @Test("Resolution is per container, in display order, skipping what is not there") + func resolutionIsPerContainer() throws { let fixture = try makeBoard() defer { fixture.tearDown() } + let snapshot = try load(fixture) + let everything: Set = [laneA, card1, card2, cardD, cardE, cardF] - 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)) + #expect(ItemPath.resolve(everything, in: .board, snapshot: snapshot) + == [.lane(laneA), .card(lane: laneA, id: card1), .card(lane: laneA, id: card2)], + "lanes left to right, each lane then its cards — never the caller's set order") + #expect(ItemPath.resolve(everything, in: .trash, snapshot: snapshot) + == [.trashCard(cardF), .trashCard(cardE), .trashCard(cardD)], + "and the trash top to bottom") + + #expect(ItemPath.resolve([], in: .board, snapshot: snapshot).isEmpty) + #expect(ItemPath.resolve([ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot).isEmpty) } - @Test("A lane entry counts what Put Back returns — cards without their own flag") - func returningCountExcludesOwnFlaggedCards() throws { + @Test("A lookup that spans containers finds an item wherever it is, and nothing where it is not") + func lookupSpansContainers() throws { let fixture = try makeBoard() defer { fixture.tearDown() } + let snapshot = try load(fixture) - 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 row set's 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 takes it off this 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 and the tombstoned lane — the trash's two rows, in display - // order, lanes left to right and each lane before its cards. Nothing under `lane2` is here: - // its cards have no rows, and the lane's *folder* is what takes them (Put Back returns them - // with it; Delete Immediately purges them with it). - 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)/-"]) - - #expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty) - #expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty) - } - - @Test("The trashed universe is exactly the trash's rows — one function, not two") - func trashedUniverseIsTheRowSet() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let model = try load(fixture) - - // The guarantee 02-architecture.md settles: "universe and rows are one function, never a - // broader set with a pointer-side subset". Anything a set may reference on the trashed side - // is something the quasi-lane draws. - let rows = Set(TrashModel.entries(of: model).map(\.id)) - let trashed = ItemReferenceSet.idUniverse(of: model, on: .trashed) - #expect(trashed == rows) - - // And the two universes therefore do **not** partition the board. `cardD` carries its own - // `deleted:` under a tombstoned lane and `card3` rides along unflagged under the same one; - // both render nowhere, so neither is on either side. - let liveUniverse = ItemReferenceSet.idUniverse(of: model, on: .live) - for hidden in [ItemID(rawValue: More.cardD), ItemID(rawValue: Ident.card3)] { - #expect(!rows.contains(hidden)) - #expect(!trashed.contains(hidden)) - #expect(!liveUniverse.contains(hidden)) - #expect(ItemReferenceSet(ids: [hidden], liveness: .trashed).resolved(against: model).isEmpty) - #expect(ItemReferenceSet(ids: [hidden], liveness: .live).resolved(against: model).isEmpty) - } - - // Paths are the same walk in folder form, so they name the same things — one per row. - #expect(TrashModel.paths(of: rows, on: .trashed, in: model).count == rows.count) - #expect(TrashModel.emptyTrashTargets(in: model).count == rows.count) - // And `isEmpty`'s short-circuit is that walk's non-emptiness, on both a dirty board and a - // clean one. - #expect(TrashModel.isEmpty(model) == rows.isEmpty) - - let clean = try WriterFixture() - defer { clean.tearDown() } - try clean.item("", Item.board) - try clean.item(Ident.lane1, live(order: "1024", title: "Todo")) - let cleanModel = try load(clean) - #expect(TrashModel.isEmpty(cleanModel) == TrashModel.entries(of: cleanModel).isEmpty) - #expect(ItemReferenceSet.idUniverse(of: cleanModel, on: .trashed).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) + #expect(ItemPath.of(laneA, in: snapshot) == .lane(laneA)) + #expect(ItemPath.of(card1, in: snapshot) == .card(lane: laneA, id: card1)) + #expect(ItemPath.of(cardD, in: snapshot) == .trashCard(cardD)) + #expect(ItemPath.of(ItemID(rawValue: Ident.indexless), in: snapshot) == nil) } } -// MARK: - Counts, phrasing, and the confirmations +// MARK: - The universes + +@MainActor +@Suite("ItemContainer ▸ the universe") +struct ItemContainerUniverseTests { + + @Test("The board's universe is its lanes and their cards; the trash's is its cards") + func universesArePresenceOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + #expect(ItemContainer.board.ids(in: snapshot) == [laneA, card1, card2]) + #expect(ItemContainer.trash.ids(in: snapshot) == [cardD, cardE, cardF]) + } + + @Test("The two universes partition the board — nothing is in both, nothing is in neither") + func theyPartition() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + let board = ItemContainer.board.ids(in: snapshot) + let trash = ItemContainer.trash.ids(in: snapshot) + + #expect(board.isDisjoint(with: trash)) + // The tombstone model's two sides deliberately did *not* partition — a card under a + // tombstoned lane was in neither. Presence being the whole test is what closed that gap. + var everything: Set = [] + for lane in snapshot.lanes { + everything.insert(lane.id) + for card in lane.cards { everything.insert(card.id) } + } + for card in snapshot.trash { everything.insert(card.id) } + #expect(board.union(trash) == everything) + } +} + +// MARK: - Phrasing @Suite("TrashModel ▸ phrasing") -struct TrashModelPhrasingTests { +struct TrashPhrasingTests { - @Test("Plural folding reads counts, singular and plural, cards and lanes and both") + @Test("Plurals fold, and there is only one noun left to fold") 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") + #expect(TrashModel.phrase(1) == "1 card") + #expect(TrashModel.phrase(41) == "41 cards") + #expect(TrashModel.phrase(0) == "0 cards") } - @Test("Entry counts split lane entries from card entries") - func entryCounts() throws { - let fixture = try WriterFixture() + @MainActor + @Test("A sole card is named; several fold into a count") + func purgePromptNamesOrCounts() throws { + let fixture = try makeBoard() 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 snapshot = 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}?") + for: [cardF], in: .trash, snapshot: snapshot, unrecoverable: true + )) + #expect(sole.title == "Permanently delete \u{201C}Newest\u{201D}?") #expect(sole.message == "This can\u{2019}t be undone.") #expect(sole.confirmTitle == "Delete") let several = try #require(TrashModel.purgePrompt( - for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], - in: model, unrecoverable: true)) + for: [cardE, cardF], in: .trash, snapshot: snapshot, 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 { + @MainActor + @Test("Delete Immediately reads the same either side of the boundary") + func purgePromptSpansContainers() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + let fromBoard = try #require(TrashModel.purgePrompt( + for: [card1], in: .board, snapshot: snapshot, unrecoverable: true + )) + #expect(fromBoard.title == "Permanently delete \u{201C}First\u{201D}?", + "11 ▸ Delete Immediately skips the trash from anywhere") + } + + @MainActor + @Test("A lane in the set contributes nothing — no purge path reaches one") + func lanesAreNeverPurged() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + #expect(TrashModel.purgePrompt(for: [laneA], in: .board, snapshot: snapshot, unrecoverable: true) == nil) + } + + @MainActor + @Test("An untitled card reads as the untitled rendering, never as an empty pair of quotes") + func untitledReadsAsARendering() throws { 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") + try fixture.item(More.laneA, card(order: "1024", title: "Todo")) + try fixture.item(".trash/\(More.cardD)", untitled(order: "1024")) + let snapshot = try load(fixture) let prompt = try #require(TrashModel.purgePrompt( - for: [ItemID(rawValue: Ident.card1)], in: try load(fixture), unrecoverable: true)) + for: [cardD], in: .trash, snapshot: snapshot, 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") + @MainActor + @Test("A set naming nothing raises no prompt — the refusal and the action agree") + func nothingToPurgeRaisesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + #expect(TrashModel.purgePrompt( + for: [ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot, unrecoverable: true + ) == nil) + #expect(TrashModel.purgePrompt(for: [], in: .board, snapshot: snapshot, unrecoverable: true) == nil) + } + + @MainActor + @Test("Empty Trash names the true count, and the message follows recoverability") func emptyTrashPrompt() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + let unrecoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: true)) + #expect(unrecoverable.title == "Permanently delete 3 cards?") + #expect(unrecoverable.message == "This can\u{2019}t be undone.") + + let recoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: false)) + #expect(recoverable.message == "The board\u{2019}s history still has them.") + } + + @MainActor + @Test("An empty trash raises no Empty Trash prompt") + func emptyTrashOnAnEmptyTrash() 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(More.laneA, card(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")) - 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?") + #expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil) } } // MARK: - Menu validation +@MainActor @Suite("TrashModel ▸ validation") -struct TrashModelValidationTests { +struct TrashValidationTests { - 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 { + /// **One Delete, one predicate.** The tombstone model needed a mirror-image pair so two ⌘⌫ twins + /// could enable exactly one of themselves; Put Back's retirement left one item, so the predicate + /// is "does this selection name anything", asked in the selection's own container. + @Test("Delete enables for either container, and for nothing that names nothing") + func canDeleteIsStagedNotSplit() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let model = try load(fixture) + let snapshot = 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)) + #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot)) + #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot)) + #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot)) - let trashedSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card2)], liveness: .trashed) - #expect(!TrashModel.canDelete(selection: trashedSelection, in: model)) - #expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model)) + #expect(!TrashModel.canDelete(selection: .empty, in: snapshot)) + // A selection the next reload will drop: the id is real, but not in the container it claims. + #expect(!TrashModel.canDelete( + selection: ItemReferenceSet(ids: [card1], container: .trash), in: snapshot + )) + #expect(!TrashModel.canDelete( + selection: ItemReferenceSet(ids: [cardD], container: .board), in: snapshot + )) } - @Test("Neither enables on an empty selection, or on one whose members have gone") - func nothingToActOn() throws { + @Test("Delete Immediately is cards only, in either container") + func canDeleteImmediatelyIsCardsOnly() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let model = try load(fixture) + let snapshot = 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)) + #expect(TrashModel.canDeleteImmediately( + selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot + )) + #expect(TrashModel.canDeleteImmediately( + selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot + )) + #expect(!TrashModel.canDeleteImmediately( + selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot + ), "a lane's delete is physical already — there is nothing for 'skip the trash' to mean") + #expect(!TrashModel.canDeleteImmediately(selection: .empty, in: snapshot)) } } diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index a08a946..27ca8b4 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -2,17 +2,29 @@ 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). +/// `BoardStore`'s trash operations — Delete (staged by place), Delete Immediately, Empty Trash, and +/// the legacy tombstone migration (03-board-ui.md § Trash, resettled 2026-07-28; 01-storage-format.md +/// § Deletion). /// /// 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`. +/// files — which folder moved, which rank it landed under, which stamps followed it, and what came +/// through byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. +/// +/// **Restore has no suite here**, and its absence is a finding: "restoring is an ordinary move out … +/// there is no restore-specific machinery and no Put Back" (03 § Trash), so it is tested where the +/// ordinary moves are (`DragWriteTests ▸ restore by move-out`). // MARK: - Fixtures -private func tombstoned(order: String, title: String) -> String { +/// One more literal identity than `Ident` offers — the trash needs two residents to have an order. +private enum More { + static let newer = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +} + +/// A card already sitting in `/.trash/` — an ordinary card in a special place, with an unknown +/// key so the verbatim-preservation claims have something to preserve. +private func trashResident(order: String, title: String) -> String { """ --- schema: 1 @@ -20,27 +32,44 @@ private func tombstoned(order: String, title: String) -> String { 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. +/// A legacy tombstone — the only thing in this file that still writes a `deleted:` key, because the +/// migration is the one code path that still reads one. +private func legacyTombstone(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + project: lanework # agent overlay + created: 2026-01-01T09:00:00Z + deleted: \(deleted) + --- + \(title) body. + + """ +} + +/// Three lanes — two cards in the first, one in the second, one in the third — plus two cards +/// already in the board's trash. @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.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) 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")) + try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) + try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) + try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed")) + try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer")) return fixture } @@ -51,13 +80,14 @@ 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) +private let trashed = ItemID(rawValue: Ident.indexless) +private let newer = ItemID(rawValue: More.newer) /// 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. +/// through a delete byte-for-byte, in order. private func untouchedLines(_ text: String) -> [Substring] { text.split(separator: "\n", omittingEmptySubsequences: false).filter { - !$0.hasPrefix("modified") && !$0.hasPrefix("deleted:") + !$0.hasPrefix("modified") && !$0.hasPrefix("order:") } } @@ -73,20 +103,28 @@ private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> (d return (data, modified) } +private func loaded(_ fixture: WriterFixture) throws -> BoardModel { + try BoardLoader.load(boardRoot: fixture.root).model +} + +private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument { + try FrontmatterDocument.parse(fixture.indexText(relativePath)) +} + @MainActor private func reload(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() } -// MARK: - Delete +// MARK: - Delete: a card is a move into .trash/ @MainActor -@Suite("BoardStore ▸ delete") -struct TrashDeleteTests { +@Suite("BoardStore ▸ delete a card") +struct DeleteCardTests { - @Test("Delete stamps deleted and modified, clears modified-by, and touches nothing else") - func tombstonesAndStamps() throws { + @Test("Deleting a card moves its folder into .trash/ and writes no key at all") + func deleteIsAMove() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) @@ -94,326 +132,247 @@ struct TrashDeleteTests { 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(!fixture.exists("\(Ident.lane1)/\(Ident.card1)")) + #expect(fixture.exists(".trash/\(Ident.card1)")) + let after = try fixture.indexText(".trash/\(Ident.card1)") + #expect(!after.contains("deleted:"), "the tombstone model is retired — no key is ever written") + // Everything but the rank and the stamps rides along byte-for-byte, unknown keys and their + // comments included: a move never reads below the folder it moves. #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 { + @Test("The move stamps modified — the deliberate exception to moves-don't-stamp") + func deleteStamps() 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]) + store.delete([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) + // 01-storage-format.md § Deletion: "The move stamps `modified` (deletion is an edit to the + // card's story — the deliberate exception to moves-don't-stamp), which is what a future + // age-based auto-purge will read." + #expect(try document(fixture, ".trash/\(Ident.card1)").modified.value != nil) } - @Test("Delete clears the selection") - func clearsSelection() throws { + @Test("Entry is at the top: the rank is minted above the current topmost") + func entryIsAtTheTop() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.select([card1], liveness: .live) + // The trash's current top is `newer` at 512. + #expect(try loaded(fixture).trash.map(\.id) == [newer, trashed]) - store.deleteSelection() + store.delete([card1]) + + let landed = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) + #expect(landed < 512, "03 ▸ Trash: every arrival mints an `order` rank above the current top") + #expect(try loaded(fixture).trash.map(\.id) == [card1, newer, trashed], + "newest-first falls out of ordinary ranks — no timestamp sort") + } + + @Test("A multi-card delete is one bracket, each arrival above the one before it") + func batchLandsNewestOnTop() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.delete([card1, card2]) + + let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) + let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value) + #expect(second < first) + #expect(try loaded(fixture).trash.map(\.id) == [card2, card1, newer, trashed]) + } + + @Test("The selection moves to the successor sibling, immediately") + func successorSelection() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.select([card1], in: .board) + + store.delete([card1]) + + // Computed from the pre-write snapshot and applied at once: a second ⌫ pressed before the + // watcher rounds the first one back must already have somewhere to land. + #expect(store.selection.ids == [card2]) + #expect(store.selection.container == .board) + } + + @Test("An emptied lane leaves nothing selected") + func emptiedContainerClears() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.select([card3], in: .board) + + store.delete([card3]) - // m5's successor-selection grammar replaces this; until then, what was selected renders - // nowhere and the selection says so. #expect(store.selection.isEmpty) } - @Test("The card window's Delete is the same tombstone, and says nothing about the selection") - func cardWindowDeleteIsTheSameWrite() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let store = try BoardStore(rootURL: fixture.root) - let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") - // Something *else* is selected on the board, which is the case the rule is about: the card - // window's card need not be the board's selection at all. - store.select([card3], liveness: .live, anchor: card3, head: card3) - - store.deleteCard(card1) - - // Byte-indistinguishable from the ⌫ tombstone above — same write op, same stamps, same - // minimal touch (05-card-window.md ▸ Actions: "Delete — tombstones the card"). - let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") - #expect(try FrontmatterDocument.parse(after).deleted.value != nil) - #expect(!after.contains("modified-by")) - #expect(untouchedLines(after) == untouchedLines(before)) - // ⌫ moves the selection to the successor sibling so a repeated keystroke walks down a lane. - // A button in another window has no such continuation, and re-pointing a selection that never - // lost anything would be the drag's mistake (`deleteByDrag`'s rule, shared). - #expect(store.selection.ids == [card3]) - } - - @Test("The card window's Delete writes nothing for a card that is already gone") - func cardWindowDeleteIsLiveOnly() 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)") - - // Its own tombstone, its lane's tombstone (effective liveness is ancestor-walked), and a card - // this board has never heard of. All three are windows already dismissing — nothing is ever - // written into a vanished folder. - store.deleteCard(card2) - store.deleteCard(card4) - store.deleteCard(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("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: - Delete by drop - -/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28): "release -/// tombstones the dragged card(s), exactly the ⌫ tombstone". -/// -/// *Exactly* is the claim under test, and it is a claim about the disk — so these run the two -/// gestures over two identical fixtures and compare the bytes. Where the drop *may* land and what it -/// draws on the way are `TrashDropTests`' and `DropSettleTests`'; here it has already landed. -@MainActor -@Suite("BoardStore ▸ delete by drop") -struct TrashDropWriteTests { - - @Test("A drop-delete is byte-for-byte the ⌫ tombstone") - func indistinguishableFromTheKeystroke() throws { + @Test("Drop-on-trash and the card window's button write exactly what ⌫ writes") + func everyGestureWritesTheSameThing() throws { let byKey = try makeBoard() defer { byKey.tearDown() } let byDrop = try makeBoard() defer { byDrop.tearDown() } + let byButton = try makeBoard() + defer { byButton.tearDown() } try BoardStore(rootURL: byKey.root).delete([card1]) try BoardStore(rootURL: byDrop.root).deleteByDrag(cardIDs: [card1]) + try BoardStore(rootURL: byButton.root).deleteCard(card1) - let keyed = try byKey.indexText("\(Ident.lane1)/\(Ident.card1)") - let dropped = try byDrop.indexText("\(Ident.lane1)/\(Ident.card1)") - // Everything but the two stamps that are clocks rather than content, which differ between any - // two writes at all — including two ⌫ presses. - #expect(untouchedLines(dropped) == untouchedLines(keyed)) - #expect(try FrontmatterDocument.parse(dropped).deleted.value != nil) - #expect(!dropped.contains("modified-by"), "an app-mediated write clears an external writer's attribution") + let keyed = try byKey.indexText(".trash/\(Ident.card1)") + #expect(untouchedLines(try byDrop.indexText(".trash/\(Ident.card1)")) == untouchedLines(keyed)) + #expect(untouchedLines(try byButton.indexText(".trash/\(Ident.card1)")) == untouchedLines(keyed)) + // Same rank, too: all three mint at the head of the same trash. + let rank = try document(byKey, ".trash/\(Ident.card1)").order.value + #expect(try document(byDrop, ".trash/\(Ident.card1)").order.value == rank) + #expect(try document(byButton, ".trash/\(Ident.card1)").order.value == rank) } - /// A multi-selection drag carries its whole run across lanes, and the tombstone is the card's own - /// `index.md` and nothing else — the parents are not rewritten to record a child's departure, - /// because nothing departed. - @Test("A cross-lane run lands whole and touches nothing it did not carry") - func theWholeRunLands() throws { + @Test("Only ⌫ picks a successor — a drag and a card window's button leave the selection alone") + func onlyTheKeystrokePicksASuccessor() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let lane = try stat(fixture, Ident.lane1) - - store.deleteByDrag(cardIDs: [card1, card3]) - - #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) - .deleted.value != nil) - #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane2)/\(Ident.card3)")) - .deleted.value != nil) - let laneAfter = try stat(fixture, Ident.lane1) - #expect(laneAfter.data == lane.data) - #expect(laneAfter.modified == lane.modified) - #expect(store.banners.oneShots.isEmpty) - } - - /// The one thing the drop deliberately does *not* share with ⌫. The keystroke picks a successor - /// because the selection lost its cards and "repeated ⌫ walks down a lane"; a drag's run is not - /// necessarily the selection at all, so re-pointing one that lost nothing would be a bug. The - /// reload's resolve rule ejects tombstoned members from a live-side set on its own. - @Test("A drop-delete never touches the selection, where ⌫ moves it to the successor") - func theSelectionIsLeftAlone() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let store = try BoardStore(rootURL: fixture.root) - // Something else entirely is selected — dragging a card outside the selection drags it alone - // and leaves the selection standing (`LaneView.startCardDrag`). - store.select([card3], liveness: .live, anchor: card3, head: card3) + store.select([card3], in: .board) store.deleteByDrag(cardIDs: [card1]) + #expect(store.selection.ids == [card3], "a drag's run need not be the selection at all") + store.deleteCard(card2) #expect(store.selection.ids == [card3]) - #expect(store.selection.liveness == .live) - - // The keystroke's contrasting half, over an identical board: ⌫ re-points the selection - // whatever was in it, because it is the gesture that promises to walk down a lane. - let keyed = try makeBoard() - defer { keyed.tearDown() } - let keyedStore = try BoardStore(rootURL: keyed.root) - keyedStore.select([card3], liveness: .live, anchor: card3, head: card3) - - keyedStore.delete([card1]) - - #expect(keyedStore.selection.ids != [card3]) } - @Test("Already-tombstoned ids are skipped, and an empty run writes nothing") - func liveOnly() throws { + @Test("An id that names nothing writes nothing and opens no bracket") + func vanishedTargetsAreSkipped() 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 untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") - store.deleteByDrag(cardIDs: [card2]) - store.deleteByDrag(cardIDs: []) + store.delete([ItemID(rawValue: "44444444-4444-4444-4444-444444444444")]) + store.delete([]) + // A card that is in the trash is not on the board side, so a board delete never finds it. + store.delete([trashed]) - #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched) + #expect(fixture.exists(".trash/\(Ident.indexless)")) #expect(store.banners.oneShots.isEmpty) } - - @Test("A read-only board refuses the drop 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.deleteByDrag(cardIDs: [card1]) - - #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) - #expect(store.banners.oneShots.isEmpty, "the lock row is already standing") - } } -// MARK: - Put Back +// MARK: - Delete: a lane is physical @MainActor -@Suite("BoardStore ▸ put back") -struct TrashPutBackTests { +@Suite("BoardStore ▸ delete a lane") +struct DeleteLaneTests { - @Test("A delete→Put Back round trip differs from the original only in the modified timestamp") - func roundTripFidelity() async throws { + @Test("Deleting a lane removes the folder and its contents — nothing is trashed") + func laneDeleteIsPhysical() 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]) + store.delete([lane3]) - 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) + // 03-board-ui.md § Trash: "Cards only. Lanes are never trashed … deleting a lane deletes it, + // folder and contents, physically." + #expect(!fixture.exists(Ident.lane3)) + #expect(!fixture.exists(".trash/\(Ident.card4)"), "its cards go with it, not into the trash") + #expect(try loaded(fixture).trash.map(\.id) == [newer, trashed], "the trash is untouched") + #expect(store.banners.oneShots.isEmpty) } - @Test("Putting back a lane splits its contents by flag — own-flag cards stay tombstoned") - func laneSplitsByFlag() throws { + @Test("The selection moves to the successor lane") + func laneSuccessor() 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.select([lane2], in: .board) - store.putBack([lane3]) + store.delete([lane2]) - // 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)) + #expect(store.selection.ids == [lane3]) } - @Test("Put Back on a live item, an empty set, or an unknown id writes nothing") - func noOps() throws { + @Test("A set naming both a lane and a card acts on the lane — the selection is cards XOR lanes") + func lanesWinAMixedSet() 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())]) + store.delete([lane3, card1]) - #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == live.modified) + #expect(!fixture.exists(Ident.lane3)) + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"), + "the card is left alone rather than earning a second undo step for one keystroke") + } +} + +// MARK: - Delete: staged by place + +@MainActor +@Suite("BoardStore ▸ Delete is staged by place") +struct StagedDeleteTests { + + @Test("A board selection moves to the trash; a trash selection deletes permanently") + func stagingFollowsTheContainer() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1], in: .board) + store.deleteSelection() + #expect(fixture.exists(".trash/\(Ident.card1)"), "on the board it moves to the trash") + + store.select([trashed], in: .trash) + store.deleteSelection() + #expect(!fixture.exists(".trash/\(Ident.indexless)"), "in the trash it removes the folder") + } + + @Test("A permanent delete walks the trash's own successor, so repeated ⌫ walks the column") + func trashSuccessorWalksTheColumn() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + // The column's order is [newer, trashed]. + store.select([newer], in: .trash) + + store.deleteTrashCards([newer]) + + #expect(store.selection.ids == [trashed]) + #expect(store.selection.container == .trash) + } + + @Test("A permanent delete of the last card clears the selection") + func emptiedTrashClears() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.select([newer, trashed], in: .trash) + + store.deleteTrashCards([newer, trashed]) + + #expect(try loaded(fixture).trash.isEmpty) + #expect(store.selection.isEmpty) + } + + @Test("A permanent delete never reaches a board card, whatever the ids say") + func trashDeleteCannotReachTheBoard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.deleteTrashCards([card1]) + + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)")) + #expect(store.banners.oneShots.isEmpty) } } @@ -421,181 +380,349 @@ struct TrashPutBackTests { @MainActor @Suite("BoardStore ▸ purge") -struct TrashPurgeTests { +struct PurgeTests { - @Test("Delete Immediately removes the folder and leaves everything else alone") - func purgeRemovesTheFolder() throws { + @Test("Delete Immediately skips the trash from a lane") + func skipsTheTrashFromTheBoard() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let sibling = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + store.select([card1], in: .board) - store.deleteImmediately([card2]) + store.deleteImmediately([card1]) - #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) - #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == sibling.modified) + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)")) + #expect(!fixture.exists(".trash/\(Ident.card1)"), "03 ▸ Trash: ⌥⌘⌫ skips the trash from anywhere") #expect(store.selection.isEmpty) - #expect(store.banners.oneShots.isEmpty) } - @Test("Purging a lane takes its whole folder, tombstoned cards and all") - func purgingALaneTakesItsSubtree() throws { + @Test("Delete Immediately purges a card already in the trash") + func purgesFromTheTrash() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) + store.select([trashed], in: .trash) + + store.deleteImmediately([trashed]) + + #expect(!fixture.exists(".trash/\(Ident.indexless)")) + #expect(fixture.exists(".trash/\(More.newer)"), "and only what it named") + } + + @Test("A lane in the set is never purged — cards only") + func lanesAreNotPurged() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.select([lane3], in: .board) store.deleteImmediately([lane3]) - #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") + #expect(fixture.exists(Ident.lane3)) } - @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 { + @Test("Empty Trash removes every card in the container, and leaves strays verbatim") + func emptyTrashIsWholeScope() throws { let fixture = try makeBoard() defer { fixture.tearDown() } + try fixture.file(".trash/notes.txt", Data("hand-written".utf8)) 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) + #expect(try loaded(fixture).trash.isEmpty) + #expect(!fixture.exists(".trash/\(Ident.indexless)")) + #expect(!fixture.exists(".trash/\(More.newer)")) + #expect(FileManager.default.fileExists(atPath: fixture.url(".trash").appendingPathComponent("notes.txt").path), + "stray tolerance does not stop applying because the folder is the app's") } - @Test("Empty Trash on a board with no tombstones writes nothing") - func emptyTrashOnACleanBoard() throws { + @Test("Empty Trash clears a trash-side selection and leaves a board one alone") + func emptyTrashAndTheSelection() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1], in: .board) + store.emptyTrash() + #expect(store.selection.ids == [card1], "the board it names is still right there") + } + + @Test("Emptying an already-empty trash writes nothing") + func emptyTrashOnNothing() 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 { + @Test("No purge registers an undo step: purgeIsUnrecoverable stays true") + func purgesAreUnrecoverable() 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. + let history = NativeHistoryProvider() + store.history = history #expect(store.purgeIsUnrecoverable) + + store.select([trashed], in: .trash) + store.deleteTrashCards([trashed]) + store.deleteImmediately([newer]) + store.emptyTrash() + + // 13-native-undo.md ▸ Rules: "Permanently delete (Delete Immediately, Empty Trash) … + // the confirm *is* the safety." A stack entry here would be a promise the filesystem + // cannot keep. + #expect(!history.canUndo) } } -// MARK: - Drag to restore +// MARK: - The legacy tombstone migration +/// 01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, never +/// destroy" — the store-side scheduling, which is `relocateLooseCardFiles`' twin in every +/// mechanical respect. @MainActor -@Suite("BoardStore ▸ drag to restore") -struct TrashDragRestoreTests { +@Suite("BoardStore ▸ the legacy tombstone migration") +struct StoreTombstoneMigrationTests { - @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, at: 1) - - let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") - #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) - #expect(!after.contains("deleted")) - // Dropped at index 1 — past lane one's single live card, which is exactly where the card's - // recorded 2048 already puts it. The rank the drop names and the rank on disk agree, so no - // `order` is written at all: the `order` line comes through byte-for-byte and the card - // returns where it was, the position-perfect restore a pure-view trash makes possible. - #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) - #expect(untouchedLines(after) == untouchedLines(original)) + /// A board an older version wrote: two tombstoned cards under a live lane, one tombstoned lane + /// with a live card inside it, and a board-level key that means nothing. + private func makeLegacyBoard() 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: "Live")) + try fixture.item( + "\(Ident.lane1)/\(Ident.card2)", + legacyTombstone(order: "2048", title: "Older", deleted: "2026-03-01T09:00:00Z") + ) + try fixture.item( + "\(Ident.lane1)/\(Ident.card3)", + legacyTombstone(order: "3072", title: "Newer", deleted: "2026-03-05T09:00:00Z") + ) + try fixture.item(Ident.lane2, legacyTombstone(order: "2048", title: "Retired")) + try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Rides along")) + return fixture } - @Test("A drop on another lane restores and appends at that lane's bottom, in one bracket") - func crossLaneMovesAndAppends() throws { - let fixture = try makeBoard() + @Test("Cards relocate into .trash/ with the key removed; lanes return live in place") + func migrationMovesCardsAndResurrectsLanes() throws { + let fixture = try makeLegacyBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1) + store.migrateLegacyTombstones() + // The cards moved, and their keys went with the move. #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")) - // Dropped at index 1 — lane two's one visible card is at 1024, so the drop's own rank is - // the append 2048, carried by the move rather than left to the Writer to compute. - #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)")) + #expect(fixture.exists(".trash/\(Ident.card2)")) + #expect(fixture.exists(".trash/\(Ident.card3)")) + #expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:"))) + #expect(!(try fixture.indexText(".trash/\(Ident.card3)").contains("deleted:"))) - 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") + // The lane stayed exactly where it was, key stripped — "resurrection is the safe direction, + // nothing is destroyed by migration". + #expect(fixture.exists(Ident.lane2)) + #expect(!(try fixture.indexText(Ident.lane2).contains("deleted:"))) + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"), "its cards come back with it") + #expect(try document(fixture, Ident.lane2).order.value == 2048, "at its own position") } - @Test("A drop that names nothing droppable writes nothing") - func noOps() throws { + @Test("Cards migrate oldest-first, so the newest deletion ends up on top") + func migrationOrderIsOldestFirst() throws { + let fixture = try makeLegacyBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.migrateLegacyTombstones() + + // Every arrival mints above the current top, so migrating oldest-first reproduces the + // newest-first column the tombstone model's timestamp sort used to render. + #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Newer", "Older"]) + } + + @Test("The order is deterministic when the stamps are missing or unparseable") + func undatedSortsOldest() 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)", + legacyTombstone(order: "1024", title: "Corrupt", deleted: "not-a-date") + ) + try fixture.item( + "\(Ident.lane1)/\(Ident.card2)", + legacyTombstone(order: "2048", title: "Dated", deleted: "2026-03-01T09:00:00Z") + ) + let store = try BoardStore(rootURL: fixture.root) + + store.migrateLegacyTombstones() + + // "A corrupt stamp must not outrank fresh deletions for the trash's most prominent rows": + // undated sorts oldest, so it migrates first and ends up *below* the dated one. + #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Dated", "Corrupt"]) + } + + @Test("A board-level deleted: is never migrated — it is meaningless, ignored and logged") + func boardLevelKeyIsLeftAlone() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", """ + --- + schema: 1 + title: Board + deleted: 2026-03-03T09:00:00Z + --- + Board body. + + """) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + let store = try BoardStore(rootURL: fixture.root) + + store.migrateLegacyTombstones() + + #expect(try fixture.indexText("").contains("deleted:"), "preserved verbatim") + #expect(store.banners.losses.isEmpty, "and nothing to announce") + } + + @Test("The notice is one folded warning-tone row naming both halves") + func theNotice() throws { + let fixture = try makeLegacyBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.migrateLegacyTombstones() + + #expect(store.banners.losses.count == 1, "one migration, one row") + #expect(store.banners.losses.first?.message + == "Moved 2 cards to the trash and restored 'Retired' — they carried old deleted markers") + } + + @Test("A board with nothing legacy migrates nothing and says nothing") + func nothingToMigrate() 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, at: 0) - // A lane that is not on the board at all. - store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless), at: 0) - // 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, at: 0) - store.restoreByDrag(cardID: card4, intoLane: lane1, at: 0) + store.migrateLegacyTombstones() - #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) + #expect(store.banners.losses.isEmpty) + #expect(try loaded(fixture).trash.count == 2, "the existing trash is not disturbed") } - @Test("A read-only board refuses the drop") - func readOnlyRefuses() throws { - let fixture = try makeBoard() + @Test("The read-only lock defers it, and remembers nothing — the next attempt is a fresh one") + func theLockDefers() throws { + let fixture = try makeLegacyBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() - store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1) + store.migrateLegacyTombstones() - #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) - #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:")) + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "01: deferred under any read-only lock") + #expect(store.banners.losses.isEmpty) + } + + /// The loop guard `relocateLooseCardFiles` documents, read for this migration: after a success + /// the walk finds nothing and the memo clears; a second call against the *same* unchanged picture + /// never re-attempts. + @Test("It cannot hot-loop: a second call against the same picture writes nothing") + func theLoopGuard() throws { + let fixture = try makeLegacyBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.migrateLegacyTombstones() + let after = try stat(fixture, ".trash/\(Ident.card2)") + store.banners.dismissAllDismissableRows() + + // The store's snapshot still reports the same legacy tombstones (the reload has not landed), + // so an unguarded second call would migrate a card that has already moved — and fail. + store.migrateLegacyTombstones() + + #expect(try stat(fixture, ".trash/\(Ident.card2)") == after) + #expect(store.banners.losses.isEmpty) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A successful reload clears the memo and the pending work together") + func theReloadClosesTheWindow() async throws { + let fixture = try makeLegacyBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + #expect(!store.legacyTombstones.isEmpty) + + store.migrateLegacyTombstones() + await reload(store) + + // The window closes per board on the first successful migration write: no key is left to + // read, so the channel empties and stays empty. + #expect(store.legacyTombstones.isEmpty) + #expect(store.snapshot.trash.count == 2) + #expect(store.snapshot.lanes.count == 2, "the resurrected lane is an ordinary lane again") + } + + @Test("It is armed by the reload seam, exactly like the loose-file relocation") + func theReloadArmsIt() async 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) + + // The tombstone arrives after the store opened — an older version's board pulled in by a + // sync, or a hand edit. + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + legacyTombstone(order: "1024", title: "Legacy") + ) + await reload(store) + + #expect(fixture.exists(".trash/\(Ident.card1)"), "the reload that saw it is the reload that fixed it") + #expect(store.banners.losses.count == 1) + } +} + +// MARK: - The banner's phrasing + +@Suite("BannerCenter ▸ the migration notice") +struct MigrationNoticeTests { + + @Test("One card names it; several fold to a count") + func cardsFold() { + #expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"], lanes: []) + == "Moved 'Fix login' to the trash — it carried an old deleted marker") + #expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: []) + == "Moved 3 cards to the trash — they carried old deleted markers") + } + + @Test("A lane reads as a restoration, which is what it is") + func lanesRead() { + #expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["Doing"]) + == "Restored 'Doing' — it carried an old deleted marker") + #expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["A", "B"]) + == "Restored 2 lanes — they carried old deleted markers") + } + + @Test("Both halves fold into one sentence — one migration, one row") + func bothFold() { + #expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: ["D", "E"]) + == "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers") + } + + @Test("An untitled item reads as a rendering, and nothing migrated is not news") + func edges() { + #expect(BannerCenter.migratedTombstonesMessage(cards: [nil], lanes: []) + == "Moved an untitled item to the trash — it carried an old deleted marker") + #expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: []) == nil) } } @@ -611,34 +738,62 @@ struct TrashConfirmationsTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let confirmations = TrashConfirmations() + store.select([trashed], in: .trash) - confirmations.requestPurge(of: [card2], in: store) + confirmations.requestPurge(of: [trashed], in: store) let pending = try #require(confirmations.pending) #expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?") - #expect(pending.action == .purge([card2])) + #expect(pending.action == .purge([trashed])) // Nothing has happened yet — the alert is what stands between the keystroke and the loss. - #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(fixture.exists(".trash/\(Ident.indexless)")) confirmations.confirm(in: store) - #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(!fixture.exists(".trash/\(Ident.indexless)")) #expect(confirmations.pending == nil) // Idempotent: the binding's own dismissal fires an instant after the button. confirmations.confirm(in: store) } + /// 03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent … Both confirm exactly + /// where the loss is real." + @Test("The trash's own Delete confirms; the board's goes straight through") + func deleteIsConfirmedOnlyInTheTrash() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let confirmations = TrashConfirmations() + + // A board selection is recoverable — the trash itself, and undo — so no alert. + store.select([card1], in: .board) + confirmations.requestDelete(in: store) + #expect(confirmations.pending == nil) + #expect(fixture.exists(".trash/\(Ident.card1)"), "it went straight through") + + // A trash selection is the permanent one. + store.select([trashed], in: .trash) + confirmations.requestDelete(in: store) + let pending = try #require(confirmations.pending) + #expect(pending.action == .deleteTrashCards([trashed])) + #expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet") + + confirmations.confirm(in: store) + #expect(!fixture.exists(".trash/\(Ident.indexless)")) + } + @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() + store.select([trashed], in: .trash) - confirmations.requestPurge(of: [card2], in: store) + confirmations.requestPurge(of: [trashed], in: store) confirmations.cancel() #expect(confirmations.pending == nil) - #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(fixture.exists(".trash/\(Ident.indexless)")) } @Test("Empty Trash always confirms, and its scope is the whole trash") @@ -652,13 +807,13 @@ struct TrashConfirmationsTests { let pending = try #require(confirmations.pending) #expect(pending.action == .emptyTrash) - #expect(pending.prompt.title == "Permanently delete 1 lane and 1 card?") + #expect(pending.prompt.title == "Permanently delete 2 cards?") confirmations.confirm(in: store) - #expect(TrashModel.isEmpty(try BoardLoader.load(boardRoot: fixture.root).model)) + #expect(try loaded(fixture).trash.isEmpty) } - @Test("Neither command raises an alert with nothing to act on") + @Test("No command raises an alert with nothing to act on") func nothingToConfirm() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } diff --git a/KanbanTests/UndoWriteTests.swift b/KanbanTests/UndoWriteTests.swift index 45aae7e..e9b91ca 100644 --- a/KanbanTests/UndoWriteTests.swift +++ b/KanbanTests/UndoWriteTests.swift @@ -18,7 +18,10 @@ import Testing // MARK: - Fixtures -private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { +/// A card sitting in `/.trash/` — an ordinary card in a special place (03-board-ui.md § +/// Trash), with the unknown-key overlay every other fixture card carries so an inverse's +/// verbatim-preservation claim has something to preserve. +private func trashResident(order: String, title: String) -> String { """ --- schema: 1 @@ -26,7 +29,6 @@ private func tombstoned(order: String, title: String, deleted: String = "2026-03 order: \(order) project: lanework # agent overlay created: 2026-01-01T09:00:00Z - deleted: \(deleted) --- \(title) body. @@ -47,8 +49,8 @@ Styled body. """ -/// Two live lanes — the first with two live cards, a styled one and a tombstoned one; the second -/// with one card. Enough for every inverse in this file. +/// Two lanes — the first with two plain cards and a styled one, the second with one card — plus one +/// card already sitting in the board's `.trash/`. Enough for every inverse in this file. @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() @@ -57,9 +59,9 @@ private func makeBoard() throws -> WriterFixture { try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard) - try fixture.item("\(Ident.lane1)/\(Ident.card4)", tombstoned(order: "4096", title: "Trashed")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere")) + try fixture.item(".trash/\(Ident.card4)", trashResident(order: "1024", title: "Trashed")) return fixture } @@ -74,7 +76,7 @@ private let elsewhere = ItemID(rawValue: Ident.indexless) private let card1Path = "\(Ident.lane1)/\(Ident.card1)" private let card2Path = "\(Ident.lane1)/\(Ident.card2)" private let card3Path = "\(Ident.lane1)/\(Ident.card3)" -private let trashedPath = "\(Ident.lane1)/\(Ident.card4)" +private let trashedPath = ".trash/\(Ident.card4)" /// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak** /// — the session owns the stack in the app, and a test that dropped it would watch its own steps @@ -488,7 +490,7 @@ struct MoveUndoTests { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) - store.select([card1], liveness: .live) + store.select([card1], in: .board) store.sortSelection(.down) @@ -506,13 +508,13 @@ struct MoveUndoTests { } } -// MARK: - The trash pair +// MARK: - Delete @MainActor -@Suite("Undo ▸ the trash pair") +@Suite("Undo ▸ delete") struct TrashUndoTests { - @Test("Undoing a delete is Put Back — the key goes, and nothing else moves") + @Test("Undoing a card delete moves it back out of the trash, to its lane and its rank") func deleteRoundTrip() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -520,20 +522,42 @@ struct TrashUndoTests { let before = try fixture.indexText(card1Path) store.delete([card1]) - #expect(try document(fixture, card1Path).deleted.value != nil) + #expect(!fixture.exists(card1Path), "the folder physically left its lane") + #expect(fixture.exists(".trash/\(Ident.card1)")) #expect(history.undoActionName == "Delete Card") history.undo() + #expect(fixture.exists(card1Path), "13 ▸ Interaction with the trash: the undo is the move back") + #expect(!fixture.exists(".trash/\(Ident.card1)")) let undone = try fixture.indexText(card1Path) - #expect(try FrontmatterDocument.parse(undone).deleted.isMissing) - // Byte-identical but for the stamp: the tombstone and its inverse are one key each. - #expect(untouchedLines(undone).filter { !$0.hasPrefix("deleted:") } == untouchedLines(before)) + #expect(try FrontmatterDocument.parse(undone).order.value == 1024, "at its original rank") + // Byte-identical but for the stamps every app write owns — no `deleted:` key was ever + // written, so there is none to come back and none to remove. + #expect(untouchedLines(undone) == untouchedLines(before)) history.redo() - #expect(try document(fixture, card1Path).deleted.value != nil) + #expect(fixture.exists(".trash/\(Ident.card1)")) + #expect(!fixture.exists(card1Path)) } - @Test("A multi-item delete is one step with a plural title") + @Test("The redo files the card under the rank the delete minted, not a fresh one") + func redoUsesTheCapturedTrashRank() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.delete([card1]) + let minted = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) + #expect(minted < 1024, "entry is at the top: a rank above the current topmost (order 1024)") + + history.undo() + history.redo() + + #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted, + "the redo replays the write's own captured rank") + } + + @Test("A multi-card delete is one step with a plural title, and lands newest-last on top") func batchDeleteIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -542,69 +566,74 @@ struct TrashUndoTests { store.delete([card1, card2]) #expect(history.undoActionName == "Delete 2 Cards") + let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) + let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value) + #expect(second < first, "each arrival in the run mints a rank above the one before it") + history.undo() - #expect(try document(fixture, card1Path).deleted.isMissing) - #expect(try document(fixture, card2Path).deleted.isMissing) + #expect(fixture.exists(card1Path)) + #expect(fixture.exists(card2Path)) + #expect(try loadedTrash(fixture).map(\.id) == [trashed], "only the board's own resident is left") #expect(history.canUndo == false) } - @Test("A lane delete is named for the lane") - func laneDeleteIsNamedForTheLane() throws { + @Test("A lane delete is physical, and its undo recreates the folder byte for byte") + func laneDeleteRecreatesTheSubtree() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) + let laneText = try fixture.indexText(Ident.lane2) + let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") store.delete([lane2]) + #expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically") + #expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed") #expect(history.undoActionName == "Delete Lane") + history.undo() - #expect(try document(fixture, Ident.lane2).deleted.isMissing) + + #expect(fixture.exists(Ident.lane2)) + #expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit") + #expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText, + "and the whole subtree comes back with it — nested cards included") + + history.redo() + #expect(!fixture.exists(Ident.lane2)) } - @Test("Undoing a Put Back re-tombstones with the timestamp the row was filed under") - func putBackRoundTrip() throws { + @Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse") + func restoreIsAnOrdinaryMove() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) - let filedUnder = try #require(try document(fixture, trashedPath).deleted.value) + let trashRank = try #require(try document(fixture, trashedPath).order.value) - store.putBack([trashed]) - #expect(try document(fixture, trashedPath).deleted.isMissing) - #expect(history.undoActionName == "Restore Card") + store.moveCards([trashed], toLane: lane2, at: 0) - history.undo() - #expect(try document(fixture, trashedPath).deleted.value == filedUnder, - "the trash sorts by this — a fresh stamp would reorder a list the user was reading") - - history.redo() - #expect(try document(fixture, trashedPath).deleted.isMissing) - } - - @Test("Drag-to-restore undoes the position half too") - func restoreByDragRoundTrip() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let (store, history) = try makeStore(fixture) - let filedUnder = try #require(try document(fixture, trashedPath).deleted.value) - - store.restoreByDrag(cardID: trashed, intoLane: lane2, at: 0) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) - #expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing) - #expect(history.undoActionName == "Restore Card") + #expect(!fixture.exists(trashedPath)) + #expect(history.undoActionName == "Move Card", + "13: a restore is an ordinary move between containers, named as one") history.undo() - #expect(fixture.exists(trashedPath), "back in the lane it was trashed in") - #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)") == false) - let undone = try document(fixture, trashedPath) - #expect(undone.deleted.value == filedUnder) - #expect(undone.order.value == 4096, "at the rank it was trashed holding") + #expect(fixture.exists(trashedPath), "back in the trash it came out of") + #expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)")) + #expect(try document(fixture, trashedPath).order.value == trashRank, + "at the rank it was filed under") history.redo() #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) - #expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing) } } +/// The board's trash as the loader reads it — never the store's snapshot, which a write deliberately +/// does not touch. +@MainActor +private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] { + try BoardLoader.load(boardRoot: fixture.root).model.trash +} + // MARK: - The Edit session @MainActor @@ -703,6 +732,7 @@ struct NotUndoableTests { store.delete([card1]) let armed = try #require(history.undoActionName) + store.select([trashed], in: .trash) store.deleteImmediately([trashed]) #expect(fixture.exists(trashedPath) == false) @@ -758,7 +788,7 @@ struct NotUndoableTests { store.delete([card1]) store.setLaneWidth(lane1, units: 2) - #expect(try document(fixture, card1Path).deleted.value != nil) + #expect(fixture.exists(".trash/\(Ident.card1)")) #expect(try document(fixture, Ident.lane1).width.value == 2) #expect(store.banners.oneShots.isEmpty) } @@ -803,9 +833,9 @@ struct CrossingIsAWriteTests { store.delete([card1]) for _ in 0 ..< 3 { history.undo() - #expect(try document(fixture, card1Path).deleted.isMissing) + #expect(fixture.exists(card1Path)) history.redo() - #expect(try document(fixture, card1Path).deleted.value != nil) + #expect(fixture.exists(".trash/\(Ident.card1)")) } } } @@ -840,8 +870,15 @@ private enum Foreign { _ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body) } - static func delete(_ fixture: WriterFixture, _ path: String) throws { - try BoardWriter.deleteItem(at: fixture.url(path)) + /// A foreign delete of a **card** — the shape a delete has on disk now: the folder moves into + /// `.trash/`. `FileManager` and nothing else, so no rank is minted and no stamp is written. + static func trash(_ fixture: WriterFixture, _ path: String, id: String) throws { + try fixture.move(path, toTrash: id) + } + + /// A foreign delete of a **lane** — physical, since lanes are never trashed. + static func removeLane(_ fixture: WriterFixture, _ path: String) throws { + try FileManager.default.removeItem(at: fixture.url(path)) } static func purge(_ fixture: WriterFixture, _ path: String) throws { @@ -886,12 +923,13 @@ struct StaleStepTests { store.transient.updateRenameDraft("Second!") store.commitRename() - try Foreign.delete(fixture, card2Path) + try Foreign.trash(fixture, card2Path, id: Ident.card2) history.undo() // The top step's card is in the trash now, so its rename is not ours to walk back; the one // below it is untouched and applies in the same ⌘Z. - #expect(try document(fixture, card2Path).title.value == "Second!", "the foreign writer's board, left alone") + #expect(try document(fixture, ".trash/\(Ident.card2)").title.value == "Second!", + "the foreign writer's board, left alone") #expect(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something") #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'Second!' changed outside Lanework"]) @@ -906,29 +944,33 @@ struct StaleStepTests { let (store, history) = try makeStore(fixture) store.delete([card1]) - try Foreign.purge(fixture, card1Path) + try Foreign.purge(fixture, ".trash/\(Ident.card1)") history.undo() #expect(fixture.exists(card1Path) == false) + #expect(!fixture.exists(".trash/\(Ident.card1)")) #expect(store.banners.signposts.count == 1) #expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row") #expect(history.canUndo == false) #expect(history.canRedo == false, "a skipped step leaves nothing behind") } - @Test("A foreign Put Back skips the delete's undo — the item is not on the side we left it") + /// The container check, and it needs no field of its own: a delete step's undo expects its card + /// at `/.trash/`, and a foreign restore leaves that path empty (`HistoryStaleness`). + @Test("A foreign restore skips the delete's undo — the card is not in the container we left it") func aForeignRestoreSkipsTheDeleteStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) - try BoardWriter.restoreItem(at: fixture.url(card1Path)) + try fixture.move(".trash/\(Ident.card1)", toLane: Ident.lane2, card: Ident.card1) history.undo() - #expect(try document(fixture, card1Path).deleted.isMissing, "still live, as they left it") + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)"), "where the foreign writer put it") + #expect(!fixture.exists(card1Path), "and not moved back on top of them") #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"]) } @@ -949,23 +991,25 @@ struct StaleStepTests { #expect(history.canUndo == false) } - @Test("A tombstoned card is stale for a field edit, even with the field itself untouched") - func aTombstonedTargetIsStaleForAFieldEdit() throws { + @Test("A foreign lane delete is stale for a field edit — there is nothing at the path") + func aRemovedLaneIsStaleForAFieldEdit() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.setLaneWidth(lane1, units: 3) - try Foreign.delete(fixture, Ident.lane1) + try Foreign.removeLane(fixture, Ident.lane1) history.undo() - #expect(try document(fixture, Ident.lane1).width.value == 3, "not resized inside the trash") + #expect(!fixture.exists(Ident.lane1), "not conjured back to be resized") #expect(store.banners.signposts.count == 1) } - @Test("A card under a foreign-tombstoned lane is stale too — liveness is ancestor-walked") - func anAncestorTombstoneIsStale() throws { + /// The card's own path is the check: a lane delete is physical, so a card under it is simply not + /// there any more — no ancestor walk, which is what materializing the trash bought. + @Test("A card whose lane a foreign writer deleted is stale too") + func aCardUnderARemovedLaneIsStale() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) @@ -973,11 +1017,11 @@ struct StaleStepTests { store.transient.beginRename(of: card1, currentTitle: "First") store.transient.updateRenameDraft("Renamed") store.commitRename() - try Foreign.delete(fixture, Ident.lane1) + try Foreign.removeLane(fixture, Ident.lane1) history.undo() - #expect(try document(fixture, card1Path).title.value == "Renamed", "the card renders nowhere; nothing was written") + #expect(!fixture.exists(card1Path), "the card is gone; nothing was written") #expect(store.banners.signposts.count == 1) } @@ -1014,7 +1058,7 @@ struct StaleStepTests { store.commitRename() try Foreign.rename(fixture, card2Path, to: "Theirs") - try Foreign.delete(fixture, card3Path) + try Foreign.trash(fixture, card3Path, id: Ident.card3) history.undo() #expect(try document(fixture, card1Path).title.value == "First", "the step applied") @@ -1108,7 +1152,7 @@ struct StaleStepTests { let (store, history) = try makeStore(fixture) store.delete([card1]) - try Foreign.purge(fixture, card1Path) + try Foreign.purge(fixture, ".trash/\(Ident.card1)") history.undo() let row = try #require(store.bannerRows.last) @@ -1219,7 +1263,7 @@ struct FailedCrossingTests { try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path) history.undo() - #expect(try document(fixture, card2Path).deleted.value != nil, + #expect(fixture.exists(".trash/\(Ident.card2)"), "the step below was never reached — a refused disk is not a reason to attempt more") #expect(history.undoActionName == "Rename Card") } @@ -1322,34 +1366,36 @@ struct HistoryPhraseTests { /// that the two doors reach one state rather than two similar ones, and that alternating between /// them leaves a stack that crosses cleanly in both directions. @MainActor -@Suite("Undo ▸ the trash's two doors") +@Suite("Undo ▸ the trash and the stack are the same folder moves") struct TrashInterplayTests { - @Test("Undoing a delete lands exactly where Put Back would have — the same bytes, not a near miss") + /// 13-native-undo.md ▸ Interaction with the trash: "The stack and the trash never conflict — they + /// are the same folder moves addressed by recency instead of by selection." + @Test("Undoing a delete lands exactly where a manual move-out would — the same bytes") func theTwoDoorsReachOneState() async throws { // Two identical boards, one per door: the claim is about a *state*, so the honest comparison // is the whole board read off disk, not the one field each path happens to write. let byUndo = try makeBoard() defer { byUndo.tearDown() } - let byPutBack = try makeBoard() - defer { byPutBack.tearDown() } + let byMove = try makeBoard() + defer { byMove.tearDown() } let origin = try boardTexts(byUndo) let (undoStore, history) = try makeStore(byUndo) undoStore.delete([card1]) history.undo() - let (putBackStore, _) = try makeStore(byPutBack) - putBackStore.delete([card1]) - // Put Back reads the trashed side of the snapshot, so it has to see the tombstone first — - // which is the one-way flow, not a test artefact. - await reload(putBackStore) - putBackStore.putBack([card1]) + let (moveStore, _) = try makeStore(byMove) + moveStore.delete([card1]) + // The move-out reads the trash side of the snapshot, so it has to see the card arrive there + // first — which is the one-way flow, not a test artefact. + await reload(moveStore) + moveStore.moveCards([card1], toLane: lane1, at: 0) - expectSameBoard(try boardTexts(byUndo), try boardTexts(byPutBack), "the two doors") + expectSameBoard(try boardTexts(byUndo), try boardTexts(byMove), "the two doors") expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from") - #expect(try document(byUndo, card1Path).deleted.isMissing) - #expect(try document(byPutBack, card1Path).deleted.isMissing) + #expect(byUndo.exists(card1Path)) + #expect(byMove.exists(card1Path)) } @Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end") @@ -1358,25 +1404,25 @@ struct TrashInterplayTests { defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) - /// The lane's live cards in display order, read through the loader — the order the board - /// actually renders, rather than the ranks it is derived from. - func liveCards() throws -> [String] { + /// The lane's cards in display order, read through the loader — the order the board actually + /// renders, rather than the ranks it is derived from. + func laneCards() throws -> [String] { let result = try BoardLoader.load(boardRoot: fixture.root) let lane = try #require(result.model.lanes.first { $0.id == lane1 }) - return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue) + return lane.cards.map(\.id.rawValue) } - let before = try liveCards() + let before = try laneCards() #expect(before == [Ident.card1, Ident.card2, Ident.card3], "the middle card is genuinely in the middle") // The middle of three: an implementation that restored by appending would pass on a first or // last card and fail here. store.delete([card2]) await reload(store) - #expect(try liveCards() == [Ident.card1, Ident.card3]) + #expect(try laneCards() == [Ident.card1, Ident.card3]) history.undo() - #expect(try liveCards() == before, "01's deletion bullet: restore is position-perfect") + #expect(try laneCards() == before, "13: the undo returns the card to its source lane and rank") #expect(try document(fixture, card2Path).order.value == 2048, "the rank it held all along") } @@ -1387,30 +1433,27 @@ struct TrashInterplayTests { let (store, history) = try makeStore(fixture) let live = try boardTexts(fixture) - // Three gestures over one card, alternating the doors: ⌫, Put Back, ⌫. + // Three gestures over one card, alternating the doors: ⌫, move back out, ⌫. store.delete([card1]) await reload(store) - let filedUnder = try #require(try document(fixture, card1Path).deleted.value) + #expect(fixture.exists(".trash/\(Ident.card1)")) - store.putBack([card1]) + store.moveCards([card1], toLane: lane1, at: 0) await reload(store) - #expect(try document(fixture, card1Path).deleted.isMissing) + #expect(fixture.exists(card1Path)) store.delete([card1]) await reload(store) #expect(history.undoActionName == "Delete Card") - let trashed = try boardTexts(fixture) + let trashedBoard = try boardTexts(fixture) - // Back up the stack: delete → restore → tombstone → restore. The middle step is Put Back's - // inverse, which has to re-file the row under the stamp it was filed under rather than under - // now — the trash sorts by it. + // Back up the stack: delete → move → delete, each crossed in reverse. history.undo() - #expect(try document(fixture, card1Path).deleted.isMissing) - #expect(history.undoActionName == "Restore Card") + #expect(fixture.exists(card1Path)) + #expect(history.undoActionName == "Move Card") history.undo() - #expect(try document(fixture, card1Path).deleted.value == filedUnder, - "re-tombstoned where it was filed, not where a fresh stamp would put it") + #expect(fixture.exists(".trash/\(Ident.card1)"), "back in the trash the move took it out of") #expect(history.undoActionName == "Delete Card") history.undo() @@ -1419,12 +1462,12 @@ struct TrashInterplayTests { // And forward again, the same three steps in the other direction. history.redo() - #expect(try document(fixture, card1Path).deleted.value != nil) + #expect(fixture.exists(".trash/\(Ident.card1)")) history.redo() - #expect(try document(fixture, card1Path).deleted.isMissing) + #expect(fixture.exists(card1Path)) history.redo() #expect(history.canRedo == false) - expectSameBoard(try boardTexts(fixture), trashed, "three doors forward") + expectSameBoard(try boardTexts(fixture), trashedBoard, "three doors forward") #expect(store.banners.signposts.isEmpty, "nothing was stale: the doors never collided") #expect(store.banners.oneShots.isEmpty) } diff --git a/KanbanTests/WriterTestSupport.swift b/KanbanTests/WriterTestSupport.swift index 95e69d5..0086a24 100644 --- a/KanbanTests/WriterTestSupport.swift +++ b/KanbanTests/WriterTestSupport.swift @@ -93,6 +93,29 @@ struct WriterFixture { func entryNames(_ relativePath: String) throws -> [String] { try FileManager.default.contentsOfDirectory(atPath: url(relativePath).path).sorted() } + + /// Moves a folder from one place in the board to another — a **foreign** move, the way an agent + /// or a hand-editor makes one: `FileManager` and nothing else, no `index.md` rewritten, no rank + /// touched. What the container-crossing rules are stated in terms of (02-architecture.md § + /// Live-reload resilience, resettled 2026-07-28). + func moveFolder(_ relativePath: String, to destinationPath: String) throws { + let destination = url(destinationPath) + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.moveItem(at: url(relativePath), to: destination) + } + + /// A card folder moved into `/.trash/` — the shape of a delete on disk, made foreignly. + func move(_ relativePath: String, toTrash cardName: String) throws { + try moveFolder(relativePath, to: ".trash/\(cardName)") + } + + /// A card folder moved out of the trash into a lane — the shape of a restore, made foreignly. + func move(_ relativePath: String, toLane laneName: String, card cardName: String) throws { + try moveFolder(relativePath, to: "\(laneName)/\(cardName)") + } } // MARK: - Move/copy identities