Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+1 -1
View File
@@ -347,7 +347,7 @@ struct RevealInFinderCommand: View {
if let store { if let store {
let ids = store.selection.ids let ids = store.selection.ids
guard !ids.isEmpty else { return [store.rootURL] } 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) } .map { $0.folder(under: store.rootURL) }
} }
if let attachments { if let attachments {
+2 -4
View File
@@ -645,11 +645,9 @@ public final class AppModel {
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) { public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
var lanes = 0 var lanes = 0
var cards = 0 var cards = 0
for lane in snapshot.lanes where !lane.isDeleted { for lane in snapshot.lanes {
lanes += 1 lanes += 1
for card in lane.cards where !card.isDeleted { cards += lane.cards.count
cards += 1
}
} }
return (lanes, cards) return (lanes, cards)
} }
+2 -4
View File
@@ -175,9 +175,7 @@ struct CardWindowHost: View {
let identity = ItemID(rawValue: cardID) let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes { for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue } guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
return lane.isDeleted || card.isDeleted return .shows(CardPlacement(card: card, lane: lane))
? .dismisses
: .shows(CardPlacement(card: card, lane: lane))
} }
return .dismisses return .dismisses
} }
@@ -319,7 +317,7 @@ struct CardWindowHost: View {
/// links resolve against (05-card-window.md Preview). /// links resolve against (05-card-window.md Preview).
/// ///
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for /// 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. /// resolving images against where the board used to be would quietly stop showing them.
static func cardFolder(root: URL, placement: CardPlacement) -> URL { static func cardFolder(root: URL, placement: CardPlacement) -> URL {
root root
+4 -4
View File
@@ -27,7 +27,7 @@ extension UTType {
/// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments /// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments
/// absent", announced by a banner rather than discovered later. /// 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 /// 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 /// 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. /// 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 boardRoot: String
public var kind: SelectionKind public var kind: SelectionKind
public var side: Liveness public var container: ItemContainer
public var entries: [Entry] public var entries: [Entry]
/// One copied item: where its snapshot is staged, what it is called, and its bytes. /// 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, copyID: String,
boardRoot: URL, boardRoot: URL,
kind: SelectionKind, kind: SelectionKind,
side: Liveness, container: ItemContainer,
entries: [Entry] entries: [Entry]
) { ) {
self.version = version self.version = version
self.copyID = copyID self.copyID = copyID
self.boardRoot = boardRoot.path self.boardRoot = boardRoot.path
self.kind = kind self.kind = kind
self.side = side self.container = container
self.entries = entries self.entries = entries
} }
+70 -59
View File
@@ -192,7 +192,7 @@ public final class ClipboardStore {
copyID: copyID, copyID: copyID,
boardRoot: store.rootURL, boardRoot: store.rootURL,
kind: capture.kind, kind: capture.kind,
side: capture.side, container: capture.container,
entries: capture.subjects.map(\.entry) entries: capture.subjects.map(\.entry)
) )
guard let data = manifest.encoded() else { return } guard let data = manifest.encoded() else { return }
@@ -209,7 +209,7 @@ public final class ClipboardStore {
armedCut = ArmedCut(copyID: copyID, source: store) armedCut = ArmedCut(copyID: copyID, source: store)
store.transient.pendingCut = ItemReferenceSet( store.transient.pendingCut = ItemReferenceSet(
ids: Set(capture.subjects.map(\.id)), 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." // "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 // MARK: - Availability
/// Whether Edit Copy applies a non-empty selection that still names something the board /// Whether Edit Copy applies a non-empty selection that still names something, in either
/// renders, on either side of the live/tombstoned boundary. /// 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 /// **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 /// 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 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 /// Whether Edit Cut applies. Copy's conditions plus the one a *move* adds: the board must
/// accept writes (a cut mutates its source), and the selection must be **live** "X is /// accept writes, since a cut mutates its source.
/// disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else" (04 The ///
/// trash). /// **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 { 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`. /// Whether Edit Paste applies to `store`.
@@ -347,7 +351,6 @@ public final class ClipboardStore {
operation: .move, operation: .move,
toLane: target.laneID, toLane: target.laneID,
at: target.index, at: target.index,
clearingTombstones: false,
normalizingLooseFiles: true normalizingLooseFiles: true
) )
case let .lanes(index): case let .lanes(index):
@@ -355,7 +358,6 @@ public final class ClipboardStore {
sources, sources,
operation: .move, operation: .move,
at: index, at: index,
clearingTombstones: false,
normalizingLooseFiles: true 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, // A card copied out of the trash needs nothing done to it on arrival: it carries no
// and the one axis a paste varies that a within-board drop never does. // `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled
let clearingTombstones = manifest.side == .trashed // 2026-07-28). The tombstone era's strip-at-materialization axis is gone with it.
switch plan { switch plan {
case let .cards(target): case let .cards(target):
store.receiveCards( store.receiveCards(
@@ -399,7 +401,6 @@ public final class ClipboardStore {
operation: .copy, operation: .copy,
toLane: target.laneID, toLane: target.laneID,
at: target.index, at: target.index,
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true normalizingLooseFiles: true
) )
case let .lanes(index): case let .lanes(index):
@@ -407,7 +408,6 @@ public final class ClipboardStore {
sources, sources,
operation: .copy, operation: .copy,
at: index, at: index,
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true normalizingLooseFiles: true
) )
} }
@@ -427,10 +427,11 @@ public final class ClipboardStore {
let survivors = source.transient.pendingCut let survivors = source.transient.pendingCut
guard !survivors.isEmpty else { return nil } guard !survivors.isEmpty else { return nil }
// `TrashModel.paths` walks lanes in board order and each lane's cards in card order, which is // `ItemPath.resolve` walks the container in display order, which is the flatten order the
// the flatten order the drop commits insert in and the pending cut is homogeneous by kind, // drop commits insert in and the pending cut is homogeneous by container, so it is asked
// so only one of its two branches ever contributes. // for exactly the side the cut was made on. A cut made in the trash therefore hands the
let folders = TrashModel.paths(of: survivors.ids, on: .live, in: source.snapshot) // 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) } .map { $0.folder(under: source.rootURL) }
guard !folders.isEmpty else { return nil } guard !folders.isEmpty else { return nil }
return (source, folders) return (source, folders)
@@ -541,17 +542,17 @@ public final class ClipboardStore {
/// produces. /// produces.
struct Subject { struct Subject {
let id: ItemID let id: ItemID
let path: TrashModel.ItemPath let path: ItemPath
let entry: ClipboardManifest.Entry let entry: ClipboardManifest.Entry
} }
/// The selection, resolved into copy subjects in the order the clipboard records them or `nil` /// 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 /// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
/// (side, kind) pairs: flatten order for live cards, left-to-right for live lanes, and the trash's /// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the
/// own deterministic sort for either kind of entry. Deriving it here would be a fifth definition /// trash's own `order` for trash cards. Deriving it here would be a second definition of an order
/// of an order the app already states once. /// the app already states once.
/// ///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line /// **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 /// span, so `serialized()` on an untouched document returns the file's bytes exactly which
@@ -560,45 +561,18 @@ public final class ClipboardStore {
static func capture( static func capture(
selection: ItemReferenceSet, selection: ItemReferenceSet,
snapshot: BoardModel 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 } guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil }
let side = selection.liveness let container = selection.container
let ordered = SelectionGrammar.order(of: kind, on: side, in: snapshot) let ordered = SelectionGrammar.order(of: kind, in: container, snapshot: snapshot)
.filter { selection.ids.contains($0) } .filter { selection.ids.contains($0) }
guard !ordered.isEmpty else { return nil } guard !ordered.isEmpty else { return nil }
var subjects: [ItemID: Subject] = [:] var subjects: [ItemID: Subject] = [:]
for lane in snapshot.lanes { func addCard(_ card: Card, at path: ItemPath) {
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
)
}
)
)
}
// 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( subjects[card.id] = Subject(
id: card.id, id: card.id,
path: TrashModel.ItemPath(laneID: lane.id, cardID: card.id), path: path,
entry: ClipboardManifest.Entry( entry: ClipboardManifest.Entry(
id: card.id.rawValue, id: card.id.rawValue,
folder: card.id.rawValue, folder: card.id.rawValue,
@@ -608,10 +582,47 @@ public final class ClipboardStore {
) )
) )
} }
switch container {
case .trash:
for card in snapshot.trash {
addCard(card, at: .trashCard(card.id))
}
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] } let resolved = ordered.compactMap { subjects[$0] }
guard !resolved.isEmpty else { return nil } guard !resolved.isEmpty else { return nil }
return (kind, side, resolved) return (kind, container, resolved)
} }
} }
+13 -23
View File
@@ -110,7 +110,7 @@ extension BoardStore {
// gone is not one to pop: the stack is about to be cleared with the session anyway. // gone is not one to pop: the stack is about to be cleared with the session anyway.
guard let store else { return .failed } 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) store.banners.postSkippedStep(direction, subject: subject)
return .skipped return .skipped
} }
@@ -157,11 +157,11 @@ extension BoardStore {
/// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte. /// 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*, /// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no
/// because the item was born of the gesture being undone a tombstone would leave a trash row /// trace*, because the item was born of the gesture being undone filing it in the trash would
/// for a card the user never really made. `purgeIsUnrecoverable` is untouched by this: that flag /// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched
/// is about Delete Immediately, whose loss is the user's own final gesture, while this loss is /// by this: that flag is about Delete Immediately, whose loss is the user's own final gesture,
/// one Z away. /// while this loss is one Z away.
/// ///
/// The honest edge, recorded rather than papered over: anything that happened *inside* the /// 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 /// 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 /// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather
/// than from anything decided here. /// than from anything decided here.
/// ///
/// **Its staleness predicate is existence and liveness, and deliberately nothing else** (13: /// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence
/// "existence/liveness for create/delete/restore steps"). A create's after-value *is* the item's /// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the
/// being there, so the undo validates that the folders are still there and still live and the /// undo validates that the folders are still at their paths and the redo that they are still
/// redo that they are still gone. The same honest edge follows: a foreign *edit* inside a created /// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop Z
/// card does not stop Z from removing it, because the create never wrote that field while a /// from removing it, because the create never wrote that field while a foreign *delete* does,
/// foreign *delete* does, since the trash row the user is looking at is not this step's to purge. /// 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) { func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) {
guard !items.isEmpty else { return } guard !items.isEmpty else { return }
let operation: WriteOperation = kind == .lane ? .createLane : .createCard let operation: WriteOperation = kind == .lane ? .createLane : .createCard
registerStep( registerStep(
HistoryPhrase.name(.add, kind: kind, count: items.count), HistoryPhrase.name(.add, kind: kind, count: items.count),
subject: subject, subject: subject,
undoExpects: items.map { .live($0.folder) }, undoExpects: items.map { .present($0.folder) },
redoExpects: items.map { .absent($0.folder) } redoExpects: items.map { .absent($0.folder) }
) { _ in ) { _ in
// Reversed, so a lane and a card created by one gesture unwind child-first the same // 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<Date>, in document: inout FrontmatterDocument) {
document.set(FrontmatterKeys.deleted, to: .date(prior.value ?? Date()))
}
} }
+40 -88
View File
@@ -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 /// What one folder must currently hold for a step to be safe to cross the state that step's write
/// left it in. /// left it in.
/// ///
/// Two halves, both of them 13's: **existence and liveness** ("target folder gone ... the step is /// Two halves, both of them 13's: **existence** ("target folder gone ... the step is skipped"),
/// skipped"; "existence/liveness for create/delete/restore steps"), and the **field-level** /// and the **field-level** comparison above. A step carries one of these per item it touched, so a
/// comparison above. A step carries one of these per item it touched, so a multi-card move validates /// multi-card move validates three targets and a single rename validates one which is the whole of
/// three targets and a single rename validates one which is the whole of "a foreign change to an /// "a foreign change to an unrelated item must not skip anything": an item no step named is an item
/// unrelated item must not skip anything": an item no step named is an item no expectation mentions. /// no expectation mentions.
/// ///
/// **The folder's *path* is the parent check.** A move's step expects the card at its destination /// ### The container side rides in the folder path
/// 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 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 `<root>/.trash/<id>`; 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 `<root>/<lane>/<id>`, 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 { public struct HistoryExpectation: Sendable, Equatable {
/// Where the item this step wrote to should be the destination for a move, the item's own /// 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. /// folder for everything else, and the board root for the board's own rename and styling.
public let folder: URL 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 public let presence: Presence
/// The fields the step's write set, with the values it set them to. Empty for a step whose /// 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] 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 { public enum Presence: Sendable, Equatable {
/// There, and rendered: no `deleted:` on the item **or on any ancestor**. Liveness is /// There, with a readable `index.md`. Which container that is, is the path's own answer.
/// effective, the same ancestor walk `BoardStore.liveItem` and the card windows' fate rule case present
/// apply a card under a tombstoned lane renders nowhere, so it is as gone as a deleted one.
case live
/// There, and tombstoned a trash row, or a card hidden under a tombstoned lane. /// Not there at all: the folder is gone. What an undone create and an undone lane delete
case tombstoned /// leave, and what a redone one expects to find before putting it back.
/// 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.
case absent case absent
} }
@@ -86,37 +88,21 @@ public struct HistoryExpectation: Sendable, Equatable {
self.fields = fields self.fields = fields
} }
/// The item is live and its fields say what the step set them to. /// The item is at this path and its fields say what the step set them to.
public static func live(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation { public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields) HistoryExpectation(folder: folder, presence: .present, fields: fields)
} }
/// The same, for a caller whose field list is computed the styling gesture's, which varies per /// 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. /// dimension. A label rather than a second variadic, so `.present(folder)` stays unambiguous.
public static func live(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation { public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields) HistoryExpectation(folder: folder, presence: .present, 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)
} }
/// Nothing is at this path. /// Nothing is at this path.
public static func absent(_ folder: URL) -> HistoryExpectation { public static func absent(_ folder: URL) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .absent, fields: []) 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 // 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 ... /// (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 /// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
/// before the inverse would have been written. /// 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 { public enum HistoryStaleness {
/// Whether every target a step named still holds what that step left there. /// Whether every target a step named still holds what that step left there.
/// public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool {
/// `root` is the board's current root, which the liveness walk stops at a lane's parent. expectations.allSatisfy(isCurrent)
public static func isCurrent(_ expectations: [HistoryExpectation], under root: URL) -> Bool {
expectations.allSatisfy { isCurrent($0, under: root) }
} }
/// One target's answer. /// One target's answer.
/// ///
/// A file that cannot be read or parsed fails every expectation but `.absent`: an `index.md` /// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody
/// somebody has just broken is not one holding this step's after-value, and the honest reading of /// has just broken is not one holding this step's after-value, and the honest reading of "the
/// "the field no longer holds it" covers a field that can no longer be read at all. /// 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 { public static func isCurrent(_ expectation: HistoryExpectation) -> Bool {
guard expectation.presence != .absent else { guard expectation.presence != .absent else {
return !FileManager.default.fileExists(atPath: expectation.folder.path) return !FileManager.default.fileExists(atPath: expectation.folder.path)
} }
guard let document = index(at: expectation.folder) else { return false } 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) } 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 // MARK: Reading
/// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one /// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one
+74
View File
@@ -416,6 +416,27 @@ public final class BannerCenter {
postLoss(message) 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 /// 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 /// 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 /// 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)" 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 /// 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 "). /// login' changed outside Lanework", with Z's mirror ("Redo skipped ").
/// ///
File diff suppressed because it is too large Load Diff
+171
View File
@@ -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
/// `<root>/.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<ItemID> {
var universe: Set<ItemID> = []
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**
/// `<root>/<lane>`, `<root>/<lane>/<card>`, and `<root>/.trash/<card>`. The old two-optional-fields
/// shape could spell a fourth thing that does not exist; this cannot.
public enum ItemPath: Sendable, Equatable {
/// A lane: `<root>/<lane>/`.
case lane(ItemID)
/// A card in a lane: `<root>/<lane>/<card>/`.
case card(lane: ItemID, id: ItemID)
/// A card in the board's trash: `<root>/.trash/<card>/`.
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<ItemID>,
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
}
}
+19 -41
View File
@@ -21,7 +21,7 @@ import Foundation
/// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee, /// "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 /// 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 /// `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 /// `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). /// 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 /// **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 /// 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 /// layout and the filter is content. There is no lane overload at all: the tombstone model's trash
/// tombstoned lanes as often as they are cards and which filter "like any lane" by their own /// held lane *entries* that had to be filtered like rows, and lanes are never trashed now
/// title + body (03-board-ui.md § Trash). /// (03-board-ui.md § Trash), so a card predicate is the whole of the filter.
public struct SearchFilter: Sendable, Equatable { public struct SearchFilter: Sendable, Equatable {
/// The query exactly as typed kept so a caller can key a transaction or a test on it. /// 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) 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 // 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" /// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection"
/// (`TransientBoardState.constrainToSearch(in:)`). /// (`TransientBoardState.constrainToSearch(in:)`).
/// ///
/// It is shaped exactly like `ItemReferenceSet.idUniverse(of:on:)` and means the same thing one /// It is shaped exactly like `ItemContainer.ids(in:)` and means the same thing one step
/// step narrower: that one answers "what does the board *have*", this one "what does the board /// narrower: that one answers "what does the board *have*", this one "what does the board
/// *show*". Two differences, both stated above and neither incidental: /// *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.
/// - **Live lanes are all in it.** The filter hides cards, so a lane is visible whatever its public func visibleIDs(in snapshot: BoardModel, container: ItemContainer) -> Set<ItemID> {
/// 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<ItemID> {
switch side {
case .live:
var ids: Set<ItemID> = [] var ids: Set<ItemID> = []
for lane in snapshot.lanes where !lane.isDeleted { switch container {
case .board:
for lane in snapshot.lanes {
ids.insert(lane.id) 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)
}
}
case .trash:
for card in snapshot.trash where matches(card) {
ids.insert(card.id) ids.insert(card.id)
} }
} }
return ids return ids
case .trashed:
return Set(TrashModel.entries(of: snapshot).lazy.filter { matches($0) }.map(\.id))
}
} }
// MARK: - Folding // MARK: - Folding
+119 -131
View File
@@ -21,22 +21,22 @@ public enum SelectionKind: String, Codable, Sendable, Equatable {
case lane case lane
} }
/// What a pointer click names: an item, its level, and the side of the live/trash boundary the /// What a pointer click names: an item, its level, and the container the surface it was clicked on
/// surface it was clicked on sits on. /// belongs to.
/// ///
/// The **side is the surface's, not the item's** a card face is always `.live` and a trash row is /// The **container is the surface's, not the item's** a card face is always `.board` and a trash
/// always `.trashed`, because that is what the user clicked. A click on a surface whose item flipped /// row is always `.trash`, because that is what the user clicked. A click on a surface whose item
/// liveness a moment ago simply selects nothing the next reload will keep, which is the ordinary /// crossed containers a moment ago simply selects nothing the next reload will keep, which is the
/// vanish rule and not a case for this type to model. /// ordinary vanish rule and not a case for this type to model.
public struct SelectionTarget: Sendable, Equatable { public struct SelectionTarget: Sendable, Equatable {
public var id: ItemID public var id: ItemID
public var kind: SelectionKind 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.id = id
self.kind = kind self.kind = kind
self.side = side self.container = container
} }
} }
@@ -59,15 +59,16 @@ public enum ClickModifier: Sendable, Equatable {
/// (`SelectionGrammarTests`). /// (`SelectionGrammarTests`).
/// ///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is /// **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 /// homogeneous on **two** axes now cards XOR lanes (§ Selection) and board XOR trash (§ The
/// within the trash card entries XOR lane entries (§ The trash) and every one of them is a /// trash's "single container rule replacing the old liveness law") and the third, kind-inside-the
/// property of what a *click* is allowed to produce. So no outcome below is ever mixed: a modifier /// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
/// that would cross an axis degrades to a replace, which is the only answer that keeps the /// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below
/// invariant true without silently dropping what the user asked for. /// 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 /// **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, /// 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 { public enum SelectionGrammar {
/// What a click leaves behind: the new selection, the anchor a subsequent -click would range /// 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. /// The grammar, one call.
/// ///
/// - Parameters: /// - 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`). /// - modifier: the effective modifier, already reduced to one of three (`ClickModifier`).
/// - selection: the board's current selection. /// - selection: the board's current selection.
/// - anchor: the range origin `TransientBoardState.selectionAnchor`. /// - anchor: the range origin `TransientBoardState.selectionAnchor`.
@@ -145,18 +146,18 @@ public enum SelectionGrammar {
selection: ItemReferenceSet, selection: ItemReferenceSet,
togglesOnRepeat: Bool togglesOnRepeat: Bool
) -> Outcome { ) -> Outcome {
if togglesOnRepeat, selection.liveness == target.side, selection.ids == [target.id] { if togglesOnRepeat, selection.container == target.container, selection.ids == [target.id] {
return .cleared return .cleared
} }
return Outcome( return Outcome(
selection: ItemReferenceSet(ids: [target.id], liveness: target.side), selection: ItemReferenceSet(ids: [target.id], container: target.container),
anchor: target.id, anchor: target.id,
head: target.id head: target.id
) )
} }
/// **-click toggles** but only *within* a homogeneous set. Crossing either axis (a card /// **-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, /// 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. /// because the click unambiguously names a new set of one.
/// ///
@@ -168,7 +169,7 @@ public enum SelectionGrammar {
selection: ItemReferenceSet, selection: ItemReferenceSet,
snapshot: BoardModel snapshot: BoardModel
) -> Outcome { ) -> Outcome {
guard selection.liveness == target.side, guard selection.container == target.container,
let current = kind(of: selection, in: snapshot), let current = kind(of: selection, in: snapshot),
current == target.kind current == target.kind
else { else {
@@ -183,7 +184,7 @@ public enum SelectionGrammar {
return .cleared return .cleared
} }
return Outcome( return Outcome(
selection: ItemReferenceSet(ids: ids, liveness: target.side), selection: ItemReferenceSet(ids: ids, container: target.container),
anchor: target.id, anchor: target.id,
head: 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 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 /// 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 /// 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. /// click names an unambiguous target and so always has something to do.
private static func shift( private static func shift(
@@ -210,15 +211,15 @@ public enum SelectionGrammar {
from: anchor, from: anchor,
to: target.id, to: target.id,
kind: target.kind, kind: target.kind,
on: target.side, in: target.container,
in: snapshot, snapshot: snapshot,
filter: filter filter: filter
) )
else { else {
return plain(target, selection: selection, togglesOnRepeat: false) return plain(target, selection: selection, togglesOnRepeat: false)
} }
return Outcome( return Outcome(
selection: ItemReferenceSet(ids: span, liveness: target.side), selection: ItemReferenceSet(ids: span, container: target.container),
anchor: anchor, anchor: anchor,
head: target.id 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 /// **`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 /// 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 /// (container, kind) pair. The callers differ on what they do with that: a click degrades to a
/// click (it names an unambiguous target), while a -arrow goes inert (its next step is /// plain click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous). /// ambiguous).
/// ///
/// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search /// **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, from: ItemID,
to: ItemID, to: ItemID,
kind: SelectionKind, kind: SelectionKind,
on side: Liveness, in container: ItemContainer,
in snapshot: BoardModel, snapshot: BoardModel,
filter: SearchFilter = .inactive filter: SearchFilter = .inactive
) -> Set<ItemID>? { ) -> Set<ItemID>? {
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 } 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]) return Set(start <= end ? list[start...end] : list[end...start])
} }
// MARK: - The order lists // 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. /// board, in what order" question is answered** for the pointer.
/// ///
/// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere /// **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 /// 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 /// 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 /// 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 /// 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. /// 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`. /// **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( public static func order(
of kind: SelectionKind, of kind: SelectionKind,
on side: Liveness, in container: ItemContainer,
in snapshot: BoardModel, snapshot: BoardModel,
filter: SearchFilter = .inactive filter: SearchFilter = .inactive
) -> [ItemID] { ) -> [ItemID] {
switch (side, kind) { switch (container, kind) {
case (.live, .card): liveCards(in: snapshot, filter: filter) case (.board, .card): boardCards(in: snapshot, filter: filter)
case (.live, .lane): liveLanes(in: snapshot) case (.board, .lane): lanes(in: snapshot)
case (.trashed, _): trashEntries(of: kind, in: snapshot, filter: filter) 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 /// 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). /// 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 /// **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 /// 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. /// 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] = [] var ids: [ItemID] = []
for lane in snapshot.lanes where !lane.isDeleted { for lane in snapshot.lanes {
for card in lane.cards where !card.isDeleted && filter.matches(card) { for card in lane.cards where filter.matches(card) {
ids.append(card.id) ids.append(card.id)
} }
} }
return ids return ids
} }
/// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md § /// The board's lanes, left to right.
/// Trash collapses each into one entry), so they are absent from the live lane order entirely.
/// ///
/// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the /// **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 /// 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 /// 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. /// part of the board grammar a search does not narrow.
public static func liveLanes(in snapshot: BoardModel) -> [ItemID] { public static func lanes(in snapshot: BoardModel) -> [ItemID] {
snapshot.lanes.filter { !$0.isDeleted }.map(\.id) snapshot.lanes.map(\.id)
} }
/// One kind of trash row, in the quasi-lane's own deterministic order (`TrashModel.entries`, /// The trash's cards, top to bottom `snapshot.trash` itself, which the loader already sorted
/// whose sort is "load-bearing for input arrow walks, -ranges, and the rubber band all read /// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like
/// it"). /// 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 /// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the
/// interleave in one ordering, and "a selection never mixes card entries and lane entries" /// filter exactly like any other card") the same predicate `TrashLaneView` applies to the same
/// (04-interactions.md The trash), so a -range between two card rows spans the sorted order /// cards, so a trash-side range walks exactly what the column is showing.
/// and collects only the card rows stepping over any lane row that sits between them. That is public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
/// the deliberate pointer twin of the keyboard's rule: a -arrow onto a lane entry is *inert* snapshot.trash.filter { filter.matches($0) }.map(\.id)
/// 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)
} }
// MARK: - The current selection's kind // MARK: - The current selection's kind
/// Which level the selection holds, or `nil` when it holds nothing the board renders on its own /// Which level the selection holds, or `nil` when it holds nothing its container renders.
/// side.
/// ///
/// **Any member answers, because the set is homogeneous** but the walk is the snapshot's order /// **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 /// 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 /// 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. /// 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 /// **The trash answers `.card` or nothing**, because lanes are never trashed which is why the
/// effective liveness is live, and on the trashed side only **rows** count a card under a /// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
/// tombstoned lane has no row of its own (`TrashModel.entries`' absolute ancestor walk), so it
/// is nobody's kind.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? { public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil } guard !selection.isEmpty else { return nil }
switch selection.container {
case .trash:
return snapshot.trash.contains { selection.ids.contains($0.id) } ? .card : nil
case .board:
for lane in snapshot.lanes { for lane in snapshot.lanes {
if Liveness(isDeleted: lane.isDeleted) == selection.liveness, selection.ids.contains(lane.id) { if selection.ids.contains(lane.id) { return .lane }
return .lane if lane.cards.contains(where: { selection.ids.contains($0.id) }) { return .card }
}
// 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
}
} }
return nil return nil
} }
}
// MARK: - Successor on delete // 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. /// 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 /// > 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 /// - **`nil` is a legitimate answer** an emptied container selects nothing, and the caller
/// clears. /// 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 /// **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 /// 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 container is what the surface is *showing*.** Under a search the successor must be a
/// the user can see "nothing invisible stays selected" is the trash's phrasing of a rule the /// card the user can see picking a hidden neighbour would hand the selection straight back to
/// filter obeys too and 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 /// `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. /// filter narrows the container, and repeated walks down the *filtered* lane.
public static func successor( public static func successor(
afterDeleting ids: Set<ItemID>, afterDeleting ids: Set<ItemID>,
in snapshot: BoardModel, in container: ItemContainer = .board,
snapshot: BoardModel,
filter: SearchFilter = .inactive filter: SearchFilter = .inactive
) -> ItemID? { ) -> ItemID? {
guard !ids.isEmpty else { return nil } 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 } guard let kind = kind(of: selection, in: snapshot) else { return nil }
let container: [ItemID] let siblings: [ItemID]
switch kind { switch (container, kind) {
case .lane: case (.trash, _):
container = liveLanes(in: snapshot) siblings = trashCards(in: snapshot, filter: filter)
case .card: 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 last selected card in flatten order names the lane; its lane's rendered cards are
// the container the successor is drawn from. // 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 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 } 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 } 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 } if let after = siblings[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
return container[..<first].last { !ids.contains($0) } return siblings[..<first].last { !ids.contains($0) }
} }
} }
// MARK: - The rubber band // MARK: - The rubber band
/// One item the marquee can sweep: its identity, its level, its side, and where it is drawn. /// One item the marquee can sweep: its identity, its level, its container, and where it is drawn.
/// ///
/// The frame is in the board strip's coordinate space (`BoardView.stripSpace`) and is **registered /// The frame is in the board strip's coordinate space (`BoardView.stripSpace`) and is **registered
/// by the view that draws it** (`MarqueeTargetRegistry`) rather than computed here: the masonry's /// by the view that draws it** (`MarqueeTargetRegistry`) rather than computed here: the masonry's
@@ -439,57 +430,54 @@ public enum SelectionGrammar {
public struct MarqueeTarget: Sendable, Equatable { public struct MarqueeTarget: Sendable, Equatable {
public var id: ItemID public var id: ItemID
public var kind: SelectionKind public var kind: SelectionKind
public var side: Liveness public var container: ItemContainer
public var frame: CGRect public var frame: CGRect
public init(id: ItemID, kind: SelectionKind, side: Liveness, frame: CGRect) { public init(id: ItemID, kind: SelectionKind, container: ItemContainer, frame: CGRect) {
self.id = id self.id = id
self.kind = kind self.kind = kind
self.side = side self.container = container
self.frame = frame self.frame = frame
} }
} }
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the side the /// What a rubber band selects, as a pure function of the band, the drawn frames, and the container
/// band started on (`SelectionGrammarTests`). /// the band started in (`SelectionGrammarTests`).
/// ///
/// The two rules it exists to state, both 04-interactions.md's: /// The two rules it exists to state, both 04-interactions.md's:
/// ///
/// - **The band stays on the side of the boundary it started on** ( The trash), which is why `side` /// - **The band stays on the side of the boundary it started on** ( The trash), which is why
/// is a parameter rather than something derived from what the rect happens to touch: a band begun /// `container` is a parameter rather than something derived from what the rect happens to touch: a
/// on the board and dragged over the trash column selects live cards and nothing else. /// band begun on the board and dragged over the trash column selects board cards and nothing else.
/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands /// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands
/// across lanes" across them, not over them). Lanes are simply never registered as targets, and /// across lanes" across them, not over them). Lanes are simply never registered as targets, and
/// the live branch filters to cards anyway so the rule holds even if one were. /// the filter below keeps the rule true even if one were.
///
/// **There is no kind rule any more.** Under the tombstone model the trash 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 the rule is one line for both.
public enum MarqueeMath { public enum MarqueeMath {
/// The ids `rect` sweeps. /// The ids `rect` sweeps.
/// public static func selection(
/// On the **trashed** side the band must additionally stay homogeneous by *kind*, because the rect: CGRect,
/// trash's two row kinds interleave in one column. The rule is topmost-wins: the kind of the targets: [MarqueeTarget],
/// highest intersecting row decides, and rows of the other kind are dropped so a band pulled in container: ItemContainer
/// down from a card row keeps collecting card rows and steps over the lane rows between them, ) -> Set<ItemID> {
/// exactly as a -range does. Set(
public static func selection(rect: CGRect, targets: [MarqueeTarget], side: Liveness) -> Set<ItemID> { targets.lazy
let hits = targets.filter { $0.side == side && rect.intersects($0.frame) } .filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) }
guard !hits.isEmpty else { return [] } .map(\.id)
)
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))
}
} }
/// Which of two drawn rows is "higher" top edge, then leading edge, then identity. /// 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 /// 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 /// Used by `NavigationMath`, which breaks its score ties with it: two candidates that a metric
/// candidates that a metric cannot separate must still be separated the same way twice. /// cannot separate must still be separated the same way twice.
static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool { 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.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 } if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
+3 -3
View File
@@ -154,7 +154,7 @@ public struct StyleEditorSession: Sendable, Equatable {
case .board: case .board:
return self return self
case let .items(ids): 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 } guard !live.isEmpty else { return nil }
return live == ids ? self : StyleEditorSession(target: .items(live)) 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. /// the popover lands on the first thing the user's eye would find.
public func presentationAnchor(in snapshot: BoardModel) -> ItemID? { public func presentationAnchor(in snapshot: BoardModel) -> ItemID? {
guard case let .items(ids) = target else { return nil } 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 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 return nil
} }
+67 -155
View File
@@ -1,82 +1,9 @@
import Foundation import Foundation
import Observation 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 // 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 /// 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 /// 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 /// (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 /// and everything else here is that primitive with a universe supplied. Only the universe differs
/// between the two callers: /// 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. /// is what `resolved(against:)` computes before delegating.
/// - **The live search filter**: the universe is the visible ids the predicate produced, so /// - **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 /// 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 /// 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 /// 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. /// know about snapshots, and the whole point of the value-type snapshot is that nothing has to.
public struct ItemReferenceSet: Sendable, Equatable { public struct ItemReferenceSet: Sendable, Equatable {
public var ids: Set<ItemID> public var ids: Set<ItemID>
public var liveness: Liveness
public init(ids: Set<ItemID> = [], 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<ItemID> = [], container: ItemContainer = .board) {
self.ids = ids 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. /// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to.
public static let empty = ItemReferenceSet() public static let empty = ItemReferenceSet()
public var isEmpty: Bool { ids.isEmpty } 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 /// 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 /// (`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 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 /// The container survives even when the membership does not: an emptied set is still a set in a
/// on a side, and re-populating it (a fresh click, a new drag) is the caller's business. /// container, and re-populating it (a fresh click, a new drag) is the caller's business.
public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet { public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
guard !ids.isEmpty else { return self } 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 /// This set re-grounded on `snapshot`: the members that are still there, **in the same
/// side**, and nothing else. /// container**, and nothing else.
/// ///
/// Two rules, both settled in 02-architecture.md § Live-reload resilience: /// 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: /// 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 /// 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.) /// 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 /// - **A container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a
/// restores a selected tombstoned one ejects it, keeping 04-interactions.md's /// foreign move that trashes a selected board card or restores a selected trash card
/// homogeneous-by-liveness invariant true across reloads so menu validation never sees a /// ejects it from the selection (and from the pending cut)", keeping 04's container-boundary
/// mixed selection. The pending cut inherits the same rule for free (04 Clipboard: "a cut /// invariant true across reloads so menu validation never sees a mixed selection. Drag
/// item that is tombstoned or vanishes externally before paste drops out of the pending /// membership inherits it for free (04 Drag and drop's emptied-drag rule).
/// cut"), and so does drag membership (04 Drag and drop's emptied-drag rule).
/// ///
/// The liveness that is matched is **effective ancestor-walked** (settled), and the trashed /// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to
/// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read. /// compute "the old effective-liveness ancestor walk is retired with the tombstone model"
/// Tombstoning a lane therefore ejects its cards from a live set even though their own flags /// (02-architecture.md) because a deleted card's folder has actually moved, and a folder is
/// never changed and does **not** hand them to a trashed set, because the lane's single entry /// either in the container or it is not.
/// 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.
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet { public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
guard !ids.isEmpty else { return self } guard !ids.isEmpty else { return self }
return constrained(to: Self.idUniverse(of: snapshot, on: liveness)) return constrained(to: container.ids(in: snapshot))
}
/// 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<ItemID> {
var universe: Set<ItemID> = []
side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) }
return universe
} }
} }
@@ -442,8 +359,8 @@ public final class TransientBoardState {
// MARK: Per-open values // MARK: Per-open values
/// The lane that most recently held selection or a creation **in this window session** /// 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 /// 04-interactions.md's N target rule's fallback when nothing (or a trash selection, which
/// selected, before the last resort of the first lane. /// 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 /// 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 /// 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. /// is no target at all; `NewCardTarget` then falls through to the first lane.
public private(set) var lastActiveLaneID: ItemID? 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 /// **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 /// 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 /// 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. /// `resolve(against:)` on the next reload is what keeps the set honest over time.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) { public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, liveness: liveness) selection = ItemReferenceSet(ids: ids, container: container)
let sole = ids.count == 1 ? ids.first : nil let sole = ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole selectionAnchor = anchor ?? sole
selectionHead = head ?? 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 /// **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 /// 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 /// drag in flight or a pending cut that also held it, and each set carries its own container.
/// side. Independence is what makes that a property of the code rather than of the order the /// Independence is what makes that a property of the code rather than of the order the lines
/// lines happen to be in. /// happen to be in.
/// ///
/// **The placeholder has its own two rules**, because it references a lane rather than items: /// **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 /// - **Discarded when its anchor lane is gone** absent from the snapshot. "If the
/// tombstoned. A tombstoned lane renders nowhere (03-board-ui.md collapses it to a single /// placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane
/// trash entry), so its lane "vanished" in every sense 02-architecture.md means: "if the /// delete is physical now, so gone is the only way a lane goes.
/// placeholder's lane vanished in the reload, it is discarded".
/// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the /// - **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 /// 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 /// 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 /// **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 /// 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. /// that is trashed, 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 /// "entering the trash is a vanish from the board; nothing is ever written into a vanished
/// writes wherever the item now lives which falls out for free from matching on identity /// folder". A foreign *move between lanes* is deliberately not a vanish the editor follows
/// rather than on position. Liveness is **effective**, so a card under a lane an agent just /// the UUID and the commit writes wherever the card now lives which falls out for free from
/// tombstoned vanishes with it. /// matching on identity within the board container.
/// ///
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md /// **`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 /// 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 /// 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. /// 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 /// **`selectionAnchor` obeys it too**, in the *selection's* container: a range origin that
/// or flipped liveness is gone, and the next -click acts as a plain click rather than ranging /// vanished or crossed containers is gone, and the next -click acts as a plain click rather
/// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the /// than ranging from somewhere that renders nowhere. It deliberately does **not** have to stay
/// selection a -click that toggles the anchor's neighbour out leaves the anchor selected and /// *in* the selection a -click that toggles the anchor's neighbour out leaves the anchor
/// a range from it is still exactly what the user asked for. /// 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, /// **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 /// 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. /// 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 /// 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) newCardPlaceholder = resolvedPlaceholder(against: snapshot)
styleEditor = styleEditor?.resolved(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. // 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) let board = ItemContainer.board.ids(in: snapshot)
if let editor = renameEditor, !live.contains(editor.targetID) { if let editor = renameEditor, !board.contains(editor.targetID) {
renameEditor = nil renameEditor = nil
} }
if let lane = lastActiveLaneID, !live.contains(lane) { if let lane = lastActiveLaneID, !board.contains(lane) {
lastActiveLaneID = nil lastActiveLaneID = nil
} }
if selectionAnchor != nil || selectionHead != nil { if selectionAnchor != nil || selectionHead != nil {
// The selection's side, because that is the side both cursors live on by construction // The selection's container, because that is where both cursors live by construction
// every route that sets either sets the selection to the same side in the same call. A // every route that sets either sets the selection to the same container in the same
// vanished or liveness-flipped cursor is gone, which is the rule every item reference // call. A vanished or container-crossed cursor is gone, which is the rule every item
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives // reference here gets. The head then re-derives from the selection's last member on the
// from the selection's last member on the next arrow, which is the same fallback an // next arrow, which is the same fallback an anchorless -arrow already uses.
// anchorless -arrow already uses. let universe = selection.container == .board ? board : selection.container.ids(in: snapshot)
let universe = selection.liveness == .live
? live
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = 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 /// 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 /// 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" /// 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 /// (`RenameEditor`) read for the materialized trash, true container crossings so a foreign
/// selection here and leaves the keystrokes exactly where the user left them. /// 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) { public func constrainToSearch(in snapshot: BoardModel) {
let filter = SearchFilter(query: searchQuery) let filter = SearchFilter(query: searchQuery)
guard filter.isActive else { return } 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) selection = selection.constrained(to: universe)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = 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? { private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? {
guard let placeholder = newCardPlaceholder else { return nil } guard let placeholder = newCardPlaceholder else { return nil }
guard let anchor = snapshot.lanes.first(where: { $0.id == placeholder.laneID }), guard snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil }
!anchor.isDeleted
else { return nil }
if case let .awaitingArrival(id) = placeholder.phase, if case let .awaitingArrival(id) = placeholder.phase,
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) { snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
+77 -339
View File
@@ -1,409 +1,147 @@
import Foundation 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 `<root>/<lane>/<card>`, 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<String> {
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 // MARK: - TrashModel
/// The trash quasi-lane's contents, as a pure function of a snapshot (`TrashModelTests`) /// What is left of the trash as a *model* once the trash became a folder 03-board-ui.md § Trash's
/// 03-board-ui.md § Trash's Contents rules with nothing else mixed in. /// **materialized** container (resettled 2026-07-28).
/// ///
/// **Pure because the trash is a pure view.** "Tombstoned cards keep their `deleted:` key and stay /// ### Almost nothing, and that is the point of the pivot
/// exactly where they are on disk; nothing about the storage schema is trash-specific", so there is
/// no trash *state* anywhere only this derivation, re-run against whatever snapshot is current.
/// A reload therefore rebuilds the rows for free, exactly as it rebuilds the lanes.
/// ///
/// The three rules it owns, each of which the design settles explicitly: /// 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 /// What genuinely remains is what the *commands* need and no view can answer: the two purge
/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There /// confirmations' phrasing, and the menu validation that stages Delete by place. Both are pure
/// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled /// functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is testable
/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is /// without an alert on screen.
/// 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.
public enum TrashModel { 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<ItemID>, 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 // MARK: - Counts and phrasing
/// How many lane entries and card entries a set of entries holds the confirmation dialogs' /// "41 cards", "1 card" 06-history-undo.md's **plural folding**, which is all the folding a
/// only input beyond the item titles. /// cards-only container can need ("Cards only. Lanes are never trashed" 03-board-ui.md).
public struct EntryCounts: Sendable, Equatable { public static func phrase(_ count: Int) -> String {
public var lanes: Int = 0 "\(count) card\(count == 1 ? "" : "s")"
public var cards: Int = 0
public var total: Int { lanes + cards }
public var isEmpty: Bool { total == 0 }
}
public static func counts(of entries: [TrashEntry]) -> EntryCounts {
var counts = EntryCounts()
for entry in entries {
if entry.isLaneEntry { counts.lanes += 1 } else { counts.cards += 1 }
}
return counts
}
/// The counts a set of `ItemPath`s describes the same two numbers, from the shape the write
/// path actually carries.
public static func counts(of paths: [ItemPath]) -> EntryCounts {
var counts = EntryCounts()
for path in paths {
if path.isLane { counts.lanes += 1 } else { counts.cards += 1 }
}
return counts
}
/// "2 lanes and 3 cards", "41 cards", "1 lane" 06-history-undo.md's **plural folding** applied
/// to a mixed trash selection.
///
/// An empty count reads "nothing", which no caller renders: both confirmations refuse to open on
/// an empty scope. It is spelled anyway so the function is total.
public static func phrase(_ counts: EntryCounts) -> String {
switch (counts.lanes, counts.cards) {
case (0, 0): "nothing"
case let (0, cards): plural(cards, "card")
case let (lanes, 0): plural(lanes, "lane")
case let (lanes, cards): "\(plural(lanes, "lane")) and \(plural(cards, "card"))"
}
}
private static func plural(_ count: Int, _ noun: String) -> String {
"\(count) \(noun)\(count == 1 ? "" : "s")"
} }
// MARK: - Confirmations // MARK: - Confirmations
/// A purge confirmation's three strings, built once and rendered by the window's alert. /// 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 /// A value rather than a view so the phrasing rules plural folding, naming a sole item, and
/// lane caveat, and whether the loss is actually irreversible are testable without an alert on /// whether the loss is actually irreversible are testable without an alert on screen.
/// screen (`TrashModelTests`).
public struct PurgePrompt: Sendable, Equatable { public struct PurgePrompt: Sendable, Equatable {
public let title: String public let title: String
public let message: String public let message: String
public let confirmTitle: String public let confirmTitle: String
} }
/// Delete Immediately's alert "the alert stands between one keystroke and unrecoverable /// The alert in front of a **permanent** card delete the trash's own / and File Delete
/// deletion" (03-board-ui.md § Trash). /// 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 /// `container` is where the command found the cards: the trash for the trash's Delete, the board
/// tombstoned, which is also the command's own refusal so the prompt and the action can never /// 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. /// disagree about whether there is anything to purge.
public static func purgePrompt( public static func purgePrompt(
for ids: Set<ItemID>, for ids: Set<ItemID>,
in snapshot: BoardModel, in container: ItemContainer,
snapshot: BoardModel,
unrecoverable: Bool unrecoverable: Bool
) -> PurgePrompt? { ) -> 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 } guard !targets.isEmpty else { return nil }
let counts = counts(of: targets)
let subject: String let subject: String
if targets.count == 1, let only = targets.first { if targets.count == 1, let only = targets.first {
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}" subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
} else { } else {
subject = phrase(counts) subject = phrase(targets.count)
} }
return PurgePrompt( return PurgePrompt(
title: "Permanently delete \(subject)?", title: "Permanently delete \(subject)?",
message: message(lanes: counts.lanes, unrecoverable: unrecoverable), message: message(unrecoverable: unrecoverable),
confirmTitle: "Delete" confirmTitle: "Delete"
) )
} }
/// Empty Trash's alert **always shown** ("bulk scope, not per-item recoverability, is what it /// Empty Trash's alert **always shown** ("Empty Trash confirms everywhere"), and always
/// guards"), and always naming the **true count**: every tombstone on the board, never the /// naming the **true count**: every card in `.trash/`, never the filtered view (03-board-ui.md §
/// filtered view. /// 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 /// 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 /// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing.
/// (06-history-undo.md's plural folding).
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? { public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
let counts = counts(of: emptyTrashTargets(in: snapshot)) guard !snapshot.trash.isEmpty else { return nil }
guard !counts.isEmpty else { return nil }
return PurgePrompt( return PurgePrompt(
title: "Permanently delete \(phrase(counts))?", title: "Permanently delete \(phrase(snapshot.trash.count))?",
message: message(lanes: counts.lanes, unrecoverable: unrecoverable), message: message(unrecoverable: unrecoverable),
confirmTitle: "Delete" 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 /// The tombstone era's second sentence "Deleting a lane also deletes every card inside it"
/// return), while purging the lane folder takes *every* card inside it, individually tombstoned /// is gone with the lane entries it warned about: no purge path reaches a lane any more
/// ones included. That gap is exactly what a confirmation is for. /// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with
private static func message(lanes: Int, unrecoverable: Bool) -> String { /// undo as its net).
var parts: [String] = [] private static func message(unrecoverable: Bool) -> String {
if lanes > 0 {
parts.append("Deleting a lane also deletes every card inside it.")
}
// m7-git: on a git board the content stays reachable in history, so the second sentence is // m7-git: on a git board the content stays reachable in history, so the second sentence is
// the honest one and Delete Immediately does not confirm there at all // the honest one and Delete Immediately does not confirm there at all
// (`BoardStore.purgeIsUnrecoverable`). // (`BoardStore.purgeIsUnrecoverable`).
parts.append(unrecoverable unrecoverable
? "This can\u{2019}t be undone." ? "This can\u{2019}t be undone."
: "The board\u{2019}s history still has them.") : "The board\u{2019}s history still has them."
return parts.joined(separator: " ")
} }
/// What to call an item in a prompt its title, or the "Untitled" rendering. /// 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 /// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
/// the app gives. /// the app gives.
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String { 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" } switch path {
guard let cardID = path.cardID else { return lane.title.value ?? "Untitled" } case let .lane(id):
return lane.cards.first { $0.id == cardID }?.title.value ?? "Untitled" 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 // MARK: - Menu validation
/// Whether File Delete has something to tombstone a **live**, non-empty selection that still /// Whether File Delete has something to act on **staged by place, but validated once**
/// names something the board renders. /// (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 /// One predicate for both stagings, because there is only one item now: a board selection moves
/// selections, Put Back / Delete Immediately for tombstoned ones" (04 The trash). The /// into the trash, a trash selection deletes permanently, and the command is enabled whenever
/// resolution against the snapshot is what keeps a selection the next reload will drop from /// either names something the board still holds. The old mirror-image pair
/// enabling an item that would write nothing. /// (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 { 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 /// Whether File Delete Immediately has something to purge **a card selection, from anywhere**
/// mirror of `canDelete`, which is what makes the two twins enable exactly one of themselves. /// (11-command-nexus.md: "Board window, card selection skips the trash from anywhere").
public static func canActOnTrash(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { ///
selection.liveness == .trashed && !paths(of: selection.ids, on: .trashed, in: snapshot).isEmpty /// 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 }
} }
} }
+1 -1
View File
@@ -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 /// `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 /// 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. /// so the write derives its path from the store's *current* root.
public struct LooseCardFiles: Sendable, Equatable { public struct LooseCardFiles: Sendable, Equatable {
public let laneID: ItemID public let laneID: ItemID
+10 -4
View File
@@ -1391,12 +1391,18 @@ public enum BoardWriter: Sendable {
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) 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 // 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 // app's delete is the physical move above, and no `deleted:` key is ever written again.
// 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. // **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: <now>` into its own `index.md` /// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md`
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never /// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
+18 -18
View File
@@ -141,14 +141,14 @@ struct OpenCardCommand: View {
return store.isEditingInline || soleSelectedCard != nil return store.isEditingInline || soleSelectedCard != nil
} }
/// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned /// 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 tombstoned selections" /// selection all answer `nil` "everything edit-shaped is disabled on trash selections Open
/// (04 The trash), and a card window is tied to one card. /// Card, Rename, Style" (04 The trash), and a card window is tied to one card.
private var soleSelectedCard: ItemID? { private var soleSelectedCard: ItemID? {
guard let store else { return nil } guard let store else { return nil }
let selection = store.selection let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first, guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
else { return nil } else { return nil }
return id return id
} }
@@ -161,8 +161,8 @@ struct OpenCardCommand: View {
// holds it and re-checked after, because one of those paths is *the lane vanished*. // holds it and re-checked after, because one of those paths is *the lane vanished*.
let lane = placeholder.laneID let lane = placeholder.laneID
let created = store.commitPlaceholder() let created = store.commitPlaceholder()
if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) { if store.snapshot.lanes.contains(where: { $0.id == lane }) {
store.select([lane], liveness: .live) store.select([lane], in: .board)
} }
if let created { open(created) } if let created { open(created) }
return return
@@ -170,7 +170,7 @@ struct OpenCardCommand: View {
if let editor = store.transient.renameEditor { if let editor = store.transient.renameEditor {
let target = editor.targetID 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() store.commitRename()
if isCard { open(target) } if isCard { open(target) }
return return
@@ -269,8 +269,8 @@ struct MoveLaneCommands: View {
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? { private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
guard let store, store.acceptsBoardMutations else { return nil } guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil } guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
let lanes = SelectionGrammar.liveLanes(in: store.snapshot) let lanes = SelectionGrammar.lanes(in: store.snapshot)
// A card id is in no lane order, so this is also the "not a lane" test. // 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 } guard let from = lanes.firstIndex(of: id) else { return nil }
let to = from + delta 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 /// **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). /// 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 /// Validation is the sole-selected-board-item rule card or lane, either kind, exactly one. A
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned /// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
/// selections" (04 The trash), which `ItemReferenceSet`'s liveness side answers directly. /// (04 The trash), which `ItemReferenceSet`'s container answers directly.
struct BoardRenameCommand: View { struct BoardRenameCommand: View {
@FocusedValue(\.boardStore) private var store @FocusedValue(\.boardStore) private var store
@@ -400,8 +400,8 @@ struct BoardRenameCommand: View {
private var renameTarget: (id: ItemID, title: String?)? { private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil } guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first, guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.liveItem(id, in: store.snapshot) let item = BoardStore.boardItem(id, in: store.snapshot)
else { return nil } else { return nil }
return (id: id, title: item.title) return (id: id, title: item.title)
} }
@@ -441,7 +441,7 @@ struct BoardStyleCommand: View {
guard let store, store.acceptsBoardMutations else { return nil } guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection let selection = store.selection
guard !selection.isEmpty else { return .board } 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 // 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. // items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: store.snapshot).ids let live = selection.resolved(against: store.snapshot).ids
@@ -498,8 +498,8 @@ struct LaneWidthCommands: View {
private var selectedLanes: [Lane] { private var selectedLanes: [Lane] {
guard let store, store.acceptsBoardMutations else { return [] } guard let store, store.acceptsBoardMutations else { return [] }
let selection = store.selection let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return [] } guard selection.container == .board, !selection.isEmpty else { return [] }
return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted } 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 /// `width` is 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
+22 -28
View File
@@ -140,7 +140,7 @@ struct BoardDropContext {
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL), DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
let laneID = proposal.laneID let laneID = proposal.laneID
else { return } 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) session.propose(nil)
} }
@@ -159,7 +159,7 @@ struct BoardDropContext {
func retargetLanes() { func retargetLanes() {
guard session.isDraggingLanes, let cursor = stripCursor() else { return } guard session.isDraggingLanes, let cursor = stripCursor() else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) 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 restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
let slot = DropSlotMath.laneSlot( let slot = DropSlotMath.laneSlot(
cursorX: cursor.x, cursorX: cursor.x,
@@ -181,14 +181,14 @@ struct BoardDropContext {
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll). /// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
func retargetCards(inLane laneID: ItemID) { func retargetCards(inLane laneID: ItemID) {
guard session.isDraggingCards, let cursor = globalCursor() else { return } 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() revalidateProposal()
return return
} }
guard let grid = registry.grids[laneID] else { return } guard let grid = registry.grids[laneID] else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) 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 heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement( let placement = MasonryPlacement(
columnCount: grid.columns, columnCount: grid.columns,
@@ -218,7 +218,7 @@ struct BoardDropContext {
/// answers `nil` there and the proposal simply **holds**, which is the hysteresis contract. /// answers `nil` there and the proposal simply **holds**, which is the hysteresis contract.
func retargetCardsFromStrip() { func retargetCardsFromStrip() {
guard session.isDraggingCards, let cursor = stripCursor() else { return } 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( let index = LaneLayoutMath.laneIndex(
atX: cursor.x, atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) }, unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
@@ -251,7 +251,7 @@ struct BoardDropContext {
guard let sourceRoot = session.sourceRoot else { return false } guard let sourceRoot = session.sourceRoot else { return false }
return TrashDrop.accepts( return TrashDrop.accepts(
kind: session.kind, kind: session.kind,
side: session.side, container: session.container,
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL), isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
operation: session.resolveOperation(destinationRoot: store.rootURL), operation: session.resolveOperation(destinationRoot: store.rootURL),
isTrashShown: store.transient.isTrashVisible, 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. /// cursor and the snapshot, so it cannot oscillate the drawn layout never feeds back into it.
func retargetFile(inLane laneID: ItemID, info: DropInfo) { func retargetFile(inLane laneID: ItemID, info: DropInfo) {
guard acceptsFileDrop(info), let cursor = globalCursor(), 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] let grid = registry.grids[laneID]
else { else {
session.proposeFile(nil) session.proposeFile(nil)
return return
} }
let rendered = lane.cards.filter { !$0.isDeleted } let rendered = lane.cards
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement( let placement = MasonryPlacement(
columnCount: grid.columns, columnCount: grid.columns,
@@ -399,7 +399,7 @@ struct BoardDropContext {
session.proposeFile(nil) session.proposeFile(nil)
return return
} }
let lanes = store.snapshot.lanes.filter { !$0.isDeleted } let lanes = store.snapshot.lanes
let index = LaneLayoutMath.laneIndex( let index = LaneLayoutMath.laneIndex(
atX: cursor.x, atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) }, unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
@@ -498,7 +498,7 @@ struct BoardDropContext {
/// set's size (DRAG-REORDER.md § The drop commits). /// 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 /// **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 /// 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). /// from one deleted by keystroke (04-interactions.md The trash, settled 2026-07-28).
/// ///
@@ -547,15 +547,16 @@ struct BoardDropContext {
case .cards: case .cards:
if target.isTrash { if target.isTrash {
// **The pointer's delete gesture** (04-interactions.md The trash, settled // **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 // 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 // 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. // promised to leave alone. A refusal cancels items return, nothing is written.
guard TrashDrop.accepts( guard TrashDrop.accepts(
kind: kind, kind: kind,
side: session.side, container: session.container,
isWithinBoard: within, isWithinBoard: within,
operation: operation, operation: operation,
isTrashShown: store.transient.isTrashVisible, isTrashShown: store.transient.isTrashVisible,
@@ -571,27 +572,20 @@ struct BoardDropContext {
cancelDrop() cancelDrop()
return false return false
} }
switch (session.side, within) { // **The trash side needs no branch of its own any more** (04-interactions.md The
case (.live, true): // 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 { if operation == .copy {
store.copyCards(Set(ids), toLane: laneID, at: target.index) store.copyCards(Set(ids), toLane: laneID, at: target.index)
} else { } else {
store.moveCards(Set(ids), toLane: laneID, at: target.index) store.moveCards(Set(ids), toLane: laneID, at: target.index)
} }
case (.live, false):
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 { } else {
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index) store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
}
case (.trashed, false):
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
} }
} }
+71 -71
View File
@@ -240,7 +240,7 @@ struct BoardView: View {
guard ClickModifier.current == .plain else { return } guard ClickModifier.current == .plain else { return }
store.clearSelection() 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. /// The lanes and the drag's shadows, plus the trash column when it is shown.
@@ -269,7 +269,7 @@ struct BoardView: View {
if isTrashVisible { if isTrashVisible {
// Trailing, always the quasi-lane has no position of its own to lose, which is // 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 // 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( TrashLaneView(
store: store, store: store,
confirmations: confirmations, confirmations: confirmations,
@@ -427,11 +427,11 @@ struct BoardView: View {
} }
} }
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** /// The lanes the strip lays out, in snapshot order every lane the board has. Deleting a lane
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane /// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
/// (a later card), and a lane that is not on the board consumes none of the window's width. /// the snapshot is a lane on the board, with no hidden state to filter for.
private var liveLanes: [Lane] { private var boardLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted } store.snapshot.lanes
} }
// MARK: - Trash // MARK: - Trash
@@ -443,16 +443,15 @@ struct BoardView: View {
store.transient.isTrashVisible store.transient.isTrashVisible
} }
/// The trash's rows as the column is showing them the shown trash "participates in the filter /// The trash's cards as the column is showing them the shown trash's cards "participate in
/// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen /// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
/// (`TrashLaneView.entries` applies the identical predicate to the identical rows). /// 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 /// 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. /// filter took away.
private var trashEntries: [TrashEntry] { private var trashCards: [ItemID] {
let filter = store.searchFilter SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
} }
// MARK: - The drag // MARK: - The drag
@@ -506,7 +505,7 @@ struct BoardView: View {
/// (`arrivingLaneUnits`). /// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat { private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard } 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 units += arrivingLaneUnits
return LaneLayoutMath.standardWidth( return LaneLayoutMath.standardWidth(
stripWidth: stripWidth, stripWidth: stripWidth,
@@ -569,7 +568,7 @@ struct BoardView: View {
private var stripSlots: [StripSlot] { private var stripSlots: [StripSlot] {
let session = appModel.dragSession let session = appModel.dragSession
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) 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 } guard let index = stripProposal else { return slots }
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) } 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)) 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 } guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection let selection = store.selection
guard selection.liveness == .live, guard selection.container == .board,
selection.ids.count == 1, selection.ids.count == 1,
let id = selection.ids.first, let id = selection.ids.first,
let target = BoardStore.liveItem(id, in: store.snapshot) let target = BoardStore.boardItem(id, in: store.snapshot)
else { return .ignored } else { return .ignored }
if target.cardID == nil { 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 /// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The
/// map). /// map).
/// ///
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and 's trash-side /// **Both stagings**, unlike the tombstone era's live-only reading: "Plain performs the same
/// role belongs to the twins, not to the bare key. A tombstoned selection is therefore inert /// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28), and the delete
/// here Put Back is a chord. /// 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, /// 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. /// 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 } guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return .ignored } guard !selection.isEmpty else { return .ignored }
store.deleteSelection() store.deleteSelection()
return .handled return .handled
} }
@@ -716,7 +717,7 @@ struct BoardView: View {
guard let origin = arrowOrigin() else { return seed(direction, mode) } guard let origin = arrowOrigin() else { return seed(direction, mode) }
return origin.isLaneDomain return origin.isLaneDomain
? laneArrow(direction, mode, from: origin.head) ? 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? { 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 /// Select All and a foreign reload leave the arrows somewhere sensible without any of them
/// having to name a cursor. /// having to name a cursor.
/// ///
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk /// The **trash's list is its cards**, top to bottom there are no lane entries to interleave
/// every trash entry in its sorted order card and lane entries alike" (04 The trash). The /// any more (03-board-ui.md § Trash: "Cards only").
/// per-kind lists are the *range*'s business, not the walk's.
/// ///
/// Both lists are the **filtered** board (04 § Search: "arrow nav read[s] it"), so the /// 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. /// 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 let selection = store.selection
guard !selection.isEmpty else { return nil } guard !selection.isEmpty else { return nil }
let isLaneDomain: Bool let isLaneDomain: Bool
let list: [ItemID] let list: [ItemID]
switch selection.liveness { switch selection.container {
case .live: case .board:
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil } guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
isLaneDomain = kind == .lane isLaneDomain = kind == .lane
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter) list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
case .trashed: case .trash:
isLaneDomain = false isLaneDomain = false
list = trashEntries.map(\.id) list = trashCards
} }
if let head = store.transient.selectionHead, list.contains(head) { 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 } 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) /// **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 { if mode == .jump, direction == .left || direction == .right {
return jumpToEndLane(direction) return jumpToEndLane(direction)
} }
guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled } guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
replaceSelection(with: first, on: .live) replaceSelection(with: first, in: .board)
return .handled return .handled
} }
@@ -790,7 +790,7 @@ struct BoardView: View {
_ direction: NavigationMath.Direction, _ direction: NavigationMath.Direction,
_ mode: ArrowMode, _ mode: ArrowMode,
from head: ItemID, from head: ItemID,
on side: Liveness in container: ItemContainer
) -> KeyPress.Result { ) -> KeyPress.Result {
switch mode { switch mode {
case .step: step(direction, from: head) case .step: step(direction, from: head)
@@ -798,7 +798,7 @@ struct BoardView: View {
case .jump: case .jump:
switch direction { switch direction {
case .left, .right: jumpToEndLane(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] let next = marqueeTargets.targets[nextID]
else { return .handled } else { return .handled }
replaceSelection(with: next.id, on: next.side) replaceSelection(with: next.id, in: next.container)
return .handled return .handled
} }
@@ -837,7 +837,7 @@ struct BoardView: View {
among: marqueeTargets.all among: marqueeTargets.all
), ),
let next = marqueeTargets.targets[nextID], let next = marqueeTargets.targets[nextID],
next.side == origin.side, next.container == origin.container,
next.kind == origin.kind next.kind == origin.kind
else { return .handled } else { return .handled }
@@ -849,13 +849,13 @@ struct BoardView: View {
from: anchor, from: anchor,
to: next.id, to: next.id,
kind: next.kind, kind: next.kind,
on: next.side, in: next.container,
in: store.snapshot, snapshot: store.snapshot,
// The span is the *filtered* board's, so a range under a search collects exactly the // 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 filter: store.searchFilter
) else { return .handled } ) 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 return .handled
} }
@@ -869,30 +869,30 @@ struct BoardView: View {
private func jumpWithinContainer( private func jumpWithinContainer(
_ direction: NavigationMath.Direction, _ direction: NavigationMath.Direction,
from head: ItemID, from head: ItemID,
on side: Liveness in container: ItemContainer
) -> KeyPress.Result { ) -> KeyPress.Result {
let container: [ItemID] let siblings: [ItemID]
var lane: ItemID? var lane: ItemID?
switch side { switch container {
case .trashed: case .trash:
container = trashEntries.map(\.id) siblings = trashCards
case .live: case .board:
guard let home = store.snapshot.lanes.first(where: { lane in 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 } }) else { return .handled }
lane = home.id lane = home.id
// The container is what the lane is *showing*: a jump to "the lane's first card" under // 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. // a search means its first surviving card, not one the filter animated out.
let filter = store.searchFilter 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] { if direction == .up, target == head, let lane, store.selection.ids == [head] {
replaceSelection(with: lane, on: .live) replaceSelection(with: lane, in: .board)
return .handled return .handled
} }
replaceSelection(with: target, on: side) replaceSelection(with: target, in: container)
return .handled 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 /// 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. /// 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 { private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
if direction == .right, isTrashVisible, let first = trashEntries.first { if direction == .right, isTrashVisible, let first = trashCards.first {
replaceSelection(with: first.id, on: .trashed) replaceSelection(with: first, in: .trash)
return .handled return .handled
} }
let lanes = liveLanes let lanes = boardLanes
let filter = store.searchFilter let filter = store.searchFilter
// A lane the search emptied is scanned past exactly as an empty one is the jump lands on // 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. // 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.reversed(), filter: filter)
: Self.firstCard(scanning: lanes, filter: filter) : Self.firstCard(scanning: lanes, filter: filter)
guard let target else { return .handled } guard let target else { return .handled }
replaceSelection(with: target, on: .live) replaceSelection(with: target, in: .board)
return .handled return .handled
} }
@@ -937,7 +937,7 @@ struct BoardView: View {
_ mode: ArrowMode, _ mode: ArrowMode,
from head: ItemID from head: ItemID
) -> KeyPress.Result { ) -> 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 } guard let index = lanes.firstIndex(of: head) else { return .handled }
switch (direction, mode) { switch (direction, mode) {
@@ -945,30 +945,30 @@ struct BoardView: View {
let next = index + (direction == .left ? -1 : 1) let next = index + (direction == .left ? -1 : 1)
guard lanes.indices.contains(next) else { return .handled } guard lanes.indices.contains(next) else { return .handled }
if mode == .step { if mode == .step {
replaceSelection(with: lanes[next], on: .live) replaceSelection(with: lanes[next], in: .board)
} else { } else {
let anchor = store.transient.selectionAnchor ?? head let anchor = store.transient.selectionAnchor ?? head
guard let ids = SelectionGrammar.range( guard let ids = SelectionGrammar.range(
from: anchor, from: anchor,
to: lanes[next], to: lanes[next],
kind: .lane, kind: .lane,
on: .live, in: .board,
in: store.snapshot snapshot: store.snapshot
) else { return .handled } ) 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): case (.left, .jump), (.right, .jump):
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled } 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): 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 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 } 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): case (.up, _), (.down, .extend):
// Nothing above the lane domain, and no vertical range within it. // Nothing above the lane domain, and no vertical range within it.
@@ -980,8 +980,8 @@ struct BoardView: View {
// MARK: Shared // MARK: Shared
/// A jump's and a plain step's shared landing: one item, both cursors on it. /// 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) { private func replaceSelection(with id: ItemID, in container: ItemContainer) {
store.select([id], liveness: side, anchor: id, head: id) 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" /// 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 filter: SearchFilter = .inactive
) -> ItemID? { ) -> ItemID? {
for lane in lanes { 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 return nil
} }
+7 -17
View File
@@ -46,20 +46,6 @@ enum DragKind: String, Codable, Sendable, Equatable {
/// stray drop into a text editor does something sane rather than nothing. /// stray drop into a text editor does something sane rather than nothing.
struct DragPayload: Codable, Sendable, Equatable { 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. /// One dragged item: its UUID, its folder on disk, and its title for the text representation.
struct Item: Codable, Sendable, Equatable { struct Item: Codable, Sendable, Equatable {
var id: String var id: String
@@ -71,7 +57,11 @@ struct DragPayload: Codable, Sendable, Equatable {
var boardRoot: String var boardRoot: String
var kind: DragKind 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`" /// 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 /// (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) 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.boardRoot = boardRoot.path
self.kind = kind self.kind = kind
self.side = Side(side) self.container = container
self.items = items self.items = items
} }
+35 -33
View File
@@ -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 /// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
/// written down at all. /// 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 /// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card
/// snapshot it is `TrashModel.entries` derived from it so there is nothing to put in a /// folders directly same shape as a lane's children, no `index.md` of its own"
/// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`). /// (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 { 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 case strip
/// That lane's **masonry**: the index is a position in its logical card order /// That lane's **masonry**: the index is a position in its logical card order
/// (DRAG-REORDER.md § The card masonry). /// (DRAG-REORDER.md § The card masonry).
case lane(ItemID) 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. /// (04-interactions.md The trash, settled 2026-07-28). The index is always the topmost row.
case trash case trash
} }
@@ -57,8 +58,9 @@ enum TrashDrop {
/// The row the shadow takes, always: **the topmost**. /// The row the shadow takes, always: **the topmost**.
/// ///
/// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted` /// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank
/// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop /// 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 /// 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 /// drop in this app makes while being the only proposal on the board the *pointer* does not
/// choose. /// choose.
@@ -70,7 +72,7 @@ enum TrashDrop {
/// - **Lanes are not deliverable this way** "a lane drag proposes only lane slots". (The strip's /// - **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 /// 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.) /// 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. /// dropping it back where it came from writes nothing.
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card /// - **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 /// 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. /// - **The mutating-gesture rule**, like every other write the pointer can start.
static func accepts( static func accepts(
kind: DragKind?, kind: DragKind?,
side: Liveness, container: ItemContainer,
isWithinBoard: Bool, isWithinBoard: Bool,
operation: TransferOperation, operation: TransferOperation,
isTrashShown: Bool, isTrashShown: Bool,
acceptsMutations: Bool acceptsMutations: Bool
) -> Bool { ) -> Bool {
guard isTrashShown, acceptsMutations else { return false } 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 return operation == .move
} }
} }
@@ -207,14 +209,14 @@ enum DragLocality {
/// - **Lane drags never copy within their board.** is simply ignored there: the drag stays a /// - **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 /// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
/// its home is the clipboard ( Clipboard, Lane paste). /// 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 /// - **A trash card's drag is the restore** ( The trash). Within its own board it is "an
/// restore a move, no badge; across boards the default is the live copy that leaves the /// ordinary move to the drop position"; across boards the default is the copy that leaves the
/// tombstoned original in place, exactly as C out of the trash behaves. forces the true /// original in the source trash, and "-drag forces the true cross-board restore-move". Both
/// restore-move either way, and forces the live copy either way ("C, -drag, and the /// fall out of the ordinary locality rule with no trash clause at all, which is the pivot's
/// cross-board drag default always yield *live* copies"). /// whole point.
static func operation( static func operation(
kind: DragKind, kind: DragKind,
side: Liveness, container: ItemContainer,
isWithinBoard: Bool, isWithinBoard: Bool,
modifiers: NSEvent.ModifierFlags modifiers: NSEvent.ModifierFlags
) -> TransferOperation { ) -> TransferOperation {
@@ -227,7 +229,7 @@ enum DragLocality {
if forcesMove { return .move } if forcesMove { return .move }
if forcesCopy { return .copy } 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 return isWithinBoard ? .move : .copy
} }
} }
@@ -272,9 +274,9 @@ final class DragSession {
/// means here rather than a separate flag. /// means here rather than a separate flag.
private(set) var kind: DragKind? private(set) var kind: DragKind?
/// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed` /// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`,
/// side, and that is the whole of what makes it one (04-interactions.md The trash). /// and that is the whole of what makes it a restore (04-interactions.md The trash).
private(set) var side: Liveness = .live private(set) var container: ItemContainer = .board
/// The dragged items in **flatten order** the order they will land in. /// The dragged items in **flatten order** the order they will land in.
private(set) var members: [ItemID] = [] 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`. /// 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 /// Only the source board hides anything, and only for a board-side session: a trash card's drag
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a /// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
/// card to it. /// card to it.
/// ///
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md § /// **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 /// 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. /// the arrangement on screen is the one the release proposed and stays still until the echo.
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> { func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
guard isActive, side == .live, let sourceRoot, guard isActive, container == .board, let sourceRoot,
DragLocality.isSameBoard(root, sourceRoot) DragLocality.isSameBoard(root, sourceRoot)
else { return [] } else { return [] }
return memberSet return memberSet
@@ -473,27 +475,27 @@ final class DragSession {
// MARK: Lifecycle // MARK: Lifecycle
/// Begins a card session live faces or trash rows. /// Begins a card session board faces or trash cards.
/// ///
/// - Parameters: /// - Parameters:
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's /// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
/// own sorted order for a trash-row drag). /// trash's own order for a trash-card drag).
/// - heights: their measured heights, captured **before** the pickup transition starts. /// - heights: their measured heights, captured **before** the pickup transition starts.
func beginCards( func beginCards(
_ members: [ItemID], _ members: [ItemID],
folders: [URL], folders: [URL],
heights: [CGFloat], heights: [CGFloat],
side: Liveness, container: ItemContainer,
source: BoardStore 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 cardHeights = heights
laneUnits = [] laneUnits = []
} }
/// Begins a lane session. /// Begins a lane session.
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) { 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 laneUnits = units
cardHeights = [] cardHeights = []
} }
@@ -502,7 +504,7 @@ final class DragSession {
kind: DragKind, kind: DragKind,
members: [ItemID], members: [ItemID],
folders: [URL], folders: [URL],
side: Liveness, container: ItemContainer,
source: BoardStore source: BoardStore
) { ) {
endHold() endHold()
@@ -510,14 +512,14 @@ final class DragSession {
self.members = members self.members = members
self.memberSet = Set(members) self.memberSet = Set(members)
self.folders = folders self.folders = folders
self.side = side self.container = container
self.sourceStore = source self.sourceStore = source
self.sourceRoot = source.rootURL self.sourceRoot = source.rootURL
self.proposal = nil self.proposal = nil
self.operation = .move self.operation = .move
// The reload-resolved drag set: vanished members leave it silently, which is what // 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. // `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() armWatchdog()
} }
@@ -538,7 +540,7 @@ final class DragSession {
guard let kind, let sourceRoot else { return operation } guard let kind, let sourceRoot else { return operation }
let resolved = DragLocality.operation( let resolved = DragLocality.operation(
kind: kind, kind: kind,
side: side, container: container,
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot), isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
modifiers: NSEvent.modifierFlags modifiers: NSEvent.modifierFlags
) )
+31 -31
View File
@@ -126,7 +126,7 @@ struct LaneView: View {
// the drag off until the pointer actually moves, so a click is never a drag. // the drag off until the pointer actually moves, so a click is never a drag.
.onTapGesture { .onTapGesture {
store.click( store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live), SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current, modifier: .current,
togglesOnRepeat: true togglesOnRepeat: true
) )
@@ -232,17 +232,17 @@ struct LaneView: View {
/// alone standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept). /// alone standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
/// Right-clicking something outside the selection acts on what was clicked. /// Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget { 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([lane.id])
} }
return .items(store.selection.ids) return .items(store.selection.ids)
} }
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>` /// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// 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). /// on the live side).
private var targetIDs: Set<ItemID> { private var targetIDs: Set<ItemID> {
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 [lane.id]
} }
return store.selection.ids return store.selection.ids
@@ -352,20 +352,20 @@ struct LaneView: View {
private func startLaneDrag() -> NSItemProvider { private func startLaneDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(lane.id) && selection.ids.contains(lane.id)
&& selection.ids.count > 1 && selection.ids.count > 1
? selection.ids ? selection.ids
: [lane.id] : [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() } guard !members.isEmpty else { return NSItemProvider() }
let root = store.rootURL let root = store.rootURL
let payload = DragPayload( let payload = DragPayload(
boardRoot: root, boardRoot: root,
kind: .lanes, kind: .lanes,
side: .live, container: .board,
items: members.map { items: members.map {
DragPayload.Item( DragPayload.Item(
id: $0.id.rawValue, id: $0.id.rawValue,
@@ -405,7 +405,7 @@ struct LaneView: View {
private var draggedLaneCount: Int { private var draggedLaneCount: Int {
let selection = store.selection 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 return selection.ids.count
} }
@@ -544,7 +544,7 @@ struct LaneView: View {
// shares (04-interactions.md § Selection), and the modifier grammar on top of it. // shares (04-interactions.md § Selection), and the modifier grammar on top of it.
.onTapGesture { .onTapGesture {
store.click( store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live), SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current, modifier: .current,
togglesOnRepeat: true togglesOnRepeat: true
) )
@@ -552,7 +552,7 @@ struct LaneView: View {
// The rubber band's first surface "click-drag rubber-bands across lanes". Simultaneous // 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 // 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`). // 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 // The same menu the header carries "one menu, invoked on the header or lane empty
// space alike" (03-board-ui.md § Lane, settled). // space alike" (03-board-ui.md § Lane, settled).
.contextMenu { laneMenu } .contextMenu { laneMenu }
@@ -688,7 +688,7 @@ struct LaneView: View {
renaming: ItemID? renaming: ItemID?
) -> [Card] { ) -> [Card] {
cards.filter { card in 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 return filter.matches(card) || card.id == renaming
} }
} }
@@ -696,7 +696,7 @@ struct LaneView: View {
// MARK: - Selection // MARK: - Selection
private var isSelected: Bool { 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 /// 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 // Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel. // `SelectionGrammar`'s, reached through the store's one funnel.
.onTapGesture { .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). // "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) drops.registry.update(height: height, for: card.id)
} }
.onDisappear { drops.registry.removeHeight(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 } .contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) { .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents) 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). /// 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** /// **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. /// 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 /// 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() } guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot let snapshot = store.snapshot
let selection = store.selection let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(card.id) && selection.ids.contains(card.id)
&& selection.ids.count > 1 && selection.ids.count > 1
? selection.ids ? 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 // Flatten order, and the lane each member currently lives in the folder path's middle
// component. // component.
var lanesByCard: [ItemID: ItemID] = [:] var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String?] = [:] var titles: [ItemID: String] = [:]
for lane in snapshot.lanes where !lane.isDeleted { for lane in snapshot.lanes {
for member in lane.cards where !member.isDeleted && ids.contains(member.id) { for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value 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() } guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL let root = store.rootURL
let payload = DragPayload( let payload = DragPayload(
boardRoot: root, boardRoot: root,
kind: .cards, kind: .cards,
side: .live, container: .board,
items: ordered.compactMap { id in items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil } guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item( return DragPayload.Item(
@@ -1001,7 +1001,7 @@ private struct CardFaceView: View {
.appendingPathComponent(laneID.rawValue, isDirectory: true) .appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true) .appendingPathComponent(id.rawValue, isDirectory: true)
.path, .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 // replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion). // span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight }, heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
side: .live, container: .board,
source: store source: store
) )
return payload.itemProvider() 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 /// 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). /// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View { 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) ? max(1, store.selection.ids.count)
: 1 : 1
return ZStack { return ZStack {
@@ -1098,17 +1098,17 @@ private struct CardFaceView: View {
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete /// 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. /// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget { 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([card.id])
} }
return .items(store.selection.ids) return .items(store.selection.ids)
} }
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>` /// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// 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). /// on the live side).
private var targetIDs: Set<ItemID> { private var targetIDs: Set<ItemID> {
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 [card.id]
} }
return store.selection.ids return store.selection.ids
@@ -1205,7 +1205,7 @@ private struct CardFaceView: View {
// MARK: - Selection and rename plumbing // MARK: - Selection and rename plumbing
private var isSelected: Bool { 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 /// 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 /// 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 /// 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. /// next reload swept it away.
@discardableResult @discardableResult
private func commit() -> ItemID? { private func commit() -> ItemID? {
let lane = store.transient.newCardPlaceholder?.laneID let lane = store.transient.newCardPlaceholder?.laneID
let id = store.commitPlaceholder() let id = store.commitPlaceholder()
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) { if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
store.select([lane], liveness: .live) store.select([lane], in: .board)
} }
return id return id
} }
+6 -6
View File
@@ -30,7 +30,7 @@ final class MarqueeSession {
private(set) var current: CGPoint = .zero private(set) var current: CGPoint = .zero
/// The side of the live/trash boundary this band selects on, frozen at `begin`. /// 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 /// 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 /// 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 origin = point
current = point current = point
self.side = side self.container = container
} }
func update(to point: CGPoint) { 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 /// 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. /// 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 /// **Lanes are never registered.** The band selects cards board cards or trash cards; a lane has
/// has no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands /// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across
/// across lanes" made structural rather than filtered. /// lanes" made structural rather than filtered.
/// ///
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs /// 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 /// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
+5 -5
View File
@@ -41,7 +41,7 @@ enum NewCardTarget {
/// the two can never disagree. /// the two can never disagree.
/// ///
/// - Parameters: /// - 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 /// "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 /// 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'"). /// disk-lane never leaks in as 'the selected card's lane'").
@@ -53,7 +53,7 @@ enum NewCardTarget {
lastActiveLaneID: ItemID?, lastActiveLaneID: ItemID?,
snapshot: BoardModel snapshot: BoardModel
) -> Resolution? { ) -> Resolution? {
let lanes = snapshot.lanes.filter { !$0.isDeleted } let lanes = snapshot.lanes
guard !lanes.isEmpty else { return nil } guard !lanes.isEmpty else { return nil }
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) { 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 /// which falls out of never looking at the trashed side at all), and a stale one whose ids name
/// nothing the board renders. /// nothing the board renders.
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? { 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 // 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 // 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 of the two branches ever fires within a walk; a sole selection is simply the degenerate
// one-member case of the same rule. // one-member case of the same rule.
var anchor: Resolution? 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 // A selected lane: creation appends at its bottom, Return consistency; paste lands after
// the lane itself. // the lane itself.
if selection.ids.contains(lane.id) { if selection.ids.contains(lane.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: nil) anchor = Resolution(laneID: lane.id, anchorCardID: nil)
} }
// A selected card: its lane, immediately after it paste-anchor consistency. // 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) anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
} }
} }
+3 -3
View File
@@ -50,10 +50,10 @@ enum PasteTarget {
lastActiveLaneID: lastActiveLaneID, lastActiveLaneID: lastActiveLaneID,
snapshot: snapshot 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 } 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 // `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. // 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 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 /// 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. /// 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 { 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), guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
let position = lanes.firstIndex(where: { $0.id == anchor.laneID }) let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
else { else {
+9 -8
View File
@@ -47,24 +47,25 @@ struct MarqueeControl {
/// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than /// 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 /// by gesture priority is what keeps the two from fighting, and it stays correct as the
/// masonry reflows. /// masonry reflows.
/// - **The side is fixed at the origin** 04-interactions.md The trash's rule, stored in the /// - **The container is fixed at the origin** 04-interactions.md The trash's rule ("the
/// session so a band dragged across the boundary keeps its meaning. /// 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 /// - **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 /// band, so the selection follows the cursor both ways. An empty band clears rather than
/// leaving the last non-empty one standing. /// leaving the last non-empty one standing.
/// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The /// - **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 /// lock's scope), and no `isEditingInline` guard either a click-away mid-rename already
/// commits through the field's own focus loss. /// 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)) DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace))
.onChanged { value in .onChanged { value in
if !session.isActive { if !session.isActive {
guard !registry.contains(value.startLocation) else { return } 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) session.update(to: value.location)
guard let rect = session.rect else { return } 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 { if ids.isEmpty {
store.clearSelection() store.clearSelection()
} else { } else {
@@ -74,7 +75,7 @@ struct MarqueeControl {
// Both are spelled out rather than defaulted, because `select`'s sole-member // 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 // default would otherwise pick one up the moment a band happened to sweep
// exactly one card. // 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() } .onEnded { _ in session.end() }
@@ -111,7 +112,7 @@ extension View {
func marqueeTarget( func marqueeTarget(
_ id: ItemID, _ id: ItemID,
kind: SelectionKind, kind: SelectionKind,
side: Liveness, container: ItemContainer,
in registry: MarqueeTargetRegistry in registry: MarqueeTargetRegistry
) -> some View { ) -> some View {
// The space name is read here, on the main actor, rather than inside the measuring closure: // 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 return onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .named(space)) proxy.frame(in: .named(space))
} action: { frame in } 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) } .onDisappear { registry.remove(id) }
} }
+61 -46
View File
@@ -21,8 +21,8 @@ final class TrashConfirmations {
/// The **phrasing is captured when the request is made**, not recomputed at render time: the /// 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 /// 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* /// 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 /// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on a
/// item that has since gone `BoardWriter.purgeItem` treats an absent folder as success. /// card that has since gone the writer treats an absent folder as success.
private(set) var pending: Pending? private(set) var pending: Pending?
struct Pending: Identifiable, Equatable { struct Pending: Identifiable, Equatable {
@@ -30,20 +30,46 @@ final class TrashConfirmations {
let prompt: TrashModel.PurgePrompt let prompt: TrashModel.PurgePrompt
let action: Action let action: Action
/// What the confirmation is standing in front of. Two cases, because the two commands have /// What the confirmation is standing in front of. Three cases, because the three commands
/// genuinely different scopes: one names a selection, the other names the whole trash and /// have genuinely different scopes and two different writes: the trash's own staged Delete,
/// re-derives its targets at the moment it runs. /// 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 { enum Action: Equatable {
case deleteTrashCards(Set<ItemID>)
case purge(Set<ItemID>) case purge(Set<ItemID>)
case emptyTrash 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. /// 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 /// 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 /// 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<ItemID>, in store: BoardStore) { func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else { guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids) store.deleteImmediately(ids)
@@ -51,7 +77,8 @@ final class TrashConfirmations {
} }
guard let prompt = TrashModel.purgePrompt( guard let prompt = TrashModel.purgePrompt(
for: ids, for: ids,
in: store.snapshot, in: store.selection.container,
snapshot: store.snapshot,
unrecoverable: true unrecoverable: true
) else { return } ) else { return }
pending = Pending(prompt: prompt, action: .purge(ids)) pending = Pending(prompt: prompt, action: .purge(ids))
@@ -73,6 +100,7 @@ final class TrashConfirmations {
guard let pending else { return } guard let pending else { return }
self.pending = nil self.pending = nil
switch pending.action { switch pending.action {
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
case let .purge(ids): store.deleteImmediately(ids) case let .purge(ids): store.deleteImmediately(ids)
case .emptyTrash: store.emptyTrash() 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 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 /// **Put Back is retired with the tombstone model** (04-interactions.md The map, resettled
/// one of them: "AppKit routes a shared key equivalent to the enabled item" (04-interactions.md /// 2026-07-28): "File Delete is the chord's only owner no twin menu items, no shared-equivalent
/// The map, which names Finder's own Move to Trash/Put Back pair as the precedent). The two /// routing". The chord therefore has exactly one owner, its validation is one predicate
/// predicates are mirror images over the selection's liveness side /// (`TrashModel.canDelete`), and which write it performs is decided by the selection's *container*
/// (`TrashModel.canDelete`/`canActOnTrash`), so they can neither both enable nor both disable while /// inside the store rather than by AppKit picking whichever of two items happened to be enabled.
/// something is selected and a selection can never be mixed, because
/// `ItemReferenceSet.resolved(against:)` treats a liveness flip as a vanish.
///
/// **Both titles stay stable** (titles-are-API): each remaps independently through the system
/// mechanism, and remapping one never moves the other's role.
struct TrashCommands: View { struct TrashCommands: View {
@FocusedValue(\.boardStore) private var store @FocusedValue(\.boardStore) private var store
@@ -119,24 +142,18 @@ struct TrashCommands: View {
var body: some View { var body: some View {
Button("Delete") { Button("Delete") {
store?.deleteSelection() guard let store, let confirmations else { return }
confirmations.requestDelete(in: store)
} }
.keyboardShortcut(.delete, modifiers: .command) .keyboardShortcut(.delete, modifiers: .command)
.disabled(!canDelete) .disabled(!canDelete || confirmations == nil)
Button("Put Back") {
guard let store else { return }
store.putBack(store.selection.ids)
}
.keyboardShortcut(.delete, modifiers: .command)
.disabled(!canActOnTrash)
Button("Delete Immediately") { Button("Delete Immediately") {
guard let store, let confirmations else { return } guard let store, let confirmations else { return }
confirmations.requestPurge(of: store.selection.ids, in: store) confirmations.requestPurge(of: store.selection.ids, in: store)
} }
.keyboardShortcut(.delete, modifiers: [.option, .command]) .keyboardShortcut(.delete, modifiers: [.option, .command])
.disabled(!canActOnTrash || confirmations == nil) .disabled(!canDeleteImmediately || confirmations == nil)
Button("Empty Trash…") { Button("Empty Trash…") {
guard let store, let confirmations else { return } guard let store, let confirmations else { return }
@@ -146,29 +163,29 @@ struct TrashCommands: View {
.disabled(!canEmptyTrash) .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 { private var canDelete: Bool {
guard let store, store.acceptsBoardMutations else { return false } guard let store, store.acceptsBoardMutations else { return false }
return TrashModel.canDelete(selection: store.selection, in: store.snapshot) return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
} }
/// A tombstoned, non-empty selection Put Back's condition and Delete Immediately's alike, the /// A **card** selection, in either container "skips the trash from anywhere"
/// two being the trash side's pair (04-interactions.md The trash: "menu validation stays /// (11-command-nexus.md).
/// binary"). private var canDeleteImmediately: Bool {
private var canActOnTrash: Bool {
guard let store, store.acceptsBoardMutations else { return false } 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 /// **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 § /// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu
/// Trash: "a bulk command about the trash itself never silently narrows to the visible subset"). /// 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". /// invisible to every gesture".
private var canEmptyTrash: Bool { private var canEmptyTrash: Bool {
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false } 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. /// 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 /// **Hiding drops a trash selection** (04-interactions.md The trash: "hiding it clears a
/// "nothing invisible may stay selected" is the invariant every item-referencing set in this app /// selection of trash cards nothing invisible stays selected, so the toggle-off drops the
/// already obeys (`ItemReferenceSet`); leaving one behind would also leave Put Back and Delete /// selection rather than leave commands enabled against rows nobody can see"). A *board*
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out /// selection is untouched the board it names is still right there.
/// outright ("hidden, it is invisible to every gesture"). A *live* 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 /// 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 /// 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 // 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 // on its own which 03 § Motion rules out ("the selection highlight rides whatever
// transaction is active"). // transaction is active").
if !shown, selection.liveness == .trashed { if !shown, selection.container == .trash {
clearSelection() clearSelection()
} }
} }
+115 -171
View File
@@ -4,63 +4,55 @@ import SwiftUI
// MARK: - TrashLaneView // MARK: - TrashLaneView
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in /// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
/// (03-board-ui.md § Trash). /// (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 /// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column
/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more. /// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use
/// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery: /// 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 /// - 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, /// 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); /// 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 /// - 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 /// from the drop proposal's slot list by construction, since `BoardView` builds that from the
/// snapshot's live lanes; /// snapshot's 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;
/// - it has **no new-card button**: nothing is created in the trash. /// - 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 /// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md The trash) the
/// 2026-07-28) "the drag becomes the pointer's delete gesture; release tombstones the dragged /// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
/// card(s), exactly the tombstone". So the column does declare an `onDrop` /// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this** /// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would /// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
/// be a transfer-and-delete compound), is not (copying into the trash is not a thing), and hidden /// row**, because every arrival mints a rank above the current top.
/// 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`).
/// ///
/// It diverges from every other drop on the board in one way, and the sort is what makes the /// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by /// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it
/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly /// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an
/// where the shadow shows. /// 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) /// **Finder file drops stay inert** "Finder file drops on trash cards are inert" ( The trash)
/// and now say so directly: the delegate clears the file highlight over the column rather than /// and say so directly: the delegate clears the file highlight over the column.
/// relying on the strip resolving to no lane.
/// ///
/// ### No editing in the trash /// ### No editing in the trash
/// ///
/// "Tombstoned cards don't open double-click does nothing beyond selection; Put Back or drag out /// "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: the /// first." There is deliberately no double-tap recogniser, no rename editor, and no Style row.
/// trash is for restoring or purging, not working.
/// ///
/// ### 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 /// The column renders the materialized trash correctly and its selection, drag, drop, filter and
/// any lane") arrived with m5 and is one line see `entries`, which every other surface here reads /// context menu all speak the new container vocabulary but its *visual* treatment is still the
/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the /// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with /// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The /// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
/// **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`).
struct TrashLaneView: View { struct TrashLaneView: View {
let store: BoardStore let store: BoardStore
@@ -71,13 +63,13 @@ struct TrashLaneView: View {
/// does. /// does.
let confirmations: TrashConfirmations let confirmations: TrashConfirmations
/// The board window's drop machinery a row's drag is an ordinary card session on the /// The board window's drop machinery a card's drag is an ordinary card session in the
/// **trashed** side (`DragSession`, 04-interactions.md The trash). /// **trash** container (`DragSession`, 04-interactions.md The trash).
let drops: BoardDropContext let drops: BoardDropContext
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed** /// The strip's rubber band. The column's empty space is its third surface, in the **trash**
/// side "a rubber-band stays on the side of the boundary it started on" (04-interactions.md /// container "the rubber band stays on the side it started on" (04-interactions.md The
/// The trash) and every row registers its frame into the same registry. /// trash) and every row registers its frame into the same registry.
let marquee: MarqueeControl let marquee: MarqueeControl
/// Reduce Motion, for the row transition below 10-accessibility.md names the trash /// 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 private let rowSpacing: CGFloat = 6
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title /// 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 /// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
/// measured, so the shadow is drawn at the nominal single-line plate `LaneDropRegistry`'s own /// the nominal single-line plate `LaneDropRegistry`'s own answer to the same question, in this
/// answer to the same question, in this column's smaller idiom. /// column's smaller idiom.
private let nominalRowHeight: CGFloat = 32 private let nominalRowHeight: CGFloat = 32
var body: some View { var body: some View {
@@ -113,16 +105,16 @@ struct TrashLaneView: View {
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops)) .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 /// **Shown, the trash's cards "participate in the filter exactly like any other card"**
/// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards` /// (03-board-ui.md § Trash "the point of the pivot"), so the search predicate narrows this
/// card rows and lane rows alike, each by its own title and body (`SearchFilter`) and the /// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
/// count badge follows for free, because it reads this same value. Hidden, the column renders /// free because it reads this same value. Hidden, the column renders nothing and registers
/// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all. /// nothing, so "hidden trash is invisible to search" needs no code at all.
private var entries: [TrashEntry] { private var cards: [Card] {
let filter = store.searchFilter 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 // 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 /// keeps the arrangement the release proposed on screen for that round trip, exactly as every
/// other container's does (`CommittedHold`). /// other container's does (`CommittedHold`).
private var slots: [TrashSlot] { private var slots: [TrashSlot] {
var result = entries.map(TrashSlot.entry) var result = cards.map(TrashSlot.card)
guard let proposal else { return result } guard let proposal else { return result }
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow) let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
result.insert(contentsOf: run, at: min(max(0, proposal), result.count)) result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
@@ -186,11 +178,10 @@ struct TrashLaneView: View {
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
/// The entry count the same collection the body renders, so the badge cannot disagree with /// The card count the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second /// what is on screen (`LaneView.countBadge`'s rule).
/// change here).
private var countBadge: some View { private var countBadge: some View {
Text("\(entries.count)") Text("\(cards.count)")
.font(.caption) .font(.caption)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -210,7 +201,7 @@ struct TrashLaneView: View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
scrollableRows scrollableRows
.onChange(of: store.transient.selectionHead) { _, head in .onChange(of: store.transient.selectionHead) { _, head in
guard let head, entries.contains(where: { $0.id == head }) else { return } guard let head, cards.contains(where: { $0.id == head }) else { return }
proxy.scrollTo(TrashSlot.identity(of: head)) proxy.scrollTo(TrashSlot.identity(of: head))
} }
} }
@@ -227,10 +218,10 @@ struct TrashLaneView: View {
ForEach(slots) { slot in ForEach(slots) { slot in
Group { Group {
switch slot { switch slot {
case let .entry(entry): case let .card(card):
TrashEntryRow( TrashCardRow(
store: store, store: store,
entry: entry, card: card,
confirmations: confirmations, confirmations: confirmations,
drops: drops, drops: drops,
registry: marquee.registry registry: marquee.registry
@@ -243,10 +234,10 @@ struct TrashLaneView: View {
.frame(height: nominalRowHeight) .frame(height: nominalRowHeight)
} }
} }
// A row is a tombstoned item, so it arrives and leaves in the card's dialect // A row is a card, so it arrives and leaves in the card's dialect a delete
// a delete files one in, a Put Back or a purge takes one out, and both halves of // files one in, a restore or a purge takes one out, and both halves of that pair
// that pair should read alike from either side of the strip. The transaction is // should read alike from either side of the strip. The transaction is the
// the reload's, like the lanes' (`Motion.reloadAnimates`). // reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion)) .transition(Motion.cardTransition(reduced: reduceMotion))
// The scroll target `LaneView`'s rule, and outermost for its reason. // The scroll target `LaneView`'s rule, and outermost for its reason.
.id(slot.id) .id(slot.id)
@@ -271,7 +262,7 @@ struct TrashLaneView: View {
// as the board background allows on the live side a drag begun on a row instead is that // as the board background allows on the live side a drag begun on a row instead is that
// row's drag-out, and the begin guard makes that geometric rather than a matter of // row's drag-out, and the begin guard makes that geometric rather than a matter of
// gesture priority (`MarqueeControl`). // gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(side: .trashed)) .simultaneousGesture(marquee.gesture(in: .trash))
} }
} }
} }
@@ -289,13 +280,10 @@ private struct TrashRowPlate: View {
let symbol: String let symbol: String
/// The title as written, or `nil` for an untitled item "Untitled" is a rendering, never a value /// The title as written, or `nil` for an untitled card "Untitled" is a rendering, never a value
/// (03-board-ui.md § Card face). /// (03-board-ui.md § Card face).
let title: String? let title: String?
/// A lane entry's returning-card count, and nothing else takes a second line.
let subtitle: String?
var isSelected: Bool = false var isSelected: Bool = false
private let cornerRadius: CGFloat = 6 private let cornerRadius: CGFloat = 6
@@ -305,17 +293,10 @@ private struct TrashRowPlate: View {
Image(systemName: symbol) Image(systemName: symbol)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.imageScale(.small) .imageScale(.small)
VStack(alignment: .leading, spacing: 2) {
Text(title ?? "Untitled") Text(title ?? "Untitled")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
@@ -336,22 +317,22 @@ private struct TrashRowPlate: View {
/// echo reload reads as a *swap* or as a removal and an insertion. /// echo reload reads as a *swap* or as a removal and an insertion.
private enum TrashSlot: Identifiable { private enum TrashSlot: Identifiable {
/// A tombstone the snapshot already holds. /// A card the snapshot's trash already holds.
case entry(TrashEntry) case card(Card)
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md The trash). /// One of the drag's N shadows, holding the topmost rows open (04-interactions.md The trash).
case shadow(index: Int) case shadow(index: Int)
var id: String { var id: String {
switch self { switch self {
case let .entry(entry): Self.identity(of: entry.id) case let .card(card): Self.identity(of: card.id)
// Constant per position in the run, so a run that grows or shrinks animates as slots rather // Constant per position in the run, so a run that grows or shrinks animates as slots rather
// than blinking (`LaneSlot`'s rule). // than blinking (`LaneSlot`'s rule).
case let .shadow(index): "shadow:\(index)" case let .shadow(index): "shadow:\(index)"
} }
} }
/// An entry slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the /// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
/// scroll reader is looking for (`LaneSlot.identity(of:)`). /// scroll reader is looking for (`LaneSlot.identity(of:)`).
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" } static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
} }
@@ -381,145 +362,113 @@ private struct DiagonalHatch: Shape {
// MARK: - Rows // MARK: - Rows
/// One trash row: a compact, dimmed plate carrying the item's symbol and title and, for a lane /// One trash row: a compact, dimmed plate carrying the card's symbol and title.
/// entry, the count of cards Put Back would return with it.
/// ///
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it /// **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 /// 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 /// 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. /// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's see
private struct TrashEntryRow: View { /// `TrashLaneView`.)
private struct TrashCardRow: View {
let store: BoardStore let store: BoardStore
let entry: TrashEntry let card: Card
let confirmations: TrashConfirmations let confirmations: TrashConfirmations
let drops: BoardDropContext let drops: BoardDropContext
/// Where the rubber band looks up what it is sweeping the card face's rule, on the trashed /// Where the rubber band looks up what it is sweeping the card face's rule, in the trash
/// side (`View.marqueeTarget`). /// container (`View.marqueeTarget`).
let registry: MarqueeTargetRegistry 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 { var body: some View {
if entry.isLaneEntry {
plate
} else {
plate.onDrag(startRowDrag, preview: { dragReplica }) plate.onDrag(startRowDrag, preview: { dragReplica })
} }
}
private var plate: some View { private var plate: some View {
rowFace rowFace
// The row being dragged out dims in place the source stays visible in the trash, // 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. // because a restore is not a removal until the write lands.
.opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1) .opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
// The deferred cut wears the same dim wherever it lands, so the treatment is stated for // The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
// every surface a `pendingCut` could name rather than for two of the three. In practice // "X works cut in the trash, paste into a lane is the keyboard-native restore"
// it never fires here: X is disabled on tombstoned selections (04-interactions.md The // (04-interactions.md The trash, resettled 2026-07-28).
// trash), and a pending cut is homogeneous by liveness a reload that tombstones a cut .cutTreatment(of: card.id, in: store)
// card *ejects* it from the set rather than moving it to the other side.
.cutTreatment(of: entry.id, in: store)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { select() } .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 } .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 /// crucially no marquee registration, which is what makes it safe for the drag replica to render
/// (see `TrashRowPlate`). /// (see `TrashRowPlate`).
private var rowFace: some View { private var rowFace: some View {
TrashRowPlate( TrashRowPlate(
symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback), symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
title: entry.title, title: card.title.value,
subtitle: laneSubtitle,
isSelected: isSelected 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 // MARK: - Selection
private var isSelected: Bool { 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`). /// 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 /// The container travels with the click, and that is what keeps the one remaining homogeneity
/// axis true: a -click across the card/lane-entry boundary replaces rather than mixing, and a /// boundary true: a -click across it replaces rather than mixing (04-interactions.md The
/// -range walks only its own kind's rows (04-interactions.md The trash). No `togglesOnRepeat` /// trash). There is no kind axis inside the trash any more lanes are never trashed. No
/// click-again-to-unselect is the lane's behaviour, not a row's. /// `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. /// **A double click is two of these and nothing more**: no editor, no card window, no timer.
private func select() { private func select() {
store.click( store.click(
SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed), SelectionTarget(id: card.id, kind: .card, container: .trash),
modifier: .current modifier: .current
) )
} }
// MARK: - Drag out // MARK: - Drag out
/// Begins the row's drag out of the trash an ordinary **card session on the trashed side**, /// 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 rather than a move (04-interactions.md The /// which is the whole of what makes it a restore (04-interactions.md The trash;
/// trash; `DragLocality.operation`). /// `DragLocality.operation`).
/// ///
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the /// 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 /// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives /// every other card drop uses, committed by the same `moveCards`.
/// 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.
/// ///
/// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order the /// **Multi-drag carries the whole trash selection**, in the column's own order the order the
/// order the rows are drawn in, which is the only relative order a set of tombstones has. /// 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 /// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture. /// mutating gesture.
private func startRowDrag() -> NSItemProvider { private func startRowDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .trashed let ids: Set<ItemID> = selection.container == .trash
&& selection.ids.contains(entry.id) && selection.ids.contains(card.id)
&& selection.ids.count > 1 && selection.ids.count > 1
? selection.ids ? selection.ids
: [entry.id] : [card.id]
// Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed let rows = store.snapshot.trash.filter { ids.contains($0.id) }
// 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)
}
guard !rows.isEmpty else { return NSItemProvider() } guard !rows.isEmpty else { return NSItemProvider() }
let root = store.rootURL let root = store.rootURL
let payload = DragPayload( let payload = DragPayload(
boardRoot: root, boardRoot: root,
kind: .cards, kind: .cards,
side: .trashed, container: .trash,
items: rows.map { items: rows.map {
DragPayload.Item( DragPayload.Item(
id: $0.id.rawValue, id: $0.id.rawValue,
folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path, folder: ItemPath.trashCard($0.id).folder(under: root).path,
title: $0.title title: $0.title.value
) )
} }
) )
@@ -527,7 +476,7 @@ private struct TrashEntryRow: View {
rows.map(\.id), rows.map(\.id),
folders: payload.folders, folders: payload.folders,
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight }, heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
side: .trashed, container: .trash,
source: store source: store
) )
return payload.itemProvider() 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 /// 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. /// multi-drag the card replica's treatment, at a trash row's size.
private var dragReplica: some View { 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) ? max(1, store.selection.ids.count)
: 1 : 1
return ZStack { return ZStack {
@@ -549,22 +498,18 @@ private struct TrashEntryRow: View {
.padding(12) .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 /// Delete and Reveal in Finder the two rows 11-command-nexus.md gives a trash card, and no
/// trash entry, and no others. /// 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 /// 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 /// enabled on trash selections", read-only lock included inspecting a folder before a purge is
/// purge is exactly the errand it exists for. /// exactly the errand it exists for.
@ViewBuilder @ViewBuilder
private var menu: some View { private var menu: some View {
Button("Put Back") { Button("Delete") {
store.putBack(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") {
confirmations.requestPurge(of: targetIDs, in: store) confirmations.requestPurge(of: targetIDs, in: store)
} }
.disabled(!store.acceptsBoardMutations) .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 /// 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 /// 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 /// header apply to Style.
/// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set.
private var targetIDs: Set<ItemID> { private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else { guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
return [entry.id] return [card.id]
} }
return store.selection.ids return store.selection.ids
} }
private var targetFolders: [URL] { 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) } .map { $0.folder(under: store.rootURL) }
} }
} }
+19 -48
View File
@@ -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, 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.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.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( try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Archive"))
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.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 return fixture
} }
@@ -76,54 +66,35 @@ struct AppModelTests {
// MARK: Live-only counts // MARK: Live-only counts
@Test("The recents counts are live items only, at both levels") @Test("The recents counts are working items only — the trash is an errand, not inventory")
func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws { func liveCountsExcludeTheTrash() throws {
let fixture = try makeMixedBoard() let fixture = try makeMixedBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The snapshot itself keeps everything tombstones are what the trash renders so this is a // The snapshot itself keeps everything the trash is a sibling container so this is a
// genuine filter, not a property of the load. // genuine exclusion, not a property of the load.
#expect(snapshot.lanes.count == 3) #expect(snapshot.lanes.count == 2)
#expect(snapshot.lanes.flatMap(\.cards).count == 5) #expect(snapshot.trash.count == 2)
let counts = AppModel.liveCounts(of: snapshot) let counts = AppModel.liveCounts(of: snapshot)
#expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size") #expect(counts.lanes == 2)
#expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane") // 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 { func liveCountsOfAnEmptyBoard() throws {
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item( try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Gone"))
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"))
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model) let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
#expect(counts.lanes == 0) #expect(counts.lanes == 0)
#expect(counts.cards == 0) #expect(counts.cards == 0, "a board whose only content is trash advertises no working size")
}
@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)
} }
// MARK: Display name // MARK: Display name
@@ -241,7 +212,7 @@ struct AppModelTests {
#expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session") #expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session")
let record = try #require(model.boardRegistry.record(id: recordID)) 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.cardCount == 2)
#expect(record.isOpenNow == false) #expect(record.isOpenNow == false)
#expect(model.boardRegistry.restorables().isEmpty) #expect(model.boardRegistry.restorables().isEmpty)
+21 -23
View File
@@ -364,7 +364,7 @@ struct BoardStoreTests {
#expect(!ran) #expect(!ran)
// Reading stays live throughout keeping the last-good snapshot is the point of the lock. // 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) #expect(store.selection.ids.count == 1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired"))
@@ -421,50 +421,48 @@ struct BoardStoreTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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.card1)"))
store.handleWatcherEvent(.treeChanged(.foreign)) store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence() await store.awaitQuiescence()
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)]) #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") @Test("A container crossing is a vanish: a selected card trashed externally leaves the selection")
func selectionEjectsALivenessFlip() async throws { func selectionEjectsAContainerCrossing() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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)) store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence() await store.awaitQuiescence()
// Still in the snapshot the trash renders it but no longer on the selection's side of // Still in the snapshot the trash renders it but no longer in the selection's container,
// the boundary, so the homogeneous-by-liveness invariant survives a foreign edit. // so 04's one-container invariant survives a foreign edit (02-architecture.md's reload rule,
let flipped = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 } // resettled 2026-07-28: "re-resolution matches UUID *and* container side").
#expect(flipped?.isDeleted == true) #expect(store.snapshot.trash.map(\.id.rawValue) == [Ident.card1])
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)]) #expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
} }
@Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective") @Test("Deleting a lane externally takes its cards out of the selection with it")
func selectionEjectsCardsUnderATombstonedLane() async throws { func selectionEjectsCardsUnderARemovedLane() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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)) store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence() await store.awaitQuiescence()
// The cards' own flags never changed, but their lane's did and liveness is #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 } == nil)
// 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.selection.ids.isEmpty) #expect(store.selection.ids.isEmpty)
} }
@@ -473,7 +471,7 @@ struct BoardStoreTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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.card1)"))
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
@@ -481,9 +479,9 @@ struct BoardStoreTests {
await store.awaitQuiescence() await store.awaitQuiescence()
#expect(store.selection.ids.isEmpty) #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() store.clearSelection()
#expect(store.selection == .empty) #expect(store.selection == .empty)
} }
+1 -2
View File
@@ -191,13 +191,12 @@ struct RemoveAttachmentStoreTests {
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02])) try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02]))
let store = try BoardStore(rootURL: fixture.root) 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: ItemID(rawValue: Ident.indexless)) // no such card
store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane
store.removeAttachment(named: "", fromCard: card1) // no name 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.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") #expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
} }
+22 -28
View File
@@ -3,14 +3,18 @@ import Testing
@testable import Kanban @testable import Kanban
/// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still /// 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 /// name a card **on the board**? The decision is a pure function and this is its suite; nothing here
/// easiest to get wrong a live card under a tombstoned lane is invisible in the card's own data. /// needs a window.
/// So 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 // MARK: - Fixtures
/// - lane 1 (live): one live card, one tombstoned card /// - lane 1: one card
/// - lane 2 (**tombstoned**): one live card, whose own flag is clear /// - the trash: one card, moved there by a delete
@MainActor @MainActor
private func makeBoard() throws -> WriterFixture { private func makeBoard() throws -> WriterFixture {
let fixture = try 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, 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.card1)", Item.rich(order: "1024", title: "Fix login"))
try fixture.item( try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Gone"))
"\(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"))
return fixture return fixture
} }
@@ -62,30 +57,29 @@ struct CardWindowFateTests {
#expect(laneTitle(fate) == "Todo") #expect(laneTitle(fate) == "Todo")
} }
@Test("A tombstoned card dismisses its window") @Test("A card in the trash dismisses its window")
func aTombstonedCardDismisses() throws { func aTrashedCardDismisses() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// on the board closes the card's open window "a tombstone counts as deleted" // 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 renders it. // (05-card-window.md). The card is still in the snapshot; the trash column renders it.
#expect(snapshot.lanes[0].cards.contains { $0.id.rawValue == Ident.card2 }) #expect(snapshot.trash.contains { $0.id.rawValue == Ident.card2 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses) #expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses)
} }
@Test("A live card under a tombstoned lane dismisses too — liveness is ancestor-walked") @Test("Deleting a card's lane dismisses its window, because the card is gone with it")
func aLaneTombstoneDismissesItsCards() throws { func aLaneDeleteDismissesItsCards() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model 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 // "Deleting the card's *lane* deletes the card with it the window dismisses because the
// tombstoned lane to one restorable trash entry so the card renders nowhere, and a window // card is gone" (05). A lane delete is physical, so this needs no ancestor walk: the card
// onto something that renders nowhere is the case this walk exists for. // is simply not in the snapshot.
let buried = try #require(snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.first) #expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
#expect(!buried.isDeleted)
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card3, in: snapshot) == .dismisses)
} }
@Test("A card that is not in this board's snapshot dismisses — the cross-board move") @Test("A card that is not in this board's snapshot dismisses — the cross-board move")
+57 -54
View File
@@ -52,21 +52,21 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3) let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4) 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 schema: 1
title: \(title) title: \(title)
order: \(order) order: \(order)
created: 2026-01-01T09:00:00Z created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
--- ---
\(title) body. \(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 /// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
/// fallback lost exactly two files" both assertable. /// fallback lost exactly two files" both assertable.
@MainActor @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/photo.png", Data("png bytes".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".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.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, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) 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 return fixture
} }
@@ -140,7 +140,7 @@ struct ClipboardManifestTests {
copyID: "abc", copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true), boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
kind: .card, kind: .card,
side: .live, container: .board,
entries: [entry(Ident.card1)] entries: [entry(Ident.card1)]
) )
let data = try #require(manifest.encoded()) let data = try #require(manifest.encoded())
@@ -153,7 +153,7 @@ struct ClipboardManifestTests {
copyID: "abc", copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card, kind: .card,
side: .live, container: .board,
entries: [entry(Ident.card1)] entries: [entry(Ident.card1)]
) )
manifest.version = ClipboardManifest.currentVersion + 1 manifest.version = ClipboardManifest.currentVersion + 1
@@ -167,7 +167,7 @@ struct ClipboardManifestTests {
copyID: "abc", copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card, kind: .card,
side: .live, container: .board,
entries: [] entries: []
) )
let data = try #require(manifest.encoded()) let data = try #require(manifest.encoded())
@@ -200,7 +200,7 @@ struct ClipboardManifestTests {
copyID: "abc", copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true), boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: .card, kind: .card,
side: .live, container: .board,
entries: [titled, untitled] entries: [titled, untitled]
) )
#expect(manifest.plainText == "First\nUntitled") #expect(manifest.plainText == "First\nUntitled")
@@ -218,7 +218,7 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
@@ -238,12 +238,12 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live) harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload) let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .card) #expect(manifest.kind == .card)
#expect(manifest.side == .live) #expect(manifest.container == .board)
#expect(manifest.rootURL.path == harness.fixture.root.path) #expect(manifest.rootURL.path == harness.fixture.root.path)
// Flatten order lane `order`, then card `order`. // Flatten order lane `order`, then card `order`.
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2]) #expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
@@ -258,7 +258,7 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload) let manifest = try #require(harness.clipboard.payload)
@@ -266,12 +266,12 @@ struct ClipboardCopyTests {
#expect(manifest.entries[0].index == onDisk) #expect(manifest.entries[0].index == onDisk)
} }
@Test("A lane copy embeds its live cards and leaves the tombstoned one out") @Test("A lane copy embeds exactly its cards — the trash is board-level, so none is nested")
func laneEntryEmbedsLiveCards() async throws { func laneEntryEmbedsItsCards() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live) harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload) let manifest = try #require(harness.clipboard.payload)
@@ -281,17 +281,17 @@ struct ClipboardCopyTests {
#expect(manifest.entries[0].lostAttachmentCount == 2) #expect(manifest.entries[0].lostAttachmentCount == 2)
} }
@Test("A trashed selection copies out, side recorded") @Test("A trash selection copies out, container recorded")
func trashedSide() async throws { func trashContainerRecorded() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed) harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
let manifest = try #require(harness.clipboard.payload) let manifest = try #require(harness.clipboard.payload)
#expect(manifest.side == .trashed) #expect(manifest.container == .trash)
#expect(manifest.entries.map(\.id) == [Ident.card3]) #expect(manifest.entries.map(\.id) == [Ident.card3])
} }
@@ -310,12 +310,12 @@ struct ClipboardCopyTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let first = try #require(harness.clipboard.payload?.copyID) 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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let second = try #require(harness.clipboard.payload?.copyID) let second = try #require(harness.clipboard.payload?.copyID)
@@ -365,7 +365,7 @@ struct ClipboardSweepTests {
withIntermediateDirectories: true withIntermediateDirectories: true
) )
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
@@ -378,7 +378,7 @@ struct ClipboardSweepTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
#expect(try harness.stagedCopyIDs().count == 1) #expect(try harness.stagedCopyIDs().count == 1)
@@ -402,7 +402,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.payload != nil) #expect(harness.clipboard.payload != nil)
@@ -417,7 +417,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
let before = harness.pasteboard.changeCount let before = harness.pasteboard.changeCount
harness.clipboard.refresh() harness.clipboard.refresh()
@@ -432,7 +432,7 @@ struct ClipboardTakeoverTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store) harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1]) #expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
@@ -453,11 +453,11 @@ struct ClipboardCutTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard4], liveness: .live) harness.store.select([clipboardCard1, clipboardCard4], in: .board)
harness.clipboard.cut(from: harness.store) harness.clipboard.cut(from: harness.store)
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4]) #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") @Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
@@ -465,21 +465,21 @@ struct ClipboardCutTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store) harness.clipboard.cut(from: harness.store)
#expect(!harness.store.transient.pendingCut.isEmpty) #expect(!harness.store.transient.pendingCut.isEmpty)
harness.store.select([clipboardCard2], liveness: .live) harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
#expect(harness.store.transient.pendingCut.isEmpty) #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 { func deletionVoidsPerItem() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live) harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.cut(from: harness.store) harness.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1]) harness.store.delete([clipboardCard1])
@@ -502,19 +502,22 @@ struct ClipboardAvailabilityTests {
defer { harness.tearDown() } defer { harness.tearDown() }
#expect(harness.clipboard.canCopy(from: harness.store) == false) #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)) #expect(harness.clipboard.canCopy(from: harness.store))
} }
@Test("Copy works on a trashed selection; cut does not") /// 04-interactions.md The trash, resettled 2026-07-28: "X **works** (it was disabled under
func trashIsCopyOutOnly() throws { /// 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() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true 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.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") @Test("The read-only lock blocks cut but never copy")
@@ -522,7 +525,7 @@ struct ClipboardAvailabilityTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.store.enterVanishedRootLock() harness.store.enterVanishedRootLock()
#expect(harness.clipboard.canCopy(from: harness.store)) #expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store) == false) #expect(harness.clipboard.canCut(from: harness.store) == false)
@@ -534,7 +537,7 @@ struct ClipboardAvailabilityTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First") harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
@@ -549,7 +552,7 @@ struct ClipboardAvailabilityTests {
defer { harness.tearDown() } defer { harness.tearDown() }
#expect(harness.clipboard.canPaste(into: harness.store) == false) #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) harness.clipboard.copy(from: harness.store)
#expect(harness.clipboard.canPaste(into: harness.store)) #expect(harness.clipboard.canPaste(into: harness.store))
} }
@@ -573,11 +576,11 @@ struct ClipboardAvailabilityTests {
let sourceStore = try BoardStore(rootURL: source.root) let sourceStore = try BoardStore(rootURL: source.root)
let emptyStore = try BoardStore(rootURL: empty.root) let emptyStore = try BoardStore(rootURL: empty.root)
sourceStore.select([clipboardCard1], liveness: .live) sourceStore.select([clipboardCard1], in: .board)
clipboard.copy(from: sourceStore) clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore) == false) #expect(clipboard.canPaste(into: emptyStore) == false)
sourceStore.select([clipboardLane1], liveness: .live) sourceStore.select([clipboardLane1], in: .board)
clipboard.copy(from: sourceStore) clipboard.copy(from: sourceStore)
#expect(clipboard.canPaste(into: emptyStore)) #expect(clipboard.canPaste(into: emptyStore))
} }
@@ -600,7 +603,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture) let model = try snapshot(fixture)
let target = PasteTarget.cards( let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live), selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
lastActiveLaneID: nil, lastActiveLaneID: nil,
snapshot: model snapshot: model
) )
@@ -614,11 +617,11 @@ struct PasteTargetTests {
let model = try snapshot(fixture) let model = try snapshot(fixture)
let target = PasteTarget.cards( let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live), selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
lastActiveLaneID: nil, lastActiveLaneID: nil,
snapshot: model snapshot: model
) )
// Two rendered cards the tombstoned third is not in the layout. // Two rendered cards.
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2)) #expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
} }
@@ -629,7 +632,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture) let model = try snapshot(fixture)
let target = PasteTarget.cards( let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live), selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], container: .board),
lastActiveLaneID: nil, lastActiveLaneID: nil,
snapshot: model snapshot: model
) )
@@ -637,18 +640,18 @@ struct PasteTargetTests {
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1)) #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
} }
@Test("A tombstoned selection never anchors: it behaves as nothing selected") @Test("A trash selection never anchors: it behaves as nothing selected")
func tombstonedSelectionNeverAnchors() throws { func trashSelectionNeverAnchors() throws {
let fixture = try makeClipboardBoard() let fixture = try makeClipboardBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try snapshot(fixture) let model = try snapshot(fixture)
let target = PasteTarget.cards( let target = PasteTarget.cards(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed), selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
lastActiveLaneID: clipboardLane2, lastActiveLaneID: clipboardLane2,
snapshot: model 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)) #expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
} }
@@ -679,7 +682,7 @@ struct PasteTargetTests {
let model = try snapshot(fixture) let model = try snapshot(fixture)
#expect(PasteTarget.lanes( #expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live), selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
snapshot: model snapshot: model
) == 1) ) == 1)
} }
@@ -691,12 +694,12 @@ struct PasteTargetTests {
let model = try snapshot(fixture) let model = try snapshot(fixture)
#expect(PasteTarget.lanes( #expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live), selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
snapshot: model snapshot: model
) == 1) ) == 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 { func rightEnd() throws {
let fixture = try makeClipboardBoard() let fixture = try makeClipboardBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -704,7 +707,7 @@ struct PasteTargetTests {
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2) #expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
#expect(PasteTarget.lanes( #expect(PasteTarget.lanes(
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed), selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
snapshot: model snapshot: model
) == 2) ) == 2)
} }
+32 -32
View File
@@ -15,11 +15,11 @@ import Testing
@Suite("DragPayload") @Suite("DragPayload")
struct DragPayloadTests { 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( DragPayload(
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true), boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
kind: kind, kind: kind,
side: side, container: container,
items: [ items: [
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"), DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil) 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") @Test("A payload round-trips through its JSON representation unchanged")
func roundTrip() throws { func roundTrip() throws {
for kind in [DragKind.cards, .lanes] { for kind in [DragKind.cards, .lanes] {
for side in [Liveness.live, .trashed] { for container in [ItemContainer.board, .trash] {
let original = Self.payload(kind: kind, side: side) let original = Self.payload(kind: kind, container: container)
let data = try #require(original.encoded()) let data = try #require(original.encoded())
#expect(DragPayload(data: data) == original) #expect(DragPayload(data: data) == original)
} }
@@ -62,10 +62,10 @@ struct DragPayloadTests {
#expect(Self.payload().plainText == "First\nUntitled") #expect(Self.payload().plainText == "First\nUntitled")
} }
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag") @Test("The container survives the round trip, because it is what makes a trash drag a trash drag")
func sideSurvives() throws { func containerSurvives() throws {
let data = try #require(Self.payload(side: .trashed).encoded()) let data = try #require(Self.payload(container: .trash).encoded())
#expect(DragPayload(data: data)?.side.liveness == .trashed) #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. /// 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") @Test("Locality picks the default — within is a move, across is a copy")
func theDefault() { func theDefault() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move) #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: none) == .move)
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy) #expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy)
#expect(DragLocality.operation(kind: .lanes, side: .live, 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") @Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
func modifiersOverride() { func modifiersOverride() {
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy) #expect(DragLocality.operation(kind: .cards, container: .board, 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: false, modifiers: command) == .move)
// The no-ops. // The no-ops.
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move) #expect(DragLocality.operation(kind: .cards, container: .board, 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: false, modifiers: option) == .copy)
} }
@Test("⌘ wins over ⌥ when both are held") @Test("⌘ wins over ⌥ when both are held")
func commandWinsOverOption() { func commandWinsOverOption() {
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks. // Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
#expect(DragLocality.operation( #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: /// The first carve-out: "Lane drags never copy *within their board*. is simply ignored there:
@@ -122,13 +122,13 @@ struct DragLocalityTests {
func laneDragsNeverCopyWithinTheirBoard() { func laneDragsNeverCopyWithinTheirBoard() {
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] { for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
#expect( #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" "a within-board lane drag is a reorder whatever is held"
) )
} }
// Across boards the lane obeys the ordinary grammar again. // 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, container: .board, 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: command) == .move)
} }
/// The second: a trash row's drag is copy-out grammar (04-interactions.md The trash). Within its /// 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. /// live copy either way.
@Test("A trash row drags as a restore at home and as a copy-out abroad") @Test("A trash row drags as a restore at home and as a copy-out abroad")
func trashDragDefaults() { func trashDragDefaults() {
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move) #expect(DragLocality.operation(kind: .cards, container: .trash, 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: false, modifiers: none) == .copy)
#expect(DragLocality.operation( #expect(DragLocality.operation(
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move) kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move)
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy) #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. /// The accepted session, with one clause at a time knocked out by the cases.
private func accepts( private func accepts(
kind: DragKind? = .cards, kind: DragKind? = .cards,
side: Liveness = .live, container: ItemContainer = .board,
isWithinBoard: Bool = true, isWithinBoard: Bool = true,
operation: TransferOperation = .move, operation: TransferOperation = .move,
isTrashShown: Bool = true, isTrashShown: Bool = true,
@@ -166,7 +166,7 @@ struct TrashDropTests {
) -> Bool { ) -> Bool {
TrashDrop.accepts( TrashDrop.accepts(
kind: kind, kind: kind,
side: side, container: container,
isWithinBoard: isWithinBoard, isWithinBoard: isWithinBoard,
operation: operation, operation: operation,
isTrashShown: isTrashShown, isTrashShown: isTrashShown,
@@ -196,12 +196,12 @@ struct TrashDropTests {
#expect(!accepts(kind: nil)) #expect(!accepts(kind: nil))
} }
/// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes /// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it
/// nothing, so it never proposes. /// never proposes.
@Test("A trash row dropped back on the trash is refused") @Test("A trash card dropped back on the trash is refused")
func theTrashedSideIsRefused() { func theTrashContainerIsRefused() {
#expect(!accepts(side: .trashed)) #expect(!accepts(container: .trash))
#expect(!accepts(side: .trashed, isWithinBoard: false)) #expect(!accepts(container: .trash, isWithinBoard: false))
} }
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash /// "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) .appendingPathComponent($0.id.rawValue, isDirectory: true)
}, },
heights: members.map { _ in 44 }, heights: members.map { _ in 44 },
side: .live, container: .board,
source: store source: store
) )
} }
+110 -107
View File
@@ -16,20 +16,6 @@ import Testing
// MARK: - Fixtures // 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 /// 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. /// between siblings without either end being the answer.
@MainActor @MainActor
@@ -55,12 +41,12 @@ private enum Foreign {
static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" 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 /// The board every cross-board test drags *out of*: one lane holding two cards, plus a card sitting
/// one, so the lane-copy rule has something to strip and the restore rules have a row to carry. /// 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, /// `colliding` puts the lane and its first card under identities the **destination** already holds,
/// which is the import boundary's whole question; the tombstoned card keeps its foreign identity /// which is the import boundary's whole question; the second card keeps its foreign identity either
/// either way, so a colliding arrival can prove the degradation is per folder. /// way, so a colliding arrival can prove the degradation is per folder.
@MainActor @MainActor
private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture { private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
@@ -69,7 +55,8 @@ private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
let cardName = colliding ? Ident.card1 : Foreign.card let cardName = colliding ? Ident.card1 : Foreign.card
try fixture.item(laneName, Item.rich(order: "1024", title: "Imported")) 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)/\(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 return fixture
} }
@@ -90,14 +77,14 @@ private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
/// A lane's rendered card titles, in display order. /// A lane's rendered card titles, in display order.
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } 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 /// A lane's rendered card folder names, in display order identity, where titles would not
/// distinguish an original from its copy. /// distinguish an original from its copy.
private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } 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<Double> { private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
@@ -183,18 +170,16 @@ struct MoveCardsTests {
#expect(store.banners.oneShots.isEmpty) #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 { func noOps() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") 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([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
store.moveCards([], toLane: lane2, at: 0) // nothing dragged 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 stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp)
#expect(try titles(lane2, in: fixture) == ["Fourth"]) #expect(try titles(lane2, in: fixture) == ["Fourth"])
@@ -315,14 +300,13 @@ struct CopyCardsTests {
func noOps() throws { func noOps() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root) 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) store.copyCards([], toLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Fourth"]) #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) #expect(store.banners.oneShots.isEmpty)
} }
} }
@@ -411,14 +395,13 @@ struct ReceiveCardsTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let source = try makeSourceBoard() let source = try makeSourceBoard()
defer { source.tearDown() } defer { source.tearDown() }
try destination.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: destination.root) let store = try BoardStore(rootURL: destination.root)
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], 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) 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(try titles(lane2, in: destination) == ["Fourth"])
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)")) #expect(source.exists("\(Foreign.lane)/\(Foreign.card)"))
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
@@ -524,8 +507,12 @@ struct MoveLanesTests {
@Suite("BoardStore ▸ receiveLanes") @Suite("BoardStore ▸ receiveLanes")
struct ReceiveLanesTests { struct ReceiveLanesTests {
@Test("A lane copy transfers the content and strips the tombstoned cards") /// 04-interactions.md Drag and drop, resettled 2026-07-28: "A lane carries exactly its cards
func laneCopyStripsTombstones() throws { /// 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() let destination = try makeBoard()
defer { destination.tearDown() } defer { destination.tearDown() }
let source = try makeSourceBoard() let source = try makeSourceBoard()
@@ -538,19 +525,15 @@ struct ReceiveLanesTests {
#expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"]) #expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"])
let arrived = try #require(model.lanes.first) let arrived = try #require(model.lanes.first)
#expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level") #expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level")
#expect(arrived.cards.map(\.title.value) == ["Travelling"], #expect(arrived.cards.map(\.title.value) == ["Travelling", "Second"], "nothing to strip")
"trash isn't content — the tombstoned card did not come") #expect(arrived.cards.allSatisfy { $0.id.rawValue != Foreign.card },
#expect(arrived.cards.allSatisfy { !$0.isDeleted }) "a copied lane's cards are new cards")
#expect(arrived.cards[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")
// 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(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@Test("A lane move carries its tombstoned cards whole, into the destination's trash") @Test("A lane move carries its cards whole, identity and all")
func laneMoveCarriesTombstones() throws { func laneMoveCarriesItsCards() throws {
let destination = try makeBoard() let destination = try makeBoard()
defer { destination.tearDown() } defer { destination.tearDown() }
let source = try makeSourceBoard() let source = try makeSourceBoard()
@@ -563,10 +546,9 @@ struct ReceiveLanesTests {
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane], #expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane],
"identity travels, and the drop position is honoured") "identity travels, and the drop position is honoured")
let arrived = try #require(model.lanes.last) let arrived = try #require(model.lanes.last)
#expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.trashed]) #expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.second],
#expect(arrived.cards[1].isDeleted, "the tombstone came along as-is") "a lane carries exactly its cards: nothing lane-nested to strip or carry")
#expect(TrashModel.entries(of: model).map(\.id) == [ItemID(rawValue: Foreign.trashed)], #expect(model.trash.isEmpty, "and nothing arrived in the destination's own trash")
"and it renders in the destination's trash")
#expect(!source.exists(Foreign.lane)) #expect(!source.exists(Foreign.lane))
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@@ -591,7 +573,7 @@ struct ReceiveLanesTests {
let arrivedCards = arrived.cards.map(\.id.rawValue) let arrivedCards = arrived.cards.map(\.id.rawValue)
#expect(arrivedCards.count == 2) #expect(arrivedCards.count == 2)
#expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too") #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"], #expect(try titles(lane1, in: destination) == ["First", "Second", "Third"],
"the residents kept their identities and their ranks") "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 /// A lane holding two cards, plus one card in the board's `.trash/` so a restore has somewhere to
/// to land that is neither the head nor the tail. /// 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 @MainActor
private func makeTrashBoard() throws -> WriterFixture { private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) 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.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.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, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) 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 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 @MainActor
@Suite("BoardStore ▸ positional drag-to-restore") @Suite("BoardStore ▸ restore by move-out")
struct RestoreByDragPositionTests { struct RestoreByMoveOutTests {
@Test("The drop position sets the restored card's order") @Test("The drop position sets the restored card's order")
func dropPositionSetsTheOrder() throws { func dropPositionSetsTheOrder() throws {
@@ -636,76 +629,89 @@ struct RestoreByDragPositionTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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. // Index 0 among the lane's two cards: ahead of both.
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 0) store.moveCards([card2], toLane: lane1, at: 0)
#expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"]) #expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0)) #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "same lane never moves a folder") #expect(!fixture.exists(".trash/\(Ident.card2)"), "the folder physically left the trash")
#expect(TrashModel.isEmpty(try loaded(fixture))) #expect(try loaded(fixture).trash.isEmpty)
#expect(store.banners.oneShots.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") @Test("An out-of-range index clamps to the lane's bottom")
func indexClamps() throws { func indexClamps() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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 titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096)) #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 { func multiRestore() throws {
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) 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.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, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) 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) 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"], #expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
"the payload's order is the landing order") "the trash's own order is the landing order")
#expect(TrashModel.isEmpty(try loaded(fixture))) #expect(try loaded(fixture).trash.isEmpty)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@Test("A row that is not a trash row is skipped, and an empty list writes nothing") @Test("⌥ copies out and leaves the original in the trash")
func skipsWhatIsNotARow() throws { 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() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
// `card1` is live it has no trash row to drag and `indexless` names nothing. store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0)
store.restoreByDrag(cardIDs: [card1, ItemID(rawValue: Ident.indexless)], intoLane: lane2, at: 0) store.moveCards([], toLane: lane2, at: 0)
store.restoreByDrag(cardIDs: [], intoLane: lane2, at: 0)
#expect(try titles(lane2, in: fixture) == ["Fourth"]) #expect(try titles(lane2, in: fixture) == ["Fourth"])
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched) #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 @MainActor
@Suite("BoardStore ▸ receiveRestoredCards") @Suite("BoardStore ▸ cross-board restore")
struct ReceiveRestoredCardsTests { struct CrossBoardRestoreTests {
@Test("A cross-board restore-copy lands live and leaves the source tombstone standing") @Test("A cross-board copy out of the trash lands a fresh identity and leaves the original")
func restoreCopyStripsDeletedAndKeepsTheOriginal() throws { func copyLeavesTheOriginal() throws {
let destination = try makeBoard() let destination = try makeBoard()
defer { destination.tearDown() } defer { destination.tearDown() }
let source = try makeSourceBoard() let source = try makeSourceBoard()
defer { source.tearDown() } defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root) let store = try BoardStore(rootURL: destination.root)
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")], store.receiveCards([source.url(".trash/\(Foreign.trashed)")],
operation: .copy, toLane: lane2, at: 0) operation: .copy, toLane: lane2, at: 0)
#expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"]) #expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"])
let landedIDs = try ids(lane2, in: destination) let landed = try #require(try ids(lane2, in: destination).first)
let landed = try #require(landedIDs.first)
#expect(landed != Foreign.trashed, "a copy out of the trash is still a copy") #expect(landed != Foreign.trashed, "a copy out of the trash is still a copy")
let text = try destination.indexText("\(Ident.lane2)/\(landed)") 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") #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(".trash/\(Foreign.trashed)"), "the original stays in the source trash")
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@Test("A cross-board restore-move clears the tombstone and the source loses the folder") @Test("A ⌘-drag move out of another board's trash carries the identity and empties it")
func restoreMoveClearsTheTombstone() throws { func moveCarriesTheIdentity() throws {
let destination = try makeBoard() let destination = try makeBoard()
defer { destination.tearDown() } defer { destination.tearDown() }
let source = try makeSourceBoard() let source = try makeSourceBoard()
defer { source.tearDown() } defer { source.tearDown() }
let store = try BoardStore(rootURL: destination.root) let store = try BoardStore(rootURL: destination.root)
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")], store.receiveCards([source.url(".trash/\(Foreign.trashed)")],
operation: .move, toLane: lane2, at: 1) operation: .move, toLane: lane2, at: 1)
#expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels") #expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels")
let text = try destination.indexText("\(Ident.lane2)/\(Foreign.trashed)") #expect(!source.exists(".trash/\(Foreign.trashed)"), "the card left the source trash")
#expect(!text.contains("deleted:")) #expect(try loaded(source).trash.isEmpty)
#expect(!source.exists("\(Foreign.lane)/\(Foreign.trashed)"), "the tombstone left the source") #expect(try loaded(destination).trash.isEmpty)
#expect(TrashModel.isEmpty(try loaded(source)))
#expect(TrashModel.isEmpty(try loaded(destination)))
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
} }
+10 -13
View File
@@ -184,27 +184,25 @@ struct ImportAttachmentsToCardTests {
#expect(store.banners.oneShots.isEmpty) #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 { func inertTargets() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let sources = try DropSources() let sources = try DropSources()
defer { sources.tearDown() } defer { sources.tearDown() }
// A tombstoned card, and a live card under a tombstoned lane effective liveness is // "Finder file drops on trash cards are inert" (04 The trash), and a card whose lane was
// ancestor-walked, so both render nowhere and both are inert. // deleted is simply not there.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second")) try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried"))
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png") let shot = try sources.file("shot.png")
store.importAttachments([shot], toCard: card2) // tombstoned card store.importAttachments([shot], toCard: card2) // a trash card
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // under a tombstoned lane 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: ItemID(rawValue: Ident.indexless)) // no such card
store.importAttachments([shot], toCard: lane1) // a lane, not a card store.importAttachments([shot], toCard: lane1) // a lane, not a card
store.importAttachments([], toCard: card1) // nothing dropped 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.lane3)/\(Ident.card4)/attachments"))
#expect(!fixture.exists("\(Ident.lane1)/attachments")) #expect(!fixture.exists("\(Ident.lane1)/attachments"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments")) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"))
@@ -387,21 +385,20 @@ struct CreateCardsFromFilesTests {
#expect(store.banners.oneShots.isEmpty) #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 { func inertDestinations() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let sources = try DropSources() let sources = try DropSources()
defer { sources.tearDown() } defer { sources.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png") 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: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped 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(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
+7 -7
View File
@@ -220,21 +220,21 @@ struct InlineRenameWriteTests {
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@Test("A commit at a card under a tombstoned lane writes nothing — liveness is effective") @Test("A commit at a card that entered the trash writes nothing")
func targetUnderATombstonedLaneWritesNothing() throws { func targetInTheTrashWritesNothing() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
// The lane carries the tombstone; the card's own flag is untouched, and it renders nowhere // "Entering the trash is a vanish from the board; nothing is ever written into a vanished
// regardless (03-board-ui.md collapses the lane to one trash entry). // folder" (04 Grammar).
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
let store = try BoardStore(rootURL: fixture.root) 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.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Never lands") store.transient.updateRenameDraft("Never lands")
store.commitRename() store.commitRename()
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) #expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
+39 -48
View File
@@ -75,9 +75,9 @@ private func target(
width: CGFloat = 100, width: CGFloat = 100,
height: CGFloat = 100, height: CGFloat = 100,
kind: SelectionKind = .card, kind: SelectionKind = .card,
side: Liveness = .live container: ItemContainer = .board
) -> MarqueeTarget { ) -> 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 /// 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") @Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate")
func predicateRestrictsCandidates() { 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 live = target(card3, x: 0, y: 400)
let all = [trashed, live] let all = [trashed, live]
@@ -154,7 +154,7 @@ struct NavigationMathTests {
"a plain arrow walks across the boundary" "a plain arrow walks across the boundary"
) )
#expect( #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() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card2], in: snapshot) == card3) #expect(SelectionGrammar.successor(afterDeleting: [card2], snapshot: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2) #expect(SelectionGrammar.successor(afterDeleting: [card1], snapshot: snapshot) == card2)
#expect( #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" "a block's successor is the first survivor after its last member"
) )
} }
@@ -230,8 +230,8 @@ struct SuccessorTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card4], in: snapshot) == card3) #expect(SelectionGrammar.successor(afterDeleting: [card4], snapshot: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: snapshot) == card2) #expect(SelectionGrammar.successor(afterDeleting: [card3, card4], snapshot: snapshot) == card2)
} }
@Test("A survivor between the members is found forwards first") @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 // 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. // 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") @Test("An emptied container selects nothing")
@@ -251,10 +251,10 @@ struct SuccessorTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], in: snapshot) == nil) #expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], snapshot: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil) #expect(SelectionGrammar.successor(afterDeleting: [], snapshot: snapshot) == nil)
#expect( #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" "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 // 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. // 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") @Test("Lanes follow the same rule in the live lane order")
@@ -276,9 +276,9 @@ struct SuccessorTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [lane1], in: snapshot) == lane2) #expect(SelectionGrammar.successor(afterDeleting: [lane1], snapshot: snapshot) == lane2)
#expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor") #expect(SelectionGrammar.successor(afterDeleting: [lane3], snapshot: snapshot) == lane2, "the last lane's predecessor")
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: snapshot) == nil) #expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], snapshot: snapshot) == nil)
} }
@Test("⌫ selects the successor immediately, before the reload echoes the tombstone back") @Test("⌫ selects the successor immediately, before the reload echoes the tombstone back")
@@ -287,7 +287,7 @@ struct SuccessorTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([card2], liveness: .live) store.select([card2], in: .board)
store.deleteSelection() store.deleteSelection()
#expect(store.selection.ids == [card3]) #expect(store.selection.ids == [card3])
@@ -306,7 +306,7 @@ struct SuccessorTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([card5, card6], liveness: .live) store.select([card5, card6], in: .board)
store.deleteSelection() store.deleteSelection()
#expect(store.selection.isEmpty) #expect(store.selection.isEmpty)
@@ -324,13 +324,13 @@ struct SelectionHeadTests {
func headDefaults() throws { func headDefaults() throws {
let state = TransientBoardState() let state = TransientBoardState()
state.select([card1], liveness: .live) state.select([card1], in: .board)
#expect(state.selectionHead == card1) #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") #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.selectionAnchor == card1)
#expect(state.selectionHead == card2, "an explicit head is kept whatever the count") #expect(state.selectionHead == card2, "an explicit head is kept whatever the count")
@@ -346,9 +346,9 @@ struct SelectionHeadTests {
let snapshot = try load(fixture) let snapshot = try load(fixture)
let outcome = SelectionGrammar.click( let outcome = SelectionGrammar.click(
SelectionTarget(id: card3, kind: .card, side: .live), SelectionTarget(id: card3, kind: .card, container: .board),
modifier: .shift, modifier: .shift,
selection: ItemReferenceSet(ids: [card1], liveness: .live), selection: ItemReferenceSet(ids: [card1], container: .board),
anchor: card1, anchor: card1,
snapshot: snapshot snapshot: snapshot
) )
@@ -364,7 +364,7 @@ struct SelectionHeadTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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)")) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
store.handleWatcherEvent(.treeChanged(.foreign)) store.handleWatcherEvent(.treeChanged(.foreign))
@@ -375,25 +375,16 @@ struct SelectionHeadTests {
#expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree") #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") @Test("A container crossing is a vanish for the head too")
func resolveDropsALivenessFlippedHead() async throws { func resolveDropsAContainerCrossedHead() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([card1], liveness: .live) store.select([card1], in: .board)
#expect(store.transient.selectionHead == card1) #expect(store.transient.selectionHead == card1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """ try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
---
schema: 1
title: First
order: 1024
deleted: 2026-03-03T09:00:00Z
---
First body.
""")
store.handleWatcherEvent(.treeChanged(.foreign)) store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence() await store.awaitQuiescence()
@@ -416,7 +407,7 @@ struct SortWriteTests {
let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)")
store.select([card3], liveness: .live) store.select([card3], in: .board)
store.sortSelection(.up) store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4]) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4])
@@ -435,7 +426,7 @@ struct SortWriteTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([card2, card4], liveness: .live) store.select([card2, card4], in: .board)
store.sortSelection(.up) store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3]) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3])
@@ -447,7 +438,7 @@ struct SortWriteTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live) store.select([card1, card2], in: .board)
store.sortSelection(.down) store.sortSelection(.down)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4]) #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]) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2])
store.select([card2], liveness: .live) store.select([card2], in: .board)
store.sortSelection(.up) store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1]) #expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1])
@@ -482,16 +473,16 @@ struct SortWriteTests {
#expect(store.sortPlan(.up) == nil, "nothing selected") #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") #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") #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") #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(.up) == nil, "already at the top")
#expect(store.sortPlan(.down) != nil, "but the other direction is live") #expect(store.sortPlan(.down) != nil, "but the other direction is live")
} }
@@ -513,7 +504,7 @@ struct MoveLaneConventionTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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]) #expect(lanes == [lane1, lane2, lane3])
let from = try #require(lanes.firstIndex(of: lane2)) let from = try #require(lanes.firstIndex(of: lane2))
@@ -527,7 +518,7 @@ struct MoveLaneConventionTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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)) let from = try #require(lanes.firstIndex(of: lane1))
store.moveLane(lane1, toIndex: from + 1) store.moveLane(lane1, toIndex: from + 1)
+9 -9
View File
@@ -665,9 +665,9 @@ struct LooseFilePasteTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) 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 await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination) let arrived = try arrivedCard(in: destination)
@@ -693,9 +693,9 @@ struct LooseFilePasteTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) 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 await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination) let arrived = try arrivedCard(in: destination)
@@ -718,9 +718,9 @@ struct LooseFilePasteTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) 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 await harness.clipboard.paste(into: target)?.value
#expect(harness.fixture.exists("\(cardPath)/notes.txt")) #expect(harness.fixture.exists("\(cardPath)/notes.txt"))
@@ -735,7 +735,7 @@ struct LooseFilePasteTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
@@ -760,7 +760,7 @@ struct LooseFilePasteTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
// The snapshot goes missing between the copy and the paste 04's degraded paste. // 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)) 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 await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination) let arrived = try arrivedCard(in: destination)
+28 -28
View File
@@ -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.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, 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.card3)", Item.rich(order: "1024", title: "Third"))
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Archive"))
return fixture 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 // "With a card selected, the new card is created in that card's lane, immediately after it
// (paste-anchor consistency)." // (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)) == 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)) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
// The last card in a lane is still an anchor here "after the last card" and "at the // 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`). // 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)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
} }
@@ -89,7 +88,7 @@ struct NewCardTargetTests {
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// "With a lane selected, appended at its bottom (Return consistency)." // "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)) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
} }
@@ -107,25 +106,25 @@ struct NewCardTargetTests {
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) #expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
#expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless)) #expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
// A *tombstoned* lane is not a target either it renders nowhere, and the trash is never a // A lane that has been deleted is not a target either a lane delete is physical, so the
// creation destination. // memory simply names nothing.
#expect(resolve(snapshot, lastActive: lane3) #expect(resolve(snapshot, lastActive: lane3)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
} }
@Test("A tombstoned selection never anchors creation — it behaves as nothing selected") @Test("A trash selection never anchors creation — it behaves as nothing selected")
func aTombstonedSelectionNeverAnchors() throws { func aTrashSelectionNeverAnchors() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model 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 // 04 The map, on the N rule's own wording: "with nothing selected or a **trash**
// never anchors creation". The trash-side lane is a real lane on disk with a live sibling // selection, which never anchors creation". A trashed card's board-side lane must not leak
// list the rule must not let its identity leak in as a target. // in as a target.
let trashed = ItemReferenceSet(ids: [lane3], liveness: .trashed) let inTrash = ItemReferenceSet(ids: [card1], container: .trash)
#expect(resolve(snapshot, selection: trashed, lastActive: lane2) #expect(resolve(snapshot, selection: inTrash, lastActive: lane2)
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil)) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
#expect(resolve(snapshot, selection: trashed) #expect(resolve(snapshot, selection: inTrash)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil)) == 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 // target New Card, Return-creation, and Paste with a card payload disable via menu
// validation until a lane exists." // validation until a lane exists."
#expect(resolve(empty) == nil) #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) #expect(resolve(empty, lastActive: lane1) == nil)
} }
@Test("A board whose every lane is tombstoned is a zero-lane board") @Test("A board whose every lane has been deleted is a zero-lane board")
func everyLaneTombstonedIsAlsoZeroLane() throws { func everyLaneDeletedIsAlsoZeroLane() throws {
let fixture = try WriterFixture() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) {
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) try FileManager.default.removeItem(at: fixture.url(lane))
}
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model 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, // "Every lane deleted" is the design's own second reading of the zero-lane case and a lane
// and "every lane deleted" is the design's own second reading of the zero-lane case. // delete is physical now, so it is literally the same board as the hand-made one above.
#expect(snapshot.lanes.count == 1) #expect(snapshot.lanes.isEmpty)
#expect(resolve(snapshot) == nil) #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 // "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 // `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. // 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)) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
// Within one lane the flatten order is card order: card2 sorts after card1. // 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)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
// A multi-LANE selection appends to the last selected lane's bottom. // 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)) == 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 // 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. // 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)) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
} }
} }
+84 -93
View File
@@ -61,9 +61,9 @@ struct PasteFromStagingTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last) let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
@@ -85,9 +85,9 @@ struct PasteFromStagingTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last) let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
@@ -107,9 +107,9 @@ struct PasteFromStagingTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"]) #expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
@@ -123,13 +123,13 @@ struct PasteFromStagingTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
target.handleWatcherEvent(.treeChanged(.appMediated)) target.handleWatcherEvent(.treeChanged(.appMediated))
await target.awaitQuiescence() await target.awaitQuiescence()
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let ids = try pastedIDs(destinationLane, in: destination) let ids = try pastedIDs(destinationLane, in: destination)
@@ -142,7 +142,7 @@ struct PasteFromStagingTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardCard1], liveness: .live) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: harness.store)?.value await harness.clipboard.paste(into: harness.store)?.value
@@ -159,15 +159,15 @@ struct PasteFromStagingTests {
@Suite("Paste ▸ lanes") @Suite("Paste ▸ lanes")
struct PasteLaneTests { struct PasteLaneTests {
@Test("A pasted lane copy takes fresh GUIDs throughout and strips tombstoned cards") @Test("A pasted lane copy takes fresh GUIDs throughout and carries exactly its cards")
func laneCopyStripsTombstones() async throws { func laneCopyRemintsThroughout() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
@@ -176,12 +176,11 @@ struct PasteLaneTests {
let arrived = try #require(model.lanes.last) let arrived = try #require(model.lanes.last)
#expect(arrived.id.rawValue != Ident.lane1) #expect(arrived.id.rawValue != Ident.lane1)
#expect(arrived.title.value == "Todo") #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.count == 2)
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
#expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 }) #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 pasted(harness.fixture).trash.count == 1, "and the source's trash is untouched")
#expect(try lane(clipboardLane1, in: harness.fixture)?.cards.count == 3)
} }
@Test("A lane paste with nothing selected lands at the board's right end") @Test("A lane paste with nothing selected lands at the board's right end")
@@ -192,7 +191,7 @@ struct PasteLaneTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
@@ -208,30 +207,30 @@ struct PasteLaneTests {
try empty.item("", Item.board) try empty.item("", Item.board)
let target = try BoardStore(rootURL: empty.root) 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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"]) #expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"])
} }
@Test("A lane cut-move carries its tombstoned cards whole") @Test("A lane cut-move carries its cards whole, identity and all")
func laneCutMoveCarriesTombstones() async throws { func laneCutMoveCarriesItsCards() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) harness.clipboard.cut(from: harness.store)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let model = try pasted(destination) let model = try pasted(destination)
let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 }) let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 })
// Identity travelled, and the tombstone landed in the destination's trash. // Identity travelled, and every card came with it nothing to strip.
#expect(arrived.cards.count == 3) #expect(arrived.cards.count == 2)
#expect(arrived.cards.contains { $0.isDeleted }) #expect(model.trash.isEmpty, "the source board's trash is board-level and stays there")
// The lane left the source board entirely. // The lane left the source board entirely.
#expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2]) #expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2])
} }
@@ -241,7 +240,7 @@ struct PasteLaneTests {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
harness.store.select([clipboardLane1], liveness: .live) harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.paste(into: harness.store)?.value await harness.clipboard.paste(into: harness.store)?.value
@@ -259,8 +258,8 @@ struct PasteLaneTests {
@Suite("Paste ▸ from the trash") @Suite("Paste ▸ from the trash")
struct PasteFromTrashTests { struct PasteFromTrashTests {
@Test("A card copied out of the trash arrives live") @Test("A card copied out of the trash is an ordinary copy of an ordinary card")
func cardArrivesLive() async throws { func cardCopiesOutOrdinarily() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
@@ -268,46 +267,39 @@ struct PasteFromTrashTests {
let target = try BoardStore(rootURL: destination.root) let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed) harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"]) #expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
// And the tombstoned original stays in the source trash copy-out, never a move. // The original stays in the source trash a copy is a copy (04 The trash: "C copies a
#expect(try lane(clipboardLane1, in: harness.fixture)? // trash card; a live copy lands wherever pasted, like copying out of Finder's Trash").
.cards.first { $0.id == clipboardCard3 }?.isDeleted == true) #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") /// **Cut in the trash, paste into a lane, is the keyboard-native restore** (04-interactions.md
func laneEntryStripsBothWays() async throws { /// The trash, resettled 2026-07-28) "an ordinary folder move", which is exactly what the
let fixture = try WriterFixture() /// armed cut already does.
defer { fixture.tearDown() } @Test("Cut in the trash and paste into a lane is the keyboard restore — the folder moves")
try fixture.item("", Item.board) func cutFromTheTrashIsTheKeyboardRestore() async throws {
// A tombstoned lane holding one plain card and one that carries its own tombstone. let harness = try makeClipboardHarness()
try fixture.item(Ident.lane1, tombstonedItem(order: "1024", title: "Archive")) defer { harness.tearDown() }
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)
harness.store.transient.isTrashVisible = true harness.store.transient.isTrashVisible = true
harness.store.select([clipboardLane1], liveness: .trashed) harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store) harness.clipboard.cut(from: harness.store)
await harness.clipboard.paste(into: target)?.value harness.store.select([clipboardLane2], in: .board)
await harness.clipboard.paste(into: harness.store)?.value
let model = try pasted(destination) let model = try pasted(harness.fixture)
let arrived = try #require(model.lanes.last) #expect(model.trash.isEmpty, "the folder left the trash")
#expect(arrived.isDeleted == false) let lane = try #require(model.lanes.first { $0.id == clipboardLane2 })
#expect(arrived.title.value == "Archive") #expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
#expect(arrived.cards.count == 1) #expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
#expect(arrived.cards.first?.title.value == "Kept")
#expect(arrived.cards.first?.isDeleted == false)
} }
} }
@@ -337,9 +329,9 @@ struct PasteSearchAndStalenessTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.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. // "First" would be hidden by this query exactly the card that must not arrive invisible.
target.searchQuery = "resident" target.searchQuery = "resident"
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
@@ -356,7 +348,7 @@ struct PasteSearchAndStalenessTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) harness.clipboard.copy(from: harness.store)
target.searchQuery = "resident" target.searchQuery = "resident"
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
@@ -373,10 +365,10 @@ struct PasteSearchAndStalenessTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
harness.pasteboard.takeOver() harness.pasteboard.takeOver()
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
target.searchQuery = "resident" target.searchQuery = "resident"
// `refresh()` at the front of the paste sees the moved changeCount, so there is no payload // `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() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
target.searchQuery = "resident" target.searchQuery = "resident"
// The gesture passed validation; the takeover lands while the task is still waiting on the // The gesture passed validation; the takeover lands while the task is still waiting on the
@@ -427,9 +419,9 @@ struct PasteCutTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.cut(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
// Identity travelled. // Identity travelled.
@@ -450,13 +442,13 @@ struct PasteCutTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.cut(from: harness.store)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
target.handleWatcherEvent(.treeChanged(.appMediated)) target.handleWatcherEvent(.treeChanged(.appMediated))
await target.awaitQuiescence() await target.awaitQuiescence()
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let ids = try pastedIDs(destinationLane, in: destination) let ids = try pastedIDs(destinationLane, in: destination)
@@ -474,12 +466,12 @@ struct PasteCutTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.cut(from: harness.store)
// The source board closes its store goes, and with it the cut's arming. // The source board closes its store goes, and with it the cut's arming.
harness.store.transient.pendingCut = .empty harness.store.transient.pendingCut = .empty
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
// A copy: a fresh identity at the destination, and the original still at home. // A copy: a fresh identity at the destination, and the original still at home.
@@ -496,22 +488,21 @@ struct PasteCutTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) 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.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated)) harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence() await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2]) #expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2]) #expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2])
// The tombstoned one stayed behind, in the source board's trash. // The deleted one stayed behind, in the source board's trash.
#expect(try lane(clipboardLane1, in: harness.fixture)? #expect(try pasted(harness.fixture).trash.map(\.id).contains(clipboardCard1))
.cards.first { $0.id == clipboardCard1 }?.isDeleted == true)
} }
@Test("A cut emptied down to nothing is simply void — a paste copies instead") @Test("A cut emptied down to nothing is simply void — a paste copies instead")
@@ -522,14 +513,14 @@ struct PasteCutTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.cut(from: harness.store)
harness.store.delete([clipboardCard1]) harness.store.delete([clipboardCard1])
harness.store.handleWatcherEvent(.treeChanged(.appMediated)) harness.store.handleWatcherEvent(.treeChanged(.appMediated))
await harness.store.awaitQuiescence() await harness.store.awaitQuiescence()
#expect(harness.store.transient.pendingCut.isEmpty) #expect(harness.store.transient.pendingCut.isEmpty)
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
// The staged snapshot is still there, so the paste is a copy content intact. // The staged snapshot is still there, so the paste is a copy content intact.
@@ -551,7 +542,7 @@ struct PasteFallbackTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
// The snapshot goes a swept tree, a full disk, an unreadable container. // 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) at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
) )
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last) let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
@@ -585,7 +576,7 @@ struct PasteFallbackTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID) let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -593,7 +584,7 @@ struct PasteFallbackTests {
at: harness.staging.appendingPathComponent(copyID, isDirectory: true) at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
) )
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"]) #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) let target = try BoardStore(rootURL: destination.root)
// `card2` has no attachments, so a fallback loses nothing at all. // `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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID) let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -616,7 +607,7 @@ struct PasteFallbackTests {
at: harness.staging.appendingPathComponent(copyID, isDirectory: true) at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
) )
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"]) #expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
@@ -631,7 +622,7 @@ struct PasteFallbackTests {
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID) let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -643,14 +634,14 @@ struct PasteFallbackTests {
let arrived = try #require(try pasted(destination).lanes.last) let arrived = try #require(try pasted(destination).lanes.last)
#expect(arrived.title.value == "Todo") #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(arrived.cards.count == 2)
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"]) #expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
#expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"]) #expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
} }
@Test("A trash-sourced fallback still strips `deleted:` at materialization") @Test("A trash-sourced fallback materializes an ordinary card — there is no key to strip")
func trashedFallbackStripsDeleted() async throws { func trashedFallbackIsOrdinary() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
@@ -658,7 +649,7 @@ struct PasteFallbackTests {
let target = try BoardStore(rootURL: destination.root) let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], liveness: .trashed) harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID) let copyID = try #require(harness.clipboard.payload?.copyID)
@@ -666,7 +657,7 @@ struct PasteFallbackTests {
at: harness.staging.appendingPathComponent(copyID, isDirectory: true) at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
) )
target.select([destinationLane], liveness: .live) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"]) #expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
+7 -7
View File
@@ -423,18 +423,18 @@ struct RawSourceStoreTests {
func aTombstonedCardIsRefused() throws { func aTombstonedCardIsRefused() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } 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 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) let card = ItemID(rawValue: Ident.card1)
// 05 Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply writes // 05 Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply would
// the *whole* pre-tombstone `index.md` and would silently undelete the card a foreign // write a whole stale `index.md` over the trashed card a delete is never fought by a stale
// delete is never reverted by a stale buffer." The Edit buffer's flush is the opposite rule, // buffer." The Edit buffer's flush is the opposite rule, deliberately, which is why the two
// deliberately, which is why the two walks differ (`liveItem` vs `cardBodyTarget`). // walks differ (`boardItem` vs `cardBodyTarget`).
#expect(store.readCardSource(inCard: card) == .vanished) #expect(store.readCardSource(inCard: card) == .vanished)
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .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") @Test("A card that is not in the board at all is vanished too")
+48 -75
View File
@@ -105,18 +105,8 @@ private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane.")) try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item( try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
"\(Ident.lane1)/\(Ident.card1)", try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
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")
)
return fixture return fixture
} }
@@ -238,17 +228,17 @@ struct SearchFilterPredicateTests {
#expect(!SearchFilter(query: "csv").matches(unrelated)) #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") @Test("There is no lane predicate: the filter is a card predicate, end to end")
func lanesMatchByTheirOwnText() throws { func lanesAreNeverFiltered() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) let model = try load(fixture)
let todo = try #require(model.lanes.first { $0.id == lane1 })
#expect(SearchFilter(query: "todo").matches(todo)) // 04 § Search filters *cards*. Under the tombstone model the trash held lane *entries* that
#expect(SearchFilter(query: "inbox").matches(todo)) // had to be matched like rows, which is why `matches(_: Lane)` existed; lanes are never
// A lane is not matched through its cards: `card1` says "login", the lane does not. // trashed now, so the overload went with them and every lane is always visible.
#expect(!SearchFilter(query: "login").matches(todo)) 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") @Test("The visible universe keeps every live lane and only the matching cards")
@@ -257,7 +247,7 @@ struct SearchFilterPredicateTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) 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 // 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. // board, so a lane selection survives a query that empties its body.
#expect(visible.isSuperset(of: [lane1, lane2, lane3])) #expect(visible.isSuperset(of: [lane1, lane2, lane3]))
@@ -277,12 +267,9 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) let model = try load(fixture)
#expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5]) #expect(SelectionGrammar.boardCards(in: model) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3]) #expect(SelectionGrammar.boardCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
#expect(SelectionGrammar.order( #expect(SelectionGrammar.order(of: .card, in: .board, snapshot: model,
of: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login") filter: SearchFilter(query: "login")
) == [card1, card3]) ) == [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 // `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. // division is layout, and a `0` badge is the honest report.
#expect(SelectionGrammar.order( #expect(SelectionGrammar.order(of: .lane, in: .board, snapshot: model,
of: .lane,
on: .live,
in: model,
filter: SearchFilter(query: "login") filter: SearchFilter(query: "login")
) == [lane1, lane2, lane3]) ) == [lane1, lane2, lane3])
} }
@@ -310,16 +294,11 @@ struct SearchFilterOrderTests {
let model = try load(fixture) let model = try load(fixture)
// Unfiltered, card1 card3 sweeps card2 up with it. // 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]) == [card1, card2, card3])
// Filtered, the span between the same two endpoints is the two that are on screen. // Filtered, the span between the same two endpoints is the two that are on screen.
#expect(SelectionGrammar.range( #expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model,
from: card1,
to: card3,
kind: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login") filter: SearchFilter(query: "login")
) == [card1, card3]) ) == [card1, card3])
} }
@@ -330,34 +309,31 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) let model = try load(fixture)
#expect(SelectionGrammar.range( #expect(SelectionGrammar.range(from: card1, to: card2, kind: .card, in: .board, snapshot: model,
from: card1,
to: card2,
kind: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login") filter: SearchFilter(query: "login")
) == nil) ) == nil)
} }
@Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text") @Test("Trash cards participate in the filter exactly like any other card")
func trashEntriesNarrow() throws { func trashCardsNarrow() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) let model = try load(fixture)
#expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2]) #expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
#expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX]) #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") @Test("The trash's visible universe is its matching cards")
#expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1]) func trashUniverseNarrows() throws {
// The lane row matches on its *own* title, not on the card buried inside it. let fixture = try makeTrashBoard()
#expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX]) defer { fixture.tearDown() }
#expect(SelectionGrammar.trashEntries( let model = try load(fixture)
of: .lane,
in: model, #expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
filter: SearchFilter(query: "polish") #expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
).isEmpty)
} }
@Test("The delete successor is drawn from what the lane is showing") @Test("The delete successor is drawn from what the lane is showing")
@@ -367,12 +343,12 @@ struct SearchFilterOrderTests {
let model = try load(fixture) let model = try load(fixture)
// Unfiltered, deleting the untitled card lands on its lane's next card. // 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 // 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. // honest answer is nothing rather than a card the query animated out.
#expect(SelectionGrammar.successor( #expect(SelectionGrammar.successor(
afterDeleting: [card3], afterDeleting: [card3],
in: model, snapshot: model,
filter: SearchFilter(query: "login") filter: SearchFilter(query: "login")
) == nil) ) == nil)
} }
@@ -405,11 +381,11 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true store.transient.isTrashVisible = true
store.select([card2], liveness: .trashed) store.select([card2], in: .trash)
store.selectAll() store.selectAll()
#expect(store.selection.ids == [card1, card2]) #expect(store.selection.ids == [card1, card2])
store.select([card1], liveness: .trashed) store.select([card1], in: .trash)
store.searchQuery = "login" store.searchQuery = "login"
store.selectAll() store.selectAll()
#expect(store.selection.ids == [card1]) #expect(store.selection.ids == [card1])
@@ -421,7 +397,7 @@ struct SearchFilterStoreTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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" store.searchQuery = "login"
#expect(store.selection.ids == [card1]) #expect(store.selection.ids == [card1])
@@ -437,7 +413,7 @@ struct SearchFilterStoreTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], liveness: .live) store.select([lane3], in: .board)
store.searchQuery = "login" store.searchQuery = "login"
#expect(store.selection.ids == [lane3]) #expect(store.selection.ids == [lane3])
} }
@@ -449,12 +425,12 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login" store.searchQuery = "login"
store.select([card1], liveness: .live) store.select([card1], in: .board)
store.clearSearch() store.clearSearch()
#expect(store.searchQuery.isEmpty) #expect(store.searchQuery.isEmpty)
#expect(store.selection.ids == [card1]) #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") @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) let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login" 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, // 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. // so only the *filter's* universe can eject it.
@@ -502,7 +478,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login" store.searchQuery = "login"
store.select([card1], liveness: .live) store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login") store.transient.beginRename(of: card1, currentTitle: "Fix login")
#expect(store.searchQuery == "login") #expect(store.searchQuery == "login")
@@ -526,7 +502,7 @@ struct SearchFilterStoreTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login" store.searchQuery = "login"
store.select([card1], liveness: .live) store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login") store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login again") 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. // `Todo` shows both its cards under this query, so the successor is the visible neighbour.
store.searchQuery = "the" store.searchQuery = "the"
store.select([card1], liveness: .live) store.select([card1], in: .board)
store.delete([card1]) store.delete([card1])
#expect(store.selection.ids == [card2]) #expect(store.selection.ids == [card2])
} }
@@ -572,7 +548,7 @@ struct SearchFilterExemptionTests {
let filter = SearchFilter(query: "login") let filter = SearchFilter(query: "login")
// Every live lane is in the visible universe, `Done` which holds only a miss included. // 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.isSuperset(of: [lane1, lane2, lane3]))
#expect(!visible.contains(card5)) #expect(!visible.contains(card5))
@@ -623,7 +599,7 @@ struct SearchFilterExemptionTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login" store.searchQuery = "login"
store.select([card1], liveness: .live) store.select([card1], in: .board)
store.transient.beginRename(of: card1, currentTitle: "Fix login") store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login thoroughly") store.transient.updateRenameDraft("Fix login thoroughly")
@@ -644,7 +620,7 @@ struct SearchFilterExemptionTests {
#expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly") #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 { func aVanishStillDiscardsTheEditor() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -652,10 +628,7 @@ struct SearchFilterExemptionTests {
store.searchQuery = "login" store.searchQuery = "login"
store.transient.beginRename(of: card1, currentTitle: "Fix login") store.transient.beginRename(of: card1, currentTitle: "Fix login")
try fixture.item( try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
)
await reload(store) await reload(store)
#expect(store.transient.renameEditor == nil) #expect(store.transient.renameEditor == nil)
+119 -148
View File
@@ -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 /// 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 /// 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 /// modifier flags. The boards underneath are **real loads off real temp trees**, because the rules
/// here reads `isDeleted`, card ordering, or `TrashModel`'s sort, and a hand-built `BoardModel` /// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
/// would let all three drift from what the loader actually produces. /// 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`. /// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
// MARK: - Fixtures // MARK: - Fixtures
/// More literal identities than `Ident` offers: a three-lane range needs five cards, and the trash's /// More literal identities than `Ident` offers: a three-lane range needs five cards.
/// interleaving needs entries whose folder names are distinguishable in the sort's tie-break.
private enum More { private enum More {
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
static let laneY = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
} }
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { /// Three lanes and five cards enough that a flatten-order range crosses two lane boundaries.
"""
---
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.
/// ///
/// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own /// Flatten order is `[card1, card2, card3, card4, card5]`.
/// `deleted:` and is in none of it.
@MainActor @MainActor
private func makeLiveBoard() throws -> WriterFixture { private func makeLiveBoard() throws -> WriterFixture {
let fixture = try 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.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, 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.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, Item.rich(order: "3072", title: "Done"))
try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth")) try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
return fixture return fixture
} }
/// A trash whose two row kinds **interleave**, which is the only shape that can prove a range skips. /// 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).
/// `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.
@MainActor @MainActor
private func makeTrashBoard() throws -> WriterFixture { private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) 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)/\(More.card6)", Item.rich(order: "1024", title: "Live"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second", deleted: "2026-03-05T08:00:00Z")) try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "256", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "Third", deleted: "2026-03-05T06:00:00Z")) try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "512", title: "Second"))
try fixture.item(More.laneX, tombstoned(order: "2048", title: "Archive", deleted: "2026-03-05T09:00:00Z")) try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
try fixture.item(More.laneY, tombstoned(order: "3072", title: "Old", deleted: "2026-03-05T07:00:00Z"))
return fixture return fixture
} }
@@ -83,19 +66,18 @@ private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3) private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4) private let card4 = ItemID(rawValue: Ident.card4)
private let card5 = ItemID(rawValue: More.card5) private let card5 = ItemID(rawValue: More.card5)
private let laneX = ItemID(rawValue: More.laneX) private let card6 = ItemID(rawValue: More.card6)
private let laneY = ItemID(rawValue: More.laneY)
private func load(_ fixture: WriterFixture) throws -> BoardModel { private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model try BoardLoader.load(boardRoot: fixture.root).model
} }
private func target(_ id: ItemID, _ kind: SelectionKind, _ side: Liveness = .live) -> SelectionTarget { private func target(_ id: ItemID, _ kind: SelectionKind, _ container: ItemContainer = .board) -> SelectionTarget {
SelectionTarget(id: id, kind: kind, side: side) SelectionTarget(id: id, kind: kind, container: container)
} }
private func set(_ ids: Set<ItemID>, _ side: Liveness = .live) -> ItemReferenceSet { private func set(_ ids: Set<ItemID>, _ container: ItemContainer = .board) -> ItemReferenceSet {
ItemReferenceSet(ids: ids, liveness: side) ItemReferenceSet(ids: ids, container: container)
} }
/// One click, with the grammar's own defaults filled in. /// One click, with the grammar's own defaults filled in.
@@ -123,7 +105,7 @@ private func click(
@Suite("SelectionGrammar ▸ order") @Suite("SelectionGrammar ▸ order")
struct SelectionOrderTests { 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 { func flattenOrder() throws {
let fixture = try makeLiveBoard() let fixture = try makeLiveBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -131,22 +113,21 @@ struct SelectionOrderTests {
// "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, // "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). // top-to-bottom)" the multi-drag order (04-interactions.md Drag and drop).
#expect(SelectionGrammar.liveCards(in: snapshot) == [card1, card2, card3, card5]) #expect(SelectionGrammar.boardCards(in: snapshot) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3]) #expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
} }
@Test("Trash order lists are one sort, filtered to one kind") @Test("The trash's list is its cards, in `order`; its lane list is empty by construction")
func trashOrderPerKind() throws { func trashOrder() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
// The single ordering the quasi-lane shows, newest first. #expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
#expect(TrashModel.entries(of: snapshot).map(\.id) == [card1, laneX, card2, laneY, card3]) #expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
// Each kind's list is that ordering with the other kind's rows dropped which is exactly // "Cards only. Lanes are never trashed" so there is no list to walk rather than a rule
// what makes a -range step over them (04-interactions.md The trash). // saying not to (03-board-ui.md § Trash).
#expect(SelectionGrammar.trashEntries(of: .card, in: snapshot) == [card1, card2, card3]) #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
#expect(SelectionGrammar.trashEntries(of: .lane, in: snapshot) == [laneX, laneY])
} }
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none") @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([card1, card3]), in: snapshot) == .card)
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane) #expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane)
#expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil) #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. // A set claiming a container that does not hold its members answers nothing.
#expect(SelectionGrammar.kind(of: set([card4]), in: snapshot) == nil) #expect(SelectionGrammar.kind(of: set([card1], .trash), in: snapshot) == nil)
// Members that name nothing are ignored; one that names something still answers. // 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.selection == set([card1]))
#expect(card.anchor == card1) #expect(card.anchor == card1)
// Nor does the toggle reach across the boundary: a live click on a trashed sole selection // Nor does the toggle reach across the boundary: a board click on a trash-side sole
// of the same id is a replace. // 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) let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trash), anchor: lane1, in: snapshot, togglesOnRepeat: true)
#expect(crossed.selection == set([lane1])) #expect(crossed.selection == set([lane1]))
} }
} }
@@ -260,24 +241,19 @@ struct CommandClickTests {
#expect(ontoCard.anchor == card1) #expect(ontoCard.anchor == card1)
} }
@Test("⌘-click across the liveness boundary replaces too") @Test("⌘-click across the container boundary replaces too")
func acrossSideReplaces() throws { func acrossContainerReplaces() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
// "Selection is homogeneous by liveness a selection never mixes live and tombstoned" // "A selection never mixes trash cards with board cards a single container rule replacing
// (04 The trash). The live lane1 is the only live thing on this board. // the old liveness law" (04 The trash, resettled 2026-07-28).
let intoTrash = click(target(card1, .card, .trashed), .command, selection: set([lane1]), anchor: lane1, in: snapshot) let intoTrash = click(target(card1, .card, .trash), .command, selection: set([card6]), anchor: card6, in: snapshot)
#expect(intoTrash.selection == set([card1], .trashed)) #expect(intoTrash.selection == set([card1], .trash))
let backOut = click(target(lane1, .lane), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot) let backOut = click(target(card6, .card), .command, selection: set([card1, card2], .trash), anchor: card2, in: snapshot)
#expect(backOut.selection == set([lane1])) #expect(backOut.selection == set([card6]))
// 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)
} }
@Test("⌘-click with nothing — or nothing real — selected replaces") @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 // 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. // 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])) #expect(fromGhost.selection == set([card1]))
} }
} }
@@ -303,18 +280,16 @@ struct CommandClickTests {
@Suite("SelectionGrammar ▸ ⇧-click") @Suite("SelectionGrammar ▸ ⇧-click")
struct ShiftClickTests { 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 { func rangeAcrossLanes() throws {
let fixture = try makeLiveBoard() let fixture = try makeLiveBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot) 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. // Finder-list style: the anchor is unchanged, so successive -clicks sweep from one origin.
#expect(outcome.anchor == card1) #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") @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 snapshot = try load(fixture)
let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot) 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) #expect(backwards.anchor == card5)
} }
@@ -350,8 +325,9 @@ struct ShiftClickTests {
#expect(noAnchor.selection == set([card3])) #expect(noAnchor.selection == set([card3]))
#expect(noAnchor.anchor == card3) #expect(noAnchor.anchor == card3)
// An anchor that names nothing the board renders (card4 is tombstoned). // An anchor that names nothing the board renders.
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, in: snapshot) 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.selection == set([card3]))
#expect(vanished.anchor == card3) #expect(vanished.anchor == card3)
@@ -361,32 +337,35 @@ struct ShiftClickTests {
#expect(acrossKind.selection == set([card3])) #expect(acrossKind.selection == set([card3]))
#expect(acrossKind.anchor == card3) #expect(acrossKind.anchor == card3)
// Same for a side crossing: the live card list holds no trash row. // Same for a container crossing: the board's card list holds no trash card.
let acrossSide = click(target(card3, .card, .trashed), .shift, selection: set([card1]), anchor: card1, in: snapshot) let acrossContainer = click(target(card3, .card, .trash), .shift, selection: set([card1]), anchor: card1, in: snapshot)
#expect(acrossSide.selection == set([card3], .trashed)) #expect(acrossContainer.selection == set([card3], .trash))
} }
@Test("A trash card range steps over the lane entries inside its span") @Test("A trash range walks the column's own order")
func trashCardRangeSkipsLaneEntries() throws { func trashRangeWalksTheColumn() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
// Sorted order is [card1, laneX, card2, laneY, card3]; a card range collects card rows only. // The column's order is `[card1, card2, card3]`; a range between the ends takes all three,
let outcome = click(target(card3, .card, .trashed), .shift, selection: set([card1], .trashed), anchor: card1, in: snapshot) // with no kind to step over lanes are never trashed.
#expect(outcome.selection == set([card1, card2, card3], .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) #expect(outcome.anchor == card1)
} }
@Test("A trash lane range steps over the card entries inside its span") @Test("A range never crosses the container boundary")
func trashLaneRangeSkipsCardEntries() throws { func trashRangeStopsAtTheBoundary() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
let outcome = click(target(laneY, .lane, .trashed), .shift, selection: set([laneX], .trashed), anchor: laneX, in: snapshot) // `card6` is on the board and `card1` is in the trash: no single order list holds both, so
#expect(outcome.selection == set([laneX, laneY], .trashed)) // the anchor is not findable and the -click degrades to a plain one.
#expect(outcome.anchor == laneX) 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") @Suite("MarqueeMath")
struct MarqueeMathTests { struct MarqueeMathTests {
private static func card(_ id: ItemID, _ y: CGFloat, side: Liveness = .live) -> MarqueeTarget { private static func card(_ id: ItemID, _ y: CGFloat, container: ItemContainer = .board) -> MarqueeTarget {
MarqueeTarget(id: id, kind: .card, side: side, frame: CGRect(x: 0, y: y, width: 100, height: 40)) 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 { @Test("On the board side the band takes intersecting cards, and only cards")
MarqueeTarget(id: id, kind: .lane, side: .trashed, frame: CGRect(x: 0, y: y, width: 100, height: 40)) func boardSideTakesCards() {
}
@Test("On the live side the band takes intersecting cards, and only cards")
func liveSideTakesCards() {
let targets = [ let targets = [
Self.card(card1, 0), Self.card(card1, 0),
Self.card(card2, 100), Self.card(card2, 100),
// A lane registered by mistake is still never swept: "click-drag rubber-bands across // A lane registered by mistake is still never swept: "click-drag rubber-bands across
// lanes" (04-interactions.md § Selection) across, not over. // 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. // 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]) #expect(ids == [card1, card2])
} }
@Test("On the trash side the topmost intersecting row's kind wins") /// **There is no kind rule any more.** The tombstone model interleaved card rows and lane rows
func trashSideIsHomogeneousByKind() { /// in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind; lanes
// Interleaved rows, the trash's own shape: card, lane, card. /// 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 = [ let targets = [
Self.card(card1, 0, side: .trashed), Self.card(card1, 0, container: .trash),
Self.laneEntry(laneX, 50), Self.card(card2, 100, container: .trash),
Self.card(card2, 100, side: .trashed) Self.card(card3, 50)
] ]
let all = CGRect(x: 0, y: 0, width: 50, height: 200) 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 #expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2])
// -range does (04-interactions.md The trash). #expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
#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])
} }
@Test("A band touching nothing selects nothing") @Test("A band touching nothing selects nothing")
func emptyBand() { func emptyBand() {
let targets = [Self.card(card1, 0), Self.card(card2, 100)] 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: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, in: .board).isEmpty)
#expect(MarqueeMath.selection(rect: .zero, targets: [], side: .trashed).isEmpty) #expect(MarqueeMath.selection(rect: .zero, targets: [], in: .trash).isEmpty)
} }
} }
@@ -463,15 +435,15 @@ struct SelectionAnchorTests {
func anchorDefaults() { func anchorDefaults() {
let state = TransientBoardState() let state = TransientBoardState()
state.select([card1], liveness: .live) state.select([card1], in: .board)
#expect(state.selectionAnchor == card1) #expect(state.selectionAnchor == card1)
// "A marquee and wholesale selections pass no anchor deliberately." // "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) #expect(state.selectionAnchor == nil)
// An explicit anchor wins over the default in both directions. // 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) #expect(state.selectionAnchor == card2)
state.clearSelection() state.clearSelection()
@@ -484,7 +456,7 @@ struct SelectionAnchorTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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. // 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)")) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
await reload(store) await reload(store)
@@ -497,17 +469,18 @@ struct SelectionAnchorTests {
#expect(store.transient.selectionAnchor == nil) #expect(store.transient.selectionAnchor == nil)
} }
@Test("A liveness flip is a vanish for the anchor too") @Test("A container crossing is a vanish for the anchor too")
func flippedAnchorIsDropped() async throws { func crossedAnchorIsDropped() async throws {
let fixture = try makeLiveBoard() let fixture = try makeLiveBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1) store.select([card1, card2], in: .board, anchor: 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) 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.selection.ids == [card2])
#expect(store.transient.selectionAnchor == nil) #expect(store.transient.selectionAnchor == nil)
} }
@@ -520,7 +493,7 @@ struct SelectionAnchorTests {
let state = TransientBoardState() let state = TransientBoardState()
// A -click that toggled the anchor's own row out leaves the anchor standing. // 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) #expect(state.selectionAnchor == card1)
let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot) let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot)
#expect(outcome.selection == set([card1, card2, card3])) #expect(outcome.selection == set([card1, card2, card3]))
@@ -533,17 +506,17 @@ struct SelectionAnchorTests {
@Suite("BoardStore ▸ Select All") @Suite("BoardStore ▸ Select All")
struct SelectAllTests { 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 { func liveBranch() throws {
let fixture = try makeLiveBoard() let fixture = try makeLiveBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([lane2], liveness: .live) store.select([lane2], in: .board)
store.selectAll() store.selectAll()
// "All visible cards on the board" (04-interactions.md The map). // "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. // The lane the anchor named is not in the new set, so the anchor goes with it.
#expect(store.transient.selectionAnchor == nil) #expect(store.transient.selectionAnchor == nil)
} }
@@ -554,25 +527,23 @@ struct SelectAllTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([card3], liveness: .live) store.select([card3], in: .board)
store.selectAll() store.selectAll()
#expect(store.transient.selectionAnchor == card3) #expect(store.transient.selectionAnchor == card3)
} }
@Test("On the trash side Select All stays within the selection's kind") @Test("On the trash side Select All takes every visible trash card")
func trashBranchIsHomogeneousByKind() throws { func trashBranchTakesTheColumn() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true 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() store.selectAll()
#expect(store.selection == set([card1, card2, card3], .trashed)) #expect(store.selection == set([card1, card2, card3], .trash))
store.select([laneX], liveness: .trashed)
store.selectAll()
#expect(store.selection == set([laneX, laneY], .trashed))
} }
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board") @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) let store = try BoardStore(rootURL: fixture.root)
// Hidden the column is invisible to every gesture (04 The trash). // Hidden the column is invisible to every gesture (04 The trash).
store.select([card1], liveness: .trashed) store.select([card1], in: .trash)
store.selectAll() 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.transient.isTrashVisible = true
store.clearSelection() store.clearSelection()
store.selectAll() 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. // Shown, trash side, but the ids name no card there: a guess would be worse than the board.
store.select([card5], liveness: .trashed) store.select([card5], in: .trash)
store.selectAll() 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") @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")) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([lane1], liveness: .live) store.select([lane1], in: .board)
store.selectAll() store.selectAll()
#expect(store.selection.isEmpty) #expect(store.selection.isEmpty)
#expect(store.transient.selectionAnchor == nil) #expect(store.transient.selectionAnchor == nil)
+5 -5
View File
@@ -109,7 +109,7 @@ struct StyleEditorSessionTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card1, card2])) 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) await reload(store)
let session = try #require(store.transient.styleEditor) let session = try #require(store.transient.styleEditor)
@@ -126,7 +126,7 @@ struct StyleEditorSessionTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card3])) 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) await reload(store)
// The card's own flag never changed its lane's did. Effective liveness is ancestor-walked, // 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) let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card1])) 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) await reload(store)
#expect(store.transient.styleEditor?.target != .board) #expect(store.transient.styleEditor?.target != .board)
@@ -155,8 +155,8 @@ struct StyleEditorSessionTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .board) store.transient.beginStyleEditor(for: .board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo")) try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing")) try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
await reload(store) await reload(store)
#expect(store.transient.styleEditor?.target == .board) #expect(store.transient.styleEditor?.target == .board)
+13 -12
View File
@@ -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.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.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, 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.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")) try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
return fixture return fixture
} }
@@ -256,26 +256,27 @@ struct StyleWriteTests {
#expect(lane(lane1, in: model)?.background.isMissing == true) #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 { func skipsTargetsThatRenderNowhere() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } 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 store = try BoardStore(rootURL: fixture.root)
let log = BracketLog() let log = BracketLog()
log.attach(to: store) log.attach(to: store)
let tombstoned = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") let trashedBytes = try fixture.indexData(".trash/\(Ident.card3)")
let hidden = try fixture.indexData("\(Ident.lane4)/\(Ident.card4)")
// An id that names nothing, a tombstoned card, and a live card under a tombstoned lane // An id that names nothing, and a card that has been moved to the trash "everything
// "nothing is ever written into a vanished folder", ancestor walk included. // edit-shaped is disabled on trash selections" (04 The trash), and "nothing is ever
// written into a vanished folder".
store.applyStyle( store.applyStyle(
to: .items([ItemID(rawValue: Ident.indexless), card3, card4]), to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
background: .set("obsidian") background: .set("obsidian")
) )
#expect(log.begins == 0) #expect(log.begins == 0)
#expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == tombstoned) #expect(try fixture.indexData(".trash/\(Ident.card3)") == trashedBytes)
#expect(try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") == hidden)
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@@ -314,13 +315,13 @@ struct StyleWriteTests {
// MARK: Subjects and levels // MARK: Subjects and levels
@Test("Subjects are the live targets in display order, with their current values") @Test("Subjects are the board's targets in display order, with their current values")
func subjectsAreLiveTargetsInDisplayOrder() throws { func subjectsAreBoardTargetsInDisplayOrder() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
#expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing]) #expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
#expect(subjects.last?.folder.lastPathComponent == Ident.card2) #expect(subjects.last?.folder.lastPathComponent == Ident.card2)
+7 -7
View File
@@ -328,19 +328,19 @@ struct StoreToggleTaskMarkerTests {
#expect(try fixture.indexData(cardPath) == before) #expect(try fixture.indexData(cardPath) == before)
} }
@Test("A tombstoned card's checkbox does not write") @Test("A trashed card's checkbox does not write")
func aTombstonedCardWritesNothing() throws { func aTrashedCardWritesNothing() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } 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 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) let offsets = markerOffsets(in: checklistBody)
// Effective liveness, ancestor-walked (`BoardStore.liveItem`): a card in the trash renders // `BoardStore.boardItem` is the board container and only it: a trashed card does not open,
// nowhere, so nothing may write through a preview of it. // 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) 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)
} }
} }
+66 -72
View File
@@ -76,9 +76,9 @@ struct TransientBoardStateTests {
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged, // 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 // card3 is dragged and cut. Whatever happens to one member, two of the three sets are
// always the control. // always the control.
store.transient.select([card1, card2], liveness: .live) store.transient.select([card1, card2], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live) store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live) store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
await reload(store) await reload(store)
@@ -98,7 +98,7 @@ struct TransientBoardStateTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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 // 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 // body both miss the query, so it is not in the visible set the predicate produced, and
@@ -116,74 +116,70 @@ struct TransientBoardStateTests {
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule") #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") @Test("A container crossing is a vanish for every item-referencing set")
func effectiveLivenessEjectsFromEverySet() async throws { func aContainerCrossingEjectsFromEverySet() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, card2], liveness: .live) store.transient.select([card1, card2], in: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live) 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) await reload(store)
// The cards' own flags never changed, but their lane's did and liveness is ancestor-walked // 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches
// (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and // UUID *and* container side a foreign move that trashes a selected board card ejects it
// nothing invisible may stay selected or pending-cut. // from the selection (and from the pending cut)".
let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 } let trashedIDs = Set(store.snapshot.trash.map(\.id))
#expect(survivor?.isDeleted == false, "the card's own flag is untouched") #expect(trashedIDs == Set([card1, card2]))
#expect(store.transient.selection.ids.isEmpty) #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") @Test("A restore ejects a trash-side set the same way, in the other direction")
func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws { func restoringEjectsATrashSideSet() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
// Everything that can point at an item, all aimed at card3 and on the *trashed* side, as // Everything that can point at an item, all aimed at the trashed card.
// if the user had clicked its trash row a moment before its lane went too. store.transient.select([card3], in: .trash, anchor: card3, head: card3)
store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3) store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed) store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed)
store.transient.beginRename(of: card3, currentTitle: "Third") 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. // An agent moves it back out a container crossing the other way.
try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third")) try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store) await reload(store)
let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 }) #expect(store.snapshot.trash.isEmpty)
#expect(lane.isDeleted) #expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3))
#expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk") #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.selection.ids.isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty) #expect(store.transient.dragMembers.ids.isEmpty)
#expect(store.transient.pendingCut.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 // And no cursor survives on it: an anchor that ranges from a container the selection has left
// draws nowhere would be a range the user cannot see the origin of. // would be a range the user cannot see the origin of.
#expect(store.transient.selectionAnchor == nil) #expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == 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: // The rename editor tracks the *board* container, so a card arriving back on the board keeps
// neither twin offers to act on it. // its editor "a foreign move mid-rename is invisible" (04 Grammar). It is the departure
let stale = ItemReferenceSet(ids: [card3], liveness: .trashed) // into the trash that discards it, which the delete tests cover.
#expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot)) #expect(store.transient.renameEditor?.targetID == card3)
#expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live),
// 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)) in: store.snapshot))
} }
@@ -193,9 +189,9 @@ struct TransientBoardStateTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1, lane1], liveness: .live) store.transient.select([card1, lane1], in: .board)
store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live) store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live) store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board)
store.transient.beginPlaceholder(inLane: lane2) store.transient.beginPlaceholder(inLane: lane2)
try FileManager.default.removeItem(at: fixture.url(Ident.lane1)) try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
@@ -238,13 +234,12 @@ struct TransientBoardStateTests {
store.transient.beginPlaceholder(inLane: lane2) store.transient.beginPlaceholder(inLane: lane2)
store.transient.updateDraft("Half a title") 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) await reload(store)
// Still in the snapshot the trash renders its single entry but the lane the editor was // A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in
// sitting in is not on the board any more, which is the same vanish as far as an overlay // the reload" is literally the whole test.
// anchored to it is concerned. #expect(store.snapshot.lanes.first { $0.id == lane2 } == nil)
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true)
#expect(store.transient.newCardPlaceholder == nil) #expect(store.transient.newCardPlaceholder == nil)
} }
@@ -376,37 +371,36 @@ struct TransientBoardStateTests {
#expect(store.transient.renameEditor == nil) #expect(store.transient.renameEditor == nil)
} }
@Test("A rename whose target is tombstoned is discarded — a liveness flip is a vanish") @Test("A rename whose target enters the trash is discarded — a container crossing is a vanish")
func renameDiscardedWhenItsTargetIsTombstoned() async throws { func renameDiscardedWhenItsTargetIsTrashed() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First") 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) 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) #expect(store.transient.renameEditor == nil)
} }
@Test("A rename under a tombstoned lane is discarded too — liveness is effective") @Test("A rename whose lane is deleted is discarded too — the card went with it")
func renameDiscardedWhenItsLaneIsTombstoned() async throws { func renameDiscardedWhenItsLaneIsDeleted() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.transient.beginRename(of: card1, currentTitle: "First") 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) await reload(store)
// The card's own flag never changed; its lane's did. The ancestor walk is absolute the // A lane delete is physical, so the card is simply not in the snapshot no ancestor walk.
// card renders nowhere, so the editor sitting on it has no target #expect(store.snapshot.lanes.first { $0.id == lane1 } == nil)
// (`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)
#expect(store.transient.renameEditor == 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") #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) #expect(store.transient.lastActiveLaneID == lane2)
// A *card* selection is its lane holding selection too 04's "the lane that most recently // A *card* selection is its lane holding selection too 04's "the lane that most recently
// held selection or a creation". // held selection or a creation".
store.select([card1], liveness: .live) store.select([card1], in: .board)
#expect(store.transient.lastActiveLaneID == lane1) #expect(store.transient.lastActiveLaneID == lane1)
// A cross-lane selection names no single lane, so it leaves the memory alone rather than // A cross-lane selection names no single lane, so it leaves the memory alone rather than
// guessing at one of the two. // guessing at one of the two.
store.select([card1, card3], liveness: .live) store.select([card1, card3], in: .board)
#expect(store.transient.lastActiveLaneID == lane1) #expect(store.transient.lastActiveLaneID == lane1)
// Deselecting does not un-happen where the user was working: N with nothing selected is // 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) store.transient.beginPlaceholder(inLane: lane2)
#expect(store.transient.lastActiveLaneID == 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) await reload(store)
// A lane that renders nowhere is no target at all; `NewCardTarget` then falls through to // A lane that is gone is no target at all; `NewCardTarget` then falls through to the first
// the first lane rather than proposing the trash. // lane rather than proposing the trash.
#expect(store.transient.lastActiveLaneID == nil) #expect(store.transient.lastActiveLaneID == nil)
} }
+273 -385
View File
@@ -2,476 +2,364 @@ import Foundation
import Testing import Testing
@testable import Kanban @testable import Kanban
/// `TrashModel` is the trash quasi-lane's whole heart, and it is a pure function so this suite is /// What is left of the trash as a *model* once the trash became a folder 03-board-ui.md § Trash,
/// the executable form of 03-board-ui.md § Trash Contents: the deterministic sort, the absolute /// resettled 2026-07-28 (the materialized trash).
/// ancestor walk, and what a lane entry's card count actually counts. ///
/// **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 /// 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<Date>` shapes a valid /// other model suite here does it: only the loader produces `.trash` the way production does.
/// stamp, an unparseable one, an absent key and only the loader produces those the way production /// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
/// does. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixtures // MARK: - Fixtures
/// A few more literal identities than `Ident` offers: the sort tests need enough rows to prove an /// A few more literal identities than `Ident` offers folder name is the display tie-break, so the
/// *ordering* rather than a comparison, and folder name is the tie-break, so the names matter. /// names matter.
private enum More { private enum More {
static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" 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 cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff" 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 card(order: String, title: String) -> String {
private func tombstoned(order: String, title: String, deleted: String) -> String { "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: \(deleted)
---
\(title) body.
"""
} }
private func live(order: String, title: String) -> String { private func untitled(order: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" "---\nschema: 1\norder: \(order)\n---\nNo title.\n"
} }
private func load(_ fixture: WriterFixture) throws -> BoardModel { private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model try BoardLoader.load(boardRoot: fixture.root).model
} }
private func ids(_ entries: [TrashEntry]) -> [String] { /// One lane with two cards, and three cards in the board's trash.
entries.map(\.id.rawValue) @MainActor
} private func makeBoard() throws -> WriterFixture {
// MARK: - The sort
@Suite("TrashModel ▸ sort")
struct TrashModelSortTests {
@Test("Dated entries sort newest first, and lane entries interleave by their own stamp")
func newestFirstWithLanesInterleaved() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Oldest card", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Newest card", deleted: "2026-03-03T09:00:00Z"))
// A tombstoned lane whose own stamp falls between the two cards': the sort is one ordering
// over both kinds, not cards-then-lanes.
try fixture.item(Ident.lane2,
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z"))
let entries = TrashModel.entries(of: try load(fixture))
#expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1])
#expect(entries[1].isLaneEntry)
}
@Test("Ties on the second break by folder name, ascending")
func tiesBreakByFolderName() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
// One multi-card stamps one second onto N cards. The three are written in an order that
// is neither their folder-name order nor their `order` order, so only the rule can produce
// the expectation below.
let stamp = "2026-03-03T09:00:00Z"
try fixture.item("\(Ident.lane1)/\(More.cardF)", tombstoned(order: "1024", title: "F", deleted: stamp))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "2048", title: "5", deleted: stamp))
try fixture.item("\(Ident.lane1)/\(More.cardD)", tombstoned(order: "3072", title: "D", deleted: stamp))
let entries = TrashModel.entries(of: try load(fixture))
#expect(ids(entries) == [Ident.card1, More.cardD, More.cardF])
}
@Test("An unparseable stamp sorts oldest — after every dated entry, however old")
func unparseableSortsOldest() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Corrupt", deleted: "whenever"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Ancient", deleted: "1999-01-01T00:00:00Z"))
let model = try load(fixture)
// The premise: the key is present but has no date reading still a tombstone (presence,
// not validity), and still with no position in time.
let corrupt = try #require(model.lanes.first?.cards.first { $0.id.rawValue == Ident.card1 })
#expect(corrupt.isDeleted)
#expect(corrupt.deleted.value == nil)
// A corrupt stamp must not outrank a fresh deletion for the trash's most prominent rows
// and here it does not even outrank a 1999 one.
#expect(ids(TrashModel.entries(of: model)) == [Ident.card2, Ident.card1])
}
@Test("Undated entries order by folder name among themselves, lanes included")
func undatedOrderByFolderName() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(More.laneB, tombstoned(order: "3072", title: "B lane", deleted: "not-a-date"))
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(More.cardE)", tombstoned(order: "1024", title: "E", deleted: "corrupt"))
try fixture.item(More.laneA, tombstoned(order: "2048", title: "A lane", deleted: "later"))
// `a < b < e`: one folder-name ordering across both kinds, exactly as the dated half has
// one date ordering across both kinds.
#expect(ids(TrashModel.entries(of: try load(fixture))) == [More.laneA, More.laneB, More.cardE])
}
}
// MARK: - The ancestor walk and the returning count
@Suite("TrashModel ▸ contents")
struct TrashModelContentsTests {
/// The board every test below reads: one live lane holding a tombstoned card and a live one, and
/// one tombstoned lane holding a live card, a card with its own flag, and another live card.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First"))
tombstoned(order: "1024", title: "Trashed card", deleted: "2026-03-03T09:00:00Z")) try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", live(order: "2048", title: "Live card")) // Newest-first by ordinary ranks: every arrival mints above the current top.
try fixture.item(Ident.lane2, try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "Oldest"))
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z")) try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", live(order: "1024", title: "Rides along")) try fixture.item(".trash/\(More.cardF)", card(order: "256", title: "Newest"))
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 return fixture
} }
@Test("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute") private let laneA = ItemID(rawValue: More.laneA)
func ownFlagUnderTombstonedLaneHasNoRow() throws { 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)
// MARK: - The contents
/// **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() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture)
let entries = TrashModel.entries(of: try load(fixture)) #expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"],
// `cardD`'s stamp is the newest on the board, so if it had a row at all it would be the "03 ▸ Trash: the trash sorts by `order` like any lane, and entry is at the top")
// first one. The lane's single entry subsumes it instead. #expect(snapshot.trash.allSatisfy { $0.deleted.isMissing },
#expect(ids(entries) == [Ident.card1, Ident.lane2]) "there is no `deleted:` key and no timestamp sort")
#expect(!ids(entries).contains(More.cardD))
} }
@Test("A lane entry counts what Put Back returns — cards without their own flag") @Test("Lanes are never in it, whatever a hand-editor nests in there")
func returningCountExcludesOwnFlaggedCards() throws { func lanesAreNeverTrashed() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
// 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(!snapshot.trash.map(\.id).contains(ItemID(rawValue: Ident.card4)))
let lane = try #require(entries.first { $0.id.rawValue == Ident.lane2 }) #expect(snapshot.lanes.map(\.id) == [laneA], "and nothing in there is a lane")
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") @Test("An absent container is an empty trash")
func emptyLaneEntryCountsZero() throws { func absentIsEmpty() throws {
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Empty", deleted: "2026-03-03T09:00:00Z")) try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
let entries = TrashModel.entries(of: try load(fixture)) #expect(try load(fixture).trash.isEmpty)
#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> = [
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)
} }
} }
// MARK: - Counts, phrasing, and the confirmations // MARK: - ItemPath
/// 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 {
@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("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<ItemID> = [laneA, card1, card2, cardD, cardE, cardF]
#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 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)
#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: - 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<ItemID> = []
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") @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() { func pluralFolding() {
#expect(TrashModel.phrase(.init(lanes: 0, cards: 41)) == "41 cards") #expect(TrashModel.phrase(1) == "1 card")
#expect(TrashModel.phrase(.init(lanes: 0, cards: 1)) == "1 card") #expect(TrashModel.phrase(41) == "41 cards")
#expect(TrashModel.phrase(.init(lanes: 1, cards: 0)) == "1 lane") #expect(TrashModel.phrase(0) == "0 cards")
#expect(TrashModel.phrase(.init(lanes: 3, cards: 0)) == "3 lanes")
#expect(TrashModel.phrase(.init(lanes: 2, cards: 3)) == "2 lanes and 3 cards")
#expect(TrashModel.phrase(.init(lanes: 1, cards: 1)) == "1 lane and 1 card")
#expect(TrashModel.phrase(.init()) == "nothing")
} }
@Test("Entry counts split lane entries from card entries") @MainActor
func entryCounts() throws { @Test("A sole card is named; several fold into a count")
let fixture = try WriterFixture() func purgePromptNamesOrCounts() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) let snapshot = try load(fixture)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Gone", deleted: "2026-03-03T09:00:00Z"))
let counts = TrashModel.counts(of: TrashModel.entries(of: try load(fixture)))
#expect(counts == TrashModel.EntryCounts(lanes: 1, cards: 2))
#expect(counts.total == 3)
#expect(!counts.isEmpty)
}
@Test("Delete Immediately names a sole item and folds several into counts")
func purgePrompt() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Ship it", deleted: "2026-03-02T09:00:00Z"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-03T09:00:00Z"))
let model = try load(fixture)
let sole = try #require(TrashModel.purgePrompt( let sole = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: true)) for: [cardF], in: .trash, snapshot: snapshot, unrecoverable: true
#expect(sole.title == "Permanently delete \u{201C}Fix login\u{201D}?") ))
#expect(sole.title == "Permanently delete \u{201C}Newest\u{201D}?")
#expect(sole.message == "This can\u{2019}t be undone.") #expect(sole.message == "This can\u{2019}t be undone.")
#expect(sole.confirmTitle == "Delete") #expect(sole.confirmTitle == "Delete")
let several = try #require(TrashModel.purgePrompt( let several = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], for: [cardE, cardF], in: .trash, snapshot: snapshot, unrecoverable: true
in: model, unrecoverable: true)) ))
#expect(several.title == "Permanently delete 2 cards?") #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") @MainActor
func untitledPrompt() throws { @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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", try fixture.item(".trash/\(More.cardD)", untitled(order: "1024"))
"---\nschema: 1\norder: 1024\ndeleted: 2026-03-01T09:00:00Z\n---\nbody\n") let snapshot = try load(fixture)
let prompt = try #require(TrashModel.purgePrompt( 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}?") #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 { 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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", #expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true))
// Counts even for a small trash: the command is about the trash, not about an item.
#expect(prompt.title == "Permanently delete 2 cards?")
} }
} }
// MARK: - Menu validation // MARK: - Menu validation
@MainActor
@Suite("TrashModel ▸ validation") @Suite("TrashModel ▸ validation")
struct TrashModelValidationTests { struct TrashValidationTests {
private func makeBoard() throws -> WriterFixture { /// **One Delete, one predicate.** The tombstone model needed a mirror-image pair so two twins
let fixture = try WriterFixture() /// could enable exactly one of themselves; Put Back's retirement left one item, so the predicate
try fixture.item("", Item.board) /// is "does this selection name anything", asked in the selection's own container.
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo")) @Test("Delete enables for either container, and for nothing that names nothing")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Live")) func canDeleteIsStagedNotSplit() throws {
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Trashed", deleted: "2026-03-01T09:00:00Z"))
return fixture
}
@Test("The ⌘⌫ twins enable exactly one of themselves, by the selection's liveness side")
func chordTwinsAreBinary() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } 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: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
#expect(TrashModel.canDelete(selection: liveSelection, in: model)) #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot))
#expect(!TrashModel.canActOnTrash(selection: liveSelection, in: model)) #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: .empty, in: snapshot))
#expect(!TrashModel.canDelete(selection: trashedSelection, in: model)) // A selection the next reload will drop: the id is real, but not in the container it claims.
#expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model)) #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") @Test("Delete Immediately is cards only, in either container")
func nothingToActOn() throws { func canDeleteImmediatelyIsCardsOnly() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) let snapshot = try load(fixture)
#expect(!TrashModel.canDelete(selection: .empty, in: model)) #expect(TrashModel.canDeleteImmediately(
#expect(!TrashModel.canActOnTrash(selection: ItemReferenceSet(ids: [], liveness: .trashed), in: model)) selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot
))
// A selection the next reload will drop: the id names nothing, so the item that would write #expect(TrashModel.canDeleteImmediately(
// nothing does not look available. selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot
let stale = ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live) ))
#expect(!TrashModel.canDelete(selection: stale, in: model)) #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")
@Test("A selection whose side disagrees with the item's own flag enables neither") #expect(!TrashModel.canDeleteImmediately(selection: .empty, in: snapshot))
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))
} }
} }
File diff suppressed because it is too large Load Diff
+151 -108
View File
@@ -18,7 +18,10 @@ import Testing
// MARK: - Fixtures // MARK: - Fixtures
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { /// A card sitting in `<root>/.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 schema: 1
@@ -26,7 +29,6 @@ private func tombstoned(order: String, title: String, deleted: String = "2026-03
order: \(order) order: \(order)
project: lanework # agent overlay project: lanework # agent overlay
created: 2026-01-01T09:00:00Z created: 2026-01-01T09:00:00Z
deleted: \(deleted)
--- ---
\(title) body. \(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 /// Two lanes the first with two plain cards and a styled one, the second with one card plus one
/// with one card. Enough for every inverse in this file. /// card already sitting in the board's `.trash/`. Enough for every inverse in this file.
@MainActor @MainActor
private func makeBoard() throws -> WriterFixture { private func makeBoard() throws -> WriterFixture {
let fixture = try 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.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.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard) 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, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere")) 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 return fixture
} }
@@ -74,7 +76,7 @@ private let elsewhere = ItemID(rawValue: Ident.indexless)
private let card1Path = "\(Ident.lane1)/\(Ident.card1)" private let card1Path = "\(Ident.lane1)/\(Ident.card1)"
private let card2Path = "\(Ident.lane1)/\(Ident.card2)" private let card2Path = "\(Ident.lane1)/\(Ident.card2)"
private let card3Path = "\(Ident.lane1)/\(Ident.card3)" 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** /// 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 /// 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() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
store.select([card1], liveness: .live) store.select([card1], in: .board)
store.sortSelection(.down) store.sortSelection(.down)
@@ -506,13 +508,13 @@ struct MoveUndoTests {
} }
} }
// MARK: - The trash pair // MARK: - Delete
@MainActor @MainActor
@Suite("Undo ▸ the trash pair") @Suite("Undo ▸ delete")
struct TrashUndoTests { 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 { func deleteRoundTrip() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -520,20 +522,42 @@ struct TrashUndoTests {
let before = try fixture.indexText(card1Path) let before = try fixture.indexText(card1Path)
store.delete([card1]) 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") #expect(history.undoActionName == "Delete Card")
history.undo() 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) let undone = try fixture.indexText(card1Path)
#expect(try FrontmatterDocument.parse(undone).deleted.isMissing) #expect(try FrontmatterDocument.parse(undone).order.value == 1024, "at its original rank")
// Byte-identical but for the stamp: the tombstone and its inverse are one key each. // Byte-identical but for the stamps every app write owns no `deleted:` key was ever
#expect(untouchedLines(undone).filter { !$0.hasPrefix("deleted:") } == untouchedLines(before)) // written, so there is none to come back and none to remove.
#expect(untouchedLines(undone) == untouchedLines(before))
history.redo() 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 { func batchDeleteIsOneStep() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -542,69 +566,74 @@ struct TrashUndoTests {
store.delete([card1, card2]) store.delete([card1, card2])
#expect(history.undoActionName == "Delete 2 Cards") #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() history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing) #expect(fixture.exists(card1Path))
#expect(try document(fixture, card2Path).deleted.isMissing) #expect(fixture.exists(card2Path))
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "only the board's own resident is left")
#expect(history.canUndo == false) #expect(history.canUndo == false)
} }
@Test("A lane delete is named for the lane") @Test("A lane delete is physical, and its undo recreates the folder byte for byte")
func laneDeleteIsNamedForTheLane() throws { func laneDeleteRecreatesTheSubtree() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) 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]) 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") #expect(history.undoActionName == "Delete Lane")
history.undo() 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") @Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse")
func putBackRoundTrip() throws { func restoreIsAnOrdinaryMove() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) 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]) store.moveCards([trashed], toLane: lane2, at: 0)
#expect(try document(fixture, trashedPath).deleted.isMissing)
#expect(history.undoActionName == "Restore Card")
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(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing) #expect(!fixture.exists(trashedPath))
#expect(history.undoActionName == "Restore Card") #expect(history.undoActionName == "Move Card",
"13: a restore is an ordinary move between containers, named as one")
history.undo() history.undo()
#expect(fixture.exists(trashedPath), "back in the lane it was trashed in") #expect(fixture.exists(trashedPath), "back in the trash it came out of")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)") == false) #expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
let undone = try document(fixture, trashedPath) #expect(try document(fixture, trashedPath).order.value == trashRank,
#expect(undone.deleted.value == filedUnder) "at the rank it was filed under")
#expect(undone.order.value == 4096, "at the rank it was trashed holding")
history.redo() history.redo()
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #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 // MARK: - The Edit session
@MainActor @MainActor
@@ -703,6 +732,7 @@ struct NotUndoableTests {
store.delete([card1]) store.delete([card1])
let armed = try #require(history.undoActionName) let armed = try #require(history.undoActionName)
store.select([trashed], in: .trash)
store.deleteImmediately([trashed]) store.deleteImmediately([trashed])
#expect(fixture.exists(trashedPath) == false) #expect(fixture.exists(trashedPath) == false)
@@ -758,7 +788,7 @@ struct NotUndoableTests {
store.delete([card1]) store.delete([card1])
store.setLaneWidth(lane1, units: 2) 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(try document(fixture, Ident.lane1).width.value == 2)
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@@ -803,9 +833,9 @@ struct CrossingIsAWriteTests {
store.delete([card1]) store.delete([card1])
for _ in 0 ..< 3 { for _ in 0 ..< 3 {
history.undo() history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing) #expect(fixture.exists(card1Path))
history.redo() 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) _ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body)
} }
static func delete(_ fixture: WriterFixture, _ path: String) throws { /// A foreign delete of a **card** the shape a delete has on disk now: the folder moves into
try BoardWriter.deleteItem(at: fixture.url(path)) /// `.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 { static func purge(_ fixture: WriterFixture, _ path: String) throws {
@@ -886,12 +923,13 @@ struct StaleStepTests {
store.transient.updateRenameDraft("Second!") store.transient.updateRenameDraft("Second!")
store.commitRename() store.commitRename()
try Foreign.delete(fixture, card2Path) try Foreign.trash(fixture, card2Path, id: Ident.card2)
history.undo() history.undo()
// The top step's card is in the trash now, so its rename is not ours to walk back; the one // 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. // 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(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something")
#expect(store.banners.signposts.map(\.message) #expect(store.banners.signposts.map(\.message)
== ["Undo skipped — 'Second!' changed outside Lanework"]) == ["Undo skipped — 'Second!' changed outside Lanework"])
@@ -906,29 +944,33 @@ struct StaleStepTests {
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
store.delete([card1]) store.delete([card1])
try Foreign.purge(fixture, card1Path) try Foreign.purge(fixture, ".trash/\(Ident.card1)")
history.undo() history.undo()
#expect(fixture.exists(card1Path) == false) #expect(fixture.exists(card1Path) == false)
#expect(!fixture.exists(".trash/\(Ident.card1)"))
#expect(store.banners.signposts.count == 1) #expect(store.banners.signposts.count == 1)
#expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row") #expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row")
#expect(history.canUndo == false) #expect(history.canUndo == false)
#expect(history.canRedo == false, "a skipped step leaves nothing behind") #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 `<root>/.trash/<id>`, 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 { func aForeignRestoreSkipsTheDeleteStep() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
store.delete([card1]) store.delete([card1])
try BoardWriter.restoreItem(at: fixture.url(card1Path)) try fixture.move(".trash/\(Ident.card1)", toLane: Ident.lane2, card: Ident.card1)
history.undo() 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"]) #expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
} }
@@ -949,23 +991,25 @@ struct StaleStepTests {
#expect(history.canUndo == false) #expect(history.canUndo == false)
} }
@Test("A tombstoned card is stale for a field edit, even with the field itself untouched") @Test("A foreign lane delete is stale for a field edit — there is nothing at the path")
func aTombstonedTargetIsStaleForAFieldEdit() throws { func aRemovedLaneIsStaleForAFieldEdit() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
store.setLaneWidth(lane1, units: 3) store.setLaneWidth(lane1, units: 3)
try Foreign.delete(fixture, Ident.lane1) try Foreign.removeLane(fixture, Ident.lane1)
history.undo() 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) #expect(store.banners.signposts.count == 1)
} }
@Test("A card under a foreign-tombstoned lane is stale too — liveness is ancestor-walked") /// The card's own path is the check: a lane delete is physical, so a card under it is simply not
func anAncestorTombstoneIsStale() throws { /// 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() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
@@ -973,11 +1017,11 @@ struct StaleStepTests {
store.transient.beginRename(of: card1, currentTitle: "First") store.transient.beginRename(of: card1, currentTitle: "First")
store.transient.updateRenameDraft("Renamed") store.transient.updateRenameDraft("Renamed")
store.commitRename() store.commitRename()
try Foreign.delete(fixture, Ident.lane1) try Foreign.removeLane(fixture, Ident.lane1)
history.undo() 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) #expect(store.banners.signposts.count == 1)
} }
@@ -1014,7 +1058,7 @@ struct StaleStepTests {
store.commitRename() store.commitRename()
try Foreign.rename(fixture, card2Path, to: "Theirs") try Foreign.rename(fixture, card2Path, to: "Theirs")
try Foreign.delete(fixture, card3Path) try Foreign.trash(fixture, card3Path, id: Ident.card3)
history.undo() history.undo()
#expect(try document(fixture, card1Path).title.value == "First", "the step applied") #expect(try document(fixture, card1Path).title.value == "First", "the step applied")
@@ -1108,7 +1152,7 @@ struct StaleStepTests {
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
store.delete([card1]) store.delete([card1])
try Foreign.purge(fixture, card1Path) try Foreign.purge(fixture, ".trash/\(Ident.card1)")
history.undo() history.undo()
let row = try #require(store.bannerRows.last) let row = try #require(store.bannerRows.last)
@@ -1219,7 +1263,7 @@ struct FailedCrossingTests {
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path) try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
history.undo() 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") "the step below was never reached — a refused disk is not a reason to attempt more")
#expect(history.undoActionName == "Rename Card") #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 /// 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. /// them leaves a stack that crosses cleanly in both directions.
@MainActor @MainActor
@Suite("Undo ▸ the trash's two doors") @Suite("Undo ▸ the trash and the stack are the same folder moves")
struct TrashInterplayTests { 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 { func theTwoDoorsReachOneState() async throws {
// Two identical boards, one per door: the claim is about a *state*, so the honest comparison // 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. // is the whole board read off disk, not the one field each path happens to write.
let byUndo = try makeBoard() let byUndo = try makeBoard()
defer { byUndo.tearDown() } defer { byUndo.tearDown() }
let byPutBack = try makeBoard() let byMove = try makeBoard()
defer { byPutBack.tearDown() } defer { byMove.tearDown() }
let origin = try boardTexts(byUndo) let origin = try boardTexts(byUndo)
let (undoStore, history) = try makeStore(byUndo) let (undoStore, history) = try makeStore(byUndo)
undoStore.delete([card1]) undoStore.delete([card1])
history.undo() history.undo()
let (putBackStore, _) = try makeStore(byPutBack) let (moveStore, _) = try makeStore(byMove)
putBackStore.delete([card1]) moveStore.delete([card1])
// Put Back reads the trashed side of the snapshot, so it has to see the tombstone first // The move-out reads the trash side of the snapshot, so it has to see the card arrive there
// which is the one-way flow, not a test artefact. // first which is the one-way flow, not a test artefact.
await reload(putBackStore) await reload(moveStore)
putBackStore.putBack([card1]) 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") expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
#expect(try document(byUndo, card1Path).deleted.isMissing) #expect(byUndo.exists(card1Path))
#expect(try document(byPutBack, card1Path).deleted.isMissing) #expect(byMove.exists(card1Path))
} }
@Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end") @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() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
/// The lane's live cards in display order, read through the loader the order the board /// The lane's cards in display order, read through the loader the order the board actually
/// actually renders, rather than the ranks it is derived from. /// renders, rather than the ranks it is derived from.
func liveCards() throws -> [String] { func laneCards() throws -> [String] {
let result = try BoardLoader.load(boardRoot: fixture.root) let result = try BoardLoader.load(boardRoot: fixture.root)
let lane = try #require(result.model.lanes.first { $0.id == lane1 }) 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") #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 // The middle of three: an implementation that restored by appending would pass on a first or
// last card and fail here. // last card and fail here.
store.delete([card2]) store.delete([card2])
await reload(store) await reload(store)
#expect(try liveCards() == [Ident.card1, Ident.card3]) #expect(try laneCards() == [Ident.card1, Ident.card3])
history.undo() 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") #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 (store, history) = try makeStore(fixture)
let live = try boardTexts(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]) store.delete([card1])
await reload(store) 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) await reload(store)
#expect(try document(fixture, card1Path).deleted.isMissing) #expect(fixture.exists(card1Path))
store.delete([card1]) store.delete([card1])
await reload(store) await reload(store)
#expect(history.undoActionName == "Delete Card") #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 // Back up the stack: delete move delete, each crossed in reverse.
// inverse, which has to re-file the row under the stamp it was filed under rather than under
// now the trash sorts by it.
history.undo() history.undo()
#expect(try document(fixture, card1Path).deleted.isMissing) #expect(fixture.exists(card1Path))
#expect(history.undoActionName == "Restore Card") #expect(history.undoActionName == "Move Card")
history.undo() history.undo()
#expect(try document(fixture, card1Path).deleted.value == filedUnder, #expect(fixture.exists(".trash/\(Ident.card1)"), "back in the trash the move took it out of")
"re-tombstoned where it was filed, not where a fresh stamp would put it")
#expect(history.undoActionName == "Delete Card") #expect(history.undoActionName == "Delete Card")
history.undo() history.undo()
@@ -1419,12 +1462,12 @@ struct TrashInterplayTests {
// And forward again, the same three steps in the other direction. // And forward again, the same three steps in the other direction.
history.redo() history.redo()
#expect(try document(fixture, card1Path).deleted.value != nil) #expect(fixture.exists(".trash/\(Ident.card1)"))
history.redo() history.redo()
#expect(try document(fixture, card1Path).deleted.isMissing) #expect(fixture.exists(card1Path))
history.redo() history.redo()
#expect(history.canRedo == false) #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.signposts.isEmpty, "nothing was stale: the doors never collided")
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
+23
View File
@@ -93,6 +93,29 @@ struct WriterFixture {
func entryNames(_ relativePath: String) throws -> [String] { func entryNames(_ relativePath: String) throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: url(relativePath).path).sorted() 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 `<root>/.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 // MARK: - Move/copy identities