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 {
let ids = store.selection.ids
guard !ids.isEmpty else { return [store.rootURL] }
return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot)
return ItemPath.resolve(ids, in: store.selection.container, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
if let attachments {
+2 -4
View File
@@ -645,11 +645,9 @@ public final class AppModel {
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
var lanes = 0
var cards = 0
for lane in snapshot.lanes where !lane.isDeleted {
for lane in snapshot.lanes {
lanes += 1
for card in lane.cards where !card.isDeleted {
cards += 1
}
cards += lane.cards.count
}
return (lanes, cards)
}
+2 -4
View File
@@ -175,9 +175,7 @@ struct CardWindowHost: View {
let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
return lane.isDeleted || card.isDeleted
? .dismisses
: .shows(CardPlacement(card: card, lane: lane))
return .shows(CardPlacement(card: card, lane: lane))
}
return .dismisses
}
@@ -319,7 +317,7 @@ struct CardWindowHost: View {
/// links resolve against (05-card-window.md Preview).
///
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for
/// `BoardStore.liveItem`'s reason: a mid-session folder rename moves the board, and a preview
/// `BoardStore.boardItem`'s reason: a mid-session folder rename moves the board, and a preview
/// resolving images against where the board used to be would quietly stop showing them.
static func cardFolder(root: URL, placement: CardPlacement) -> URL {
root
+4 -4
View File
@@ -27,7 +27,7 @@ extension UTType {
/// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments
/// absent", announced by a banner rather than discovered later.
///
/// `kind` and `side` are the selection's own vocabulary (`SelectionKind`, `Liveness`) rather than
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
/// spellings are pasteboard API a manifest written before a quit is decoded after the relaunch.
@@ -53,7 +53,7 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
public var boardRoot: String
public var kind: SelectionKind
public var side: Liveness
public var container: ItemContainer
public var entries: [Entry]
/// One copied item: where its snapshot is staged, what it is called, and its bytes.
@@ -132,14 +132,14 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
copyID: String,
boardRoot: URL,
kind: SelectionKind,
side: Liveness,
container: ItemContainer,
entries: [Entry]
) {
self.version = version
self.copyID = copyID
self.boardRoot = boardRoot.path
self.kind = kind
self.side = side
self.container = container
self.entries = entries
}
+77 -66
View File
@@ -192,7 +192,7 @@ public final class ClipboardStore {
copyID: copyID,
boardRoot: store.rootURL,
kind: capture.kind,
side: capture.side,
container: capture.container,
entries: capture.subjects.map(\.entry)
)
guard let data = manifest.encoded() else { return }
@@ -209,7 +209,7 @@ public final class ClipboardStore {
armedCut = ArmedCut(copyID: copyID, source: store)
store.transient.pendingCut = ItemReferenceSet(
ids: Set(capture.subjects.map(\.id)),
liveness: capture.side
container: capture.container
)
}
// "A sweep at launch and on each copy purges entries the pasteboard no longer references."
@@ -219,8 +219,9 @@ public final class ClipboardStore {
// MARK: - Availability
/// Whether Edit Copy applies a non-empty selection that still names something the board
/// renders, on either side of the live/tombstoned boundary.
/// Whether Edit Copy applies a non-empty selection that still names something, in either
/// container ("C copies a trash card a live copy lands wherever pasted, like copying out of
/// Finder's Trash" 04-interactions.md The trash).
///
/// **The read-only lock deliberately does not close it**: "reading, selecting, searching and
/// copying out all stay live" (02-architecture.md § The lock's scope) a copy is a read. The
@@ -233,12 +234,15 @@ public final class ClipboardStore {
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
}
/// Whether Edit Cut applies. Copy's conditions, plus the two a *move* adds: the board must
/// accept writes (a cut mutates its source), and the selection must be **live** "X is
/// disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else" (04 The
/// trash).
/// Whether Edit Cut applies. Copy's conditions plus the one a *move* adds: the board must
/// accept writes, since a cut mutates its source.
///
/// **The trash no longer disqualifies it** (04-interactions.md The trash, resettled
/// 2026-07-28): "X works it was disabled under the tombstone model: cut in the trash, paste
/// into a lane is the keyboard-native restore, an ordinary folder move". So there is no
/// container clause here at all, which is the pivot showing up as a deleted line.
public func canCut(from store: BoardStore) -> Bool {
canCopy(from: store) && !store.isReadOnly && store.selection.liveness == .live
canCopy(from: store) && !store.isReadOnly
}
/// Whether Edit Paste applies to `store`.
@@ -347,7 +351,6 @@ public final class ClipboardStore {
operation: .move,
toLane: target.laneID,
at: target.index,
clearingTombstones: false,
normalizingLooseFiles: true
)
case let .lanes(index):
@@ -355,7 +358,6 @@ public final class ClipboardStore {
sources,
operation: .move,
at: index,
clearingTombstones: false,
normalizingLooseFiles: true
)
}
@@ -389,9 +391,9 @@ public final class ClipboardStore {
}
}
// "C strips `deleted:` at materialization" (04 The trash) the trash's copy-out-only rule,
// and the one axis a paste varies that a within-board drop never does.
let clearingTombstones = manifest.side == .trashed
// A card copied out of the trash needs nothing done to it on arrival: it carries no
// `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled
// 2026-07-28). The tombstone era's strip-at-materialization axis is gone with it.
switch plan {
case let .cards(target):
store.receiveCards(
@@ -399,7 +401,6 @@ public final class ClipboardStore {
operation: .copy,
toLane: target.laneID,
at: target.index,
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true
)
case let .lanes(index):
@@ -407,7 +408,6 @@ public final class ClipboardStore {
sources,
operation: .copy,
at: index,
clearingTombstones: clearingTombstones,
normalizingLooseFiles: true
)
}
@@ -427,10 +427,11 @@ public final class ClipboardStore {
let survivors = source.transient.pendingCut
guard !survivors.isEmpty else { return nil }
// `TrashModel.paths` walks lanes in board order and each lane's cards in card order, which is
// the flatten order the drop commits insert in and the pending cut is homogeneous by kind,
// so only one of its two branches ever contributes.
let folders = TrashModel.paths(of: survivors.ids, on: .live, in: source.snapshot)
// `ItemPath.resolve` walks the container in display order, which is the flatten order the
// drop commits insert in and the pending cut is homogeneous by container, so it is asked
// for exactly the side the cut was made on. A cut made in the trash therefore hands the
// paste the trash folders it must move out, which is the keyboard restore (04 The trash).
let folders = ItemPath.resolve(survivors.ids, in: survivors.container, snapshot: source.snapshot)
.map { $0.folder(under: source.rootURL) }
guard !folders.isEmpty else { return nil }
return (source, folders)
@@ -541,17 +542,17 @@ public final class ClipboardStore {
/// produces.
struct Subject {
let id: ItemID
let path: TrashModel.ItemPath
let path: ItemPath
let entry: ClipboardManifest.Entry
}
/// The selection, resolved into copy subjects in the order the clipboard records them or `nil`
/// when it names nothing the board renders on its own side.
/// when it names nothing its container holds.
///
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for all four
/// (side, kind) pairs: flatten order for live cards, left-to-right for live lanes, and the trash's
/// own deterministic sort for either kind of entry. Deriving it here would be a fifth definition
/// of an order the app already states once.
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
/// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the
/// trash's own `order` for trash cards. Deriving it here would be a second definition of an order
/// the app already states once.
///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
/// span, so `serialized()` on an untouched document returns the file's bytes exactly which
@@ -560,58 +561,68 @@ public final class ClipboardStore {
static func capture(
selection: ItemReferenceSet,
snapshot: BoardModel
) -> (kind: SelectionKind, side: Liveness, subjects: [Subject])? {
) -> (kind: SelectionKind, container: ItemContainer, subjects: [Subject])? {
guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil }
let side = selection.liveness
let ordered = SelectionGrammar.order(of: kind, on: side, in: snapshot)
let container = selection.container
let ordered = SelectionGrammar.order(of: kind, in: container, snapshot: snapshot)
.filter { selection.ids.contains($0) }
guard !ordered.isEmpty else { return nil }
var subjects: [ItemID: Subject] = [:]
for lane in snapshot.lanes {
if kind == .lane, Liveness(isDeleted: lane.isDeleted) == side {
subjects[lane.id] = Subject(
id: lane.id,
path: TrashModel.ItemPath(laneID: lane.id, cardID: nil),
entry: ClipboardManifest.Entry(
id: lane.id.rawValue,
folder: lane.id.rawValue,
title: lane.title.value,
index: lane.document.serialized(),
attachmentCount: 0,
// Live cards only a lane copy strips tombstoned cards, and the fallback
// only ever materializes a copy (see `ClipboardManifest.Entry.cards`).
cards: lane.cards.filter { !$0.isDeleted }.map { card in
ClipboardManifest.Entry.Card(
id: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
}
)
func addCard(_ card: Card, at path: ItemPath) {
subjects[card.id] = Subject(
id: card.id,
path: path,
entry: ClipboardManifest.Entry(
id: card.id.rawValue,
folder: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
)
}
switch container {
case .trash:
for card in snapshot.trash {
addCard(card, at: .trashCard(card.id))
}
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live and
// have no trash row of their own, so they are nobody's copy subject.
guard kind == .card, !lane.isDeleted else { continue }
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == side {
subjects[card.id] = Subject(
id: card.id,
path: TrashModel.ItemPath(laneID: lane.id, cardID: card.id),
entry: ClipboardManifest.Entry(
id: card.id.rawValue,
folder: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
case .board:
for lane in snapshot.lanes {
if kind == .lane {
subjects[lane.id] = Subject(
id: lane.id,
path: .lane(lane.id),
entry: ClipboardManifest.Entry(
id: lane.id.rawValue,
folder: lane.id.rawValue,
title: lane.title.value,
index: lane.document.serialized(),
attachmentCount: 0,
// Every card the lane has "a lane carries exactly its cards", and the
// trash is board-level, so there is nothing nested to strip
// (04-interactions.md Drag and drop, resettled 2026-07-28).
cards: lane.cards.map { card in
ClipboardManifest.Entry.Card(
id: card.id.rawValue,
title: card.title.value,
index: card.document.serialized(),
attachmentCount: card.attachments.count
)
}
)
)
)
continue
}
for card in lane.cards {
addCard(card, at: .card(lane: lane.id, id: card.id))
}
}
}
let resolved = ordered.compactMap { subjects[$0] }
guard !resolved.isEmpty else { return nil }
return (kind, side, resolved)
return (kind, container, resolved)
}
}
+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.
guard let store else { return .failed }
guard HistoryStaleness.isCurrent(expectations, under: store.rootURL) else {
guard HistoryStaleness.isCurrent(expectations) else {
store.banners.postSkippedStep(direction, subject: subject)
return .skipped
}
@@ -157,11 +157,11 @@ extension BoardStore {
/// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte.
///
/// **Removal, not a tombstone**, exactly as 13 words it: an undone create leaves *no trace*,
/// because the item was born of the gesture being undone a tombstone would leave a trash row
/// for a card the user never really made. `purgeIsUnrecoverable` is untouched by this: that flag
/// is about Delete Immediately, whose loss is the user's own final gesture, while this loss is
/// one Z away.
/// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no
/// trace*, because the item was born of the gesture being undone filing it in the trash would
/// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched
/// by this: that flag is about Delete Immediately, whose loss is the user's own final gesture,
/// while this loss is one Z away.
///
/// The honest edge, recorded rather than papered over: anything that happened *inside* the
/// created folder through an operation that registers no step of its own an attachment added by
@@ -169,19 +169,19 @@ extension BoardStore {
/// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather
/// than from anything decided here.
///
/// **Its staleness predicate is existence and liveness, and deliberately nothing else** (13:
/// "existence/liveness for create/delete/restore steps"). A create's after-value *is* the item's
/// being there, so the undo validates that the folders are still there and still live and the
/// redo that they are still gone. The same honest edge follows: a foreign *edit* inside a created
/// card does not stop Z from removing it, because the create never wrote that field while a
/// foreign *delete* does, since the trash row the user is looking at is not this step's to purge.
/// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence
/// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the
/// undo validates that the folders are still at their paths and the redo that they are still
/// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop Z
/// from removing it, because the create never wrote that field while a foreign *delete* does,
/// since the card the user is looking at in the trash is at a different path now.
func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) {
guard !items.isEmpty else { return }
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
registerStep(
HistoryPhrase.name(.add, kind: kind, count: items.count),
subject: subject,
undoExpects: items.map { .live($0.folder) },
undoExpects: items.map { .present($0.folder) },
redoExpects: items.map { .absent($0.folder) }
) { _ in
// Reversed, so a lane and a card created by one gesture unwind child-first the same
@@ -282,14 +282,4 @@ extension BoardStore {
}
}
/// Puts an item's tombstone back **with the timestamp it carried**, not with `now`.
///
/// The inverse of Put Back is the item returning to the trash exactly where it was, and the trash
/// sorts by `deleted` (03-board-ui.md § Trash Contents): re-stamping would file the row under
/// today and quietly reorder a list the user was reading. A prior that was malformed (or, by
/// construction impossibly, missing) falls back to `now` the item has to be tombstoned, and an
/// unreadable timestamp is not a value to preserve.
static func restoreTombstone(_ prior: FieldValue<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
/// left it in.
///
/// Two halves, both of them 13's: **existence and liveness** ("target folder gone ... the step is
/// skipped"; "existence/liveness for create/delete/restore steps"), and the **field-level**
/// comparison above. A step carries one of these per item it touched, so a multi-card move validates
/// three targets and a single rename validates one which is the whole of "a foreign change to an
/// unrelated item must not skip anything": an item no step named is an item no expectation mentions.
/// Two halves, both of them 13's: **existence** ("target folder gone ... the step is skipped"),
/// and the **field-level** comparison above. A step carries one of these per item it touched, so a
/// multi-card move validates three targets and a single rename validates one which is the whole of
/// "a foreign change to an unrelated item must not skip anything": an item no step named is an item
/// no expectation mentions.
///
/// **The folder's *path* is the parent check.** A move's step expects the card at its destination
/// path; a card that a foreign writer moved elsewhere leaves nothing at that path, so the ordinary
/// existence half already answers "moved away" without a parent field of its own.
/// ### The container side rides in the folder path
///
/// **The folder's *path* is the parent check**, and since the trash was materialized that check is
/// also the container check (03-board-ui.md § Trash, resettled 2026-07-28). A delete step's undo
/// expects its card at `<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 {
/// Where the item this step wrote to should be the destination for a move, the item's own
/// folder for everything else, and the board root for the board's own rename and styling.
public let folder: URL
/// Whether the item should be there, and if so on which side of the tombstone.
/// Whether the item should be there.
public let presence: Presence
/// The fields the step's write set, with the values it set them to. Empty for a step whose
/// whole subject *is* existence a create, a delete, a Put Back.
/// whole subject *is* existence a create, a lane delete.
public let fields: [ExpectedField]
/// Where an item stands, as the trash's own three-way reading of it.
/// Whether anything is at this path.
public enum Presence: Sendable, Equatable {
/// There, and rendered: no `deleted:` on the item **or on any ancestor**. Liveness is
/// effective, the same ancestor walk `BoardStore.liveItem` and the card windows' fate rule
/// apply a card under a tombstoned lane renders nowhere, so it is as gone as a deleted one.
case live
/// There, with a readable `index.md`. Which container that is, is the path's own answer.
case present
/// There, and tombstoned a trash row, or a card hidden under a tombstoned lane.
case tombstoned
/// Not there at all: the folder is gone. What an undone create leaves, and what a redone one
/// expects to find before putting it back.
/// Not there at all: the folder is gone. What an undone create and an undone lane delete
/// leave, and what a redone one expects to find before putting it back.
case absent
}
@@ -86,37 +88,21 @@ public struct HistoryExpectation: Sendable, Equatable {
self.fields = fields
}
/// The item is live and its fields say what the step set them to.
public static func live(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields)
/// The item is at this path and its fields say what the step set them to.
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .present, fields: fields)
}
/// The same, for a caller whose field list is computed the styling gesture's, which varies per
/// dimension. A label rather than a second variadic, so `.live(folder)` stays unambiguous.
public static func live(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields)
}
/// The item is tombstoned and its fields say what the step set them to.
public static func tombstoned(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .tombstoned, fields: fields)
/// dimension. A label rather than a second variadic, so `.present(folder)` stays unambiguous.
public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .present, fields: fields)
}
/// Nothing is at this path.
public static func absent(_ folder: URL) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .absent, fields: [])
}
/// The variant for a step whose target's liveness is not known until the gesture runs the Edit
/// session's, which is registered against a card that may have been tombstoned out from under
/// the buffer (05-card-window.md Deletion & lifecycle).
public static func item(
_ folder: URL,
tombstoned: Bool,
_ fields: ExpectedField...
) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: tombstoned ? .tombstoned : .live, fields: fields)
}
}
// MARK: - HistoryStaleness
@@ -140,29 +126,29 @@ public struct HistoryExpectation: Sendable, Equatable {
/// (settled ruled 2026-07-27): staleness is discovered at Z time, never by background pruning ...
/// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
/// before the inverse would have been written.
///
/// ### It needs no board root
///
/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and
/// needed the root to know where to stop. Materializing the trash removed the walk: an item's
/// container is its path, and a path is checked by asking the filesystem whether anything is there.
public enum HistoryStaleness {
/// Whether every target a step named still holds what that step left there.
///
/// `root` is the board's current root, which the liveness walk stops at a lane's parent.
public static func isCurrent(_ expectations: [HistoryExpectation], under root: URL) -> Bool {
expectations.allSatisfy { isCurrent($0, under: root) }
public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool {
expectations.allSatisfy(isCurrent)
}
/// One target's answer.
///
/// A file that cannot be read or parsed fails every expectation but `.absent`: an `index.md`
/// somebody has just broken is not one holding this step's after-value, and the honest reading of
/// "the field no longer holds it" covers a field that can no longer be read at all.
public static func isCurrent(_ expectation: HistoryExpectation, under root: URL) -> Bool {
/// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody
/// has just broken is not one holding this step's after-value, and the honest reading of "the
/// field no longer holds it" covers a field that can no longer be read at all.
public static func isCurrent(_ expectation: HistoryExpectation) -> Bool {
guard expectation.presence != .absent else {
return !FileManager.default.fileExists(atPath: expectation.folder.path)
}
guard let document = index(at: expectation.folder) else { return false }
let tombstoned = isEffectivelyTombstoned(expectation.folder, document: document, under: root)
guard tombstoned == (expectation.presence == .tombstoned) else { return false }
return expectation.fields.allSatisfy { matches($0, in: document) }
}
@@ -192,40 +178,6 @@ public enum HistoryStaleness {
}
}
// MARK: Liveness
/// Whether the item at `folder` renders **presence of `deleted:`, not its validity**
/// (`Lane.isDeleted`'s rule), walked up through the ancestors the way every other liveness
/// question in this app is.
///
/// The board root is never tombstoned however its own frontmatter reads: a board-level `deleted:`
/// is a tolerated load *warning* (01-storage-format.md § Deletion), not a state that hides the
/// board from itself.
private static func isEffectivelyTombstoned(
_ folder: URL,
document: FrontmatterDocument,
under root: URL
) -> Bool {
guard !isRoot(folder, root) else { return false }
guard document.deleted.isMissing else { return true }
// Lane and card are the only levels below the root, so this walks at most twice; the bound
// is there so a folder that is not under this root at all (a step registered before a
// mid-session root change) ends rather than climbing to `/`.
var parent = folder.deletingLastPathComponent()
for _ in 0 ..< 4 {
guard !isRoot(parent, root) else { return false }
guard let ancestor = index(at: parent) else { return false }
if !ancestor.deleted.isMissing { return true }
parent = parent.deletingLastPathComponent()
}
return false
}
private static func isRoot(_ folder: URL, _ root: URL) -> Bool {
folder.standardizedFileURL.path == root.standardizedFileURL.path
}
// MARK: Reading
/// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one
+74
View File
@@ -416,6 +416,27 @@ public final class BannerCenter {
postLoss(message)
}
/// **The legacy tombstone migration** (01-storage-format.md § Deletion, resettled 2026-07-28:
/// "Legacy `deleted:` keys migrate on load-and-write, never destroy a graceful warning-tone
/// notice"): a board written by an older version carried `deleted:` keys, the app moved the
/// cards those keys named into `.trash/` and returned the lanes live, and this is the row that
/// says so.
///
/// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
/// the app moved the user's folders on its own initiative, on a board it opened rather than on a
/// gesture they made. That must be said out loud, must not evaporate unread, and must not rank
/// as an error, because no action failed. The one nuance worth naming: the *lane* half is a
/// resurrection rather than a removal cards nobody asked to see again may reappear on the
/// board which is exactly the kind of surprise this class exists to announce.
///
/// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for
/// an untitled one "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so
/// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
public func postMigratedTombstones(cards: [String?], lanes: [String?]) {
guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return }
postLoss(message)
}
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
/// folders (04-interactions.md Selection, drag & drop, "Folders are refused at hover"): "a
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
@@ -822,6 +843,59 @@ public final class BannerCenter {
return "Moved '\(name)' into attachments — \(subject)"
}
/// The legacy tombstone migration's line **one folded sentence for both halves**, written in
/// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first,
/// the subject after an em dash, plurals folded, a sole item named.
///
/// The two clauses are joined rather than posted as two rows, because it is **one migration**:
/// a board opened, its old deletion markers were resolved, and that is one thing that happened
/// to the user's files. Two rows would also mean two dismissals for one event, and would rank a
/// resurrection and a relocation against each other for no reason.
///
/// The shapes, in the relocation's own idiom:
///
/// - **One card**, no lanes: "Moved 'Fix login' to the trash it carried an old deleted marker".
/// - **Several cards**: "Moved 3 cards to the trash they carried old deleted markers".
/// - **One lane**, no cards: "Restored 'Doing' it carried an old deleted marker".
/// - **Both**: "Moved 3 cards to the trash and restored 2 lanes they carried old deleted markers".
///
/// **The tail names the cause once**, and it is the whole explanation the row owes: the user did
/// not delete anything just now, and without the clause the sentence would read as an action
/// they had somehow just taken. The singular/plural of the tail follows the *total*, so the
/// mixed case never has to spell a singular (two clauses carry at least two items).
///
/// `nil` when nothing migrated a migration that migrated nothing is not news.
public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? {
let total = cards.count + lanes.count
guard total > 0 else { return nil }
var clauses: [String] = []
if !cards.isEmpty {
let subject = cards.count == 1
? sole(cards[0])
: "\(cards.count) cards"
clauses.append("Moved \(subject) to the trash")
}
if !lanes.isEmpty {
let subject = lanes.count == 1
? sole(lanes[0])
: "\(lanes.count) lanes"
clauses.append(clauses.isEmpty ? "Restored \(subject)" : "restored \(subject)")
}
let tail = total == 1
? "it carried an old deleted marker"
: "they carried old deleted markers"
return "\(clauses.joined(separator: " and "))\(tail)"
}
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
/// clause it sits in already says which level it is).
private nonisolated static func sole(_ title: String?) -> String {
guard let title else { return "an untitled item" }
return "'\(title)'"
}
/// The skipped-step line 13-native-undo.md Rules' own example sentence, "Undo skipped 'Fix
/// login' changed outside Lanework", with Z's mirror ("Redo skipped ").
///
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
}
}
+20 -42
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,
/// ranges, arrow nav, and lane count badges all read it." They read it *here* the masonry through
/// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the
/// trash through `TrashLaneView.entries`, and the selection through
/// trash through `SelectionGrammar.trashCards`, and the selection through
/// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does
/// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2).
///
@@ -29,9 +29,9 @@ import Foundation
///
/// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the
/// query stays on the board showing an empty body and a `0` badge, because the width division is
/// layout and the filter is content. `matches(_: Lane)` exists only for the trash, whose rows are
/// tombstoned lanes as often as they are cards and which filter "like any lane" by their own
/// title + body (03-board-ui.md § Trash).
/// layout and the filter is content. There is no lane overload at all: the tombstone model's trash
/// held lane *entries* that had to be filtered like rows, and lanes are never trashed now
/// (03-board-ui.md § Trash), so a card predicate is the whole of the filter.
public struct SearchFilter: Sendable, Equatable {
/// The query exactly as typed kept so a caller can key a transaction or a test on it.
@@ -78,54 +78,32 @@ public struct SearchFilter: Sendable, Equatable {
matches(title: card.title.value, body: card.body)
}
/// A lane, by its own title and description the trash's lane entries, and nothing on the board
/// itself (see the type's doc comment).
public func matches(_ lane: Lane) -> Bool {
matches(title: lane.title.value, body: lane.body)
}
/// A trash row, **by its own title and body**, whichever kind it is: "shown, it participates in
/// the filter like any lane" (03-board-ui.md § Trash), and a lane entry is a row like a card row.
///
/// A lane entry is deliberately *not* matched through its cards: the entry is one restorable
/// thing, and a lane surfacing because a card buried inside it matched would be a row the user
/// cannot act on the way the match suggests.
public func matches(_ entry: TrashEntry) -> Bool {
switch entry {
case let .card(card, _): matches(card)
case let .lane(lane, _): matches(lane)
}
}
// MARK: - The visible universe
/// Every id the filter leaves visible on `side` **the universe
/// Every id the filter leaves visible in `container` **the universe
/// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection"
/// (`TransientBoardState.constrainToSearch(in:)`).
///
/// It is shaped exactly like `ItemReferenceSet.idUniverse(of:on:)` and means the same thing one
/// step narrower: that one answers "what does the board *have*", this one "what does the board
/// *show*". Two differences, both stated above and neither incidental:
///
/// - **Live lanes are all in it.** The filter hides cards, so a lane is visible whatever its
/// cards do a lane selection survives a query that empties its body.
/// - **The trashed side is the trash's rows**, filtered `TrashModel.entries`' absolute
/// ancestor walk, which already excludes the cards a tombstoned lane subsumes. Those have no
/// row, so they are visible to nobody and belong in no universe a selection is held to.
public func visibleIDs(in snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
switch side {
case .live:
var ids: Set<ItemID> = []
for lane in snapshot.lanes where !lane.isDeleted {
/// It is shaped exactly like `ItemContainer.ids(in:)` and means the same thing one step
/// narrower: that one answers "what does the board *have*", this one "what does the board
/// *show*". The one difference is stated above and is not incidental: **every lane is in it**,
/// because the filter hides cards, so a lane selection survives a query that empties its body.
public func visibleIDs(in snapshot: BoardModel, container: ItemContainer) -> Set<ItemID> {
var ids: Set<ItemID> = []
switch container {
case .board:
for lane in snapshot.lanes {
ids.insert(lane.id)
for card in lane.cards where !card.isDeleted && matches(card) {
for card in lane.cards where matches(card) {
ids.insert(card.id)
}
}
return ids
case .trashed:
return Set(TrashModel.entries(of: snapshot).lazy.filter { matches($0) }.map(\.id))
case .trash:
for card in snapshot.trash where matches(card) {
ids.insert(card.id)
}
}
return ids
}
// MARK: - Folding
+120 -132
View File
@@ -21,22 +21,22 @@ public enum SelectionKind: String, Codable, Sendable, Equatable {
case lane
}
/// What a pointer click names: an item, its level, and the side of the live/trash boundary the
/// surface it was clicked on sits on.
/// What a pointer click names: an item, its level, and the container the surface it was clicked on
/// belongs to.
///
/// The **side is the surface's, not the item's** a card face is always `.live` and a trash row is
/// always `.trashed`, because that is what the user clicked. A click on a surface whose item flipped
/// liveness a moment ago simply selects nothing the next reload will keep, which is the ordinary
/// vanish rule and not a case for this type to model.
/// The **container is the surface's, not the item's** a card face is always `.board` and a trash
/// row is always `.trash`, because that is what the user clicked. A click on a surface whose item
/// crossed containers a moment ago simply selects nothing the next reload will keep, which is the
/// ordinary vanish rule and not a case for this type to model.
public struct SelectionTarget: Sendable, Equatable {
public var id: ItemID
public var kind: SelectionKind
public var side: Liveness
public var container: ItemContainer
public init(id: ItemID, kind: SelectionKind, side: Liveness) {
public init(id: ItemID, kind: SelectionKind, container: ItemContainer) {
self.id = id
self.kind = kind
self.side = side
self.container = container
}
}
@@ -59,15 +59,16 @@ public enum ClickModifier: Sendable, Equatable {
/// (`SelectionGrammarTests`).
///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
/// homogeneous on three axes at once cards XOR lanes (§ Selection), live XOR tombstoned, and
/// within the trash card entries XOR lane entries (§ The trash) and every one of them is a
/// property of what a *click* is allowed to produce. So no outcome below is ever mixed: a modifier
/// that would cross an axis degrades to a replace, which is the only answer that keeps the
/// invariant true without silently dropping what the user asked for.
/// homogeneous on **two** axes now cards XOR lanes (§ Selection) and board XOR trash (§ The
/// trash's "single container rule replacing the old liveness law") and the third, kind-inside-the
/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
/// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below
/// is ever mixed: a modifier that would cross an axis degrades to a replace, which is the only
/// answer that keeps the invariant true without silently dropping what the user asked for.
///
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
/// trash row) share one answer instead of four near-copies of it.
/// trash card) share one answer instead of four near-copies of it.
public enum SelectionGrammar {
/// What a click leaves behind: the new selection, the anchor a subsequent -click would range
@@ -100,7 +101,7 @@ public enum SelectionGrammar {
/// The grammar, one call.
///
/// - Parameters:
/// - target: what was clicked, with the surface's liveness side (see `SelectionTarget`).
/// - target: what was clicked, with the surface's container (see `SelectionTarget`).
/// - modifier: the effective modifier, already reduced to one of three (`ClickModifier`).
/// - selection: the board's current selection.
/// - anchor: the range origin `TransientBoardState.selectionAnchor`.
@@ -145,18 +146,18 @@ public enum SelectionGrammar {
selection: ItemReferenceSet,
togglesOnRepeat: Bool
) -> Outcome {
if togglesOnRepeat, selection.liveness == target.side, selection.ids == [target.id] {
if togglesOnRepeat, selection.container == target.container, selection.ids == [target.id] {
return .cleared
}
return Outcome(
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
selection: ItemReferenceSet(ids: [target.id], container: target.container),
anchor: target.id,
head: target.id
)
}
/// **-click toggles** but only *within* a homogeneous set. Crossing either axis (a card
/// clicked while lanes are selected, a trash row clicked while live cards are) is not a mixed
/// clicked while lanes are selected, a trash card clicked while board cards are) is not a mixed
/// selection and not a refusal: it is a **replace**, the same outcome a plain click would give,
/// because the click unambiguously names a new set of one.
///
@@ -168,7 +169,7 @@ public enum SelectionGrammar {
selection: ItemReferenceSet,
snapshot: BoardModel
) -> Outcome {
guard selection.liveness == target.side,
guard selection.container == target.container,
let current = kind(of: selection, in: snapshot),
current == target.kind
else {
@@ -183,7 +184,7 @@ public enum SelectionGrammar {
return .cleared
}
return Outcome(
selection: ItemReferenceSet(ids: ids, liveness: target.side),
selection: ItemReferenceSet(ids: ids, container: target.container),
anchor: target.id,
head: target.id
)
@@ -195,7 +196,7 @@ public enum SelectionGrammar {
///
/// The anchor is valid **iff both it and the target sit in the same order list** which folds
/// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is
/// exactly one (side, kind) pair. An invalid anchor makes the click a plain one, never a no-op:
/// exactly one (container, kind) pair. An invalid anchor makes the click a plain one, never a no-op:
/// the keyboard's -arrow goes inert at a boundary because its next step is ambiguous, while a
/// click names an unambiguous target and so always has something to do.
private static func shift(
@@ -210,15 +211,15 @@ public enum SelectionGrammar {
from: anchor,
to: target.id,
kind: target.kind,
on: target.side,
in: snapshot,
in: target.container,
snapshot: snapshot,
filter: filter
)
else {
return plain(target, selection: selection, togglesOnRepeat: false)
}
return Outcome(
selection: ItemReferenceSet(ids: span, liveness: target.side),
selection: ItemReferenceSet(ids: span, container: target.container),
anchor: anchor,
head: target.id
)
@@ -234,8 +235,8 @@ public enum SelectionGrammar {
///
/// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
/// anchor's caller-side absence, and every axis crossing into one test a list is exactly one
/// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain
/// click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// (container, kind) pair. The callers differ on what they do with that: a click degrades to a
/// plain click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous).
///
/// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search
@@ -246,44 +247,47 @@ public enum SelectionGrammar {
from: ItemID,
to: ItemID,
kind: SelectionKind,
on side: Liveness,
in snapshot: BoardModel,
in container: ItemContainer,
snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> Set<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 }
return Set(start <= end ? list[start...end] : list[end...start])
}
// MARK: - The order lists
/// The list a -range walks for one (side, kind) pair **the single place a "what's on the
/// The list a -range walks for one (container, kind) pair **the single place a "what's on the
/// board, in what order" question is answered** for the pointer.
///
/// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere
/// else** the filter "is the single source of truth for what's on the board ranges all
/// read it" (04-interactions.md § Search), and every range, every Select All and every arrow
/// walk is stated in terms of these four lists, so one parameter narrows all of them together.
/// walk is stated in terms of these lists, so one parameter narrows all of them together.
///
/// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
///
/// **The lane list takes no filter**, because a card query hides no lane see `SearchFilter`.
/// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed"
/// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to.
public static func order(
of kind: SelectionKind,
on side: Liveness,
in snapshot: BoardModel,
in container: ItemContainer,
snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> [ItemID] {
switch (side, kind) {
case (.live, .card): liveCards(in: snapshot, filter: filter)
case (.live, .lane): liveLanes(in: snapshot)
case (.trashed, _): trashEntries(of: kind, in: snapshot, filter: filter)
switch (container, kind) {
case (.board, .card): boardCards(in: snapshot, filter: filter)
case (.board, .lane): lanes(in: snapshot)
case (.trash, .card): trashCards(in: snapshot, filter: filter)
case (.trash, .lane): []
}
}
/// Live cards in **flatten order** "lane `order` first, then card `order` (a cross-lane
/// The board's cards in **flatten order** "lane `order` first, then card `order` (a cross-lane
/// selection flattens left-to-right, top-to-bottom)", the multi-drag order the N target rule
/// and paste anchoring already share (04-interactions.md Drag and drop, The map).
///
@@ -293,85 +297,65 @@ public enum SelectionGrammar {
/// **The filter narrows the walk in place**, which is what makes a search-time -range and
/// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same
/// predicate to the same cards, one lane at a time, and this is that collection flattened.
public static func liveCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
public static func boardCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
var ids: [ItemID] = []
for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted && filter.matches(card) {
for lane in snapshot.lanes {
for card in lane.cards where filter.matches(card) {
ids.append(card.id)
}
}
return ids
}
/// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md §
/// Trash collapses each into one entry), so they are absent from the live lane order entirely.
/// The board's lanes, left to right.
///
/// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the
/// query empties is still a lane on the board the width division is layout, and the badge
/// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one
/// part of the board grammar a search does not narrow.
public static func liveLanes(in snapshot: BoardModel) -> [ItemID] {
snapshot.lanes.filter { !$0.isDeleted }.map(\.id)
public static func lanes(in snapshot: BoardModel) -> [ItemID] {
snapshot.lanes.map(\.id)
}
/// One kind of trash row, in the quasi-lane's own deterministic order (`TrashModel.entries`,
/// whose sort is "load-bearing for input arrow walks, -ranges, and the rubber band all read
/// it").
/// The trash's cards, top to bottom `snapshot.trash` itself, which the loader already sorted
/// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like
/// any lane", newest-first falling out of the ranks rather than a timestamp sort).
///
/// **Filtered to one kind, so a range skips what it cannot include.** Card and lane entries
/// interleave in one ordering, and "a selection never mixes card entries and lane entries"
/// (04-interactions.md The trash), so a -range between two card rows spans the sorted order
/// and collects only the card rows stepping over any lane row that sits between them. That is
/// the deliberate pointer twin of the keyboard's rule: a -arrow onto a lane entry is *inert*
/// because its next step is ambiguous, while a click names an unambiguous same-kind target and
/// so the range simply skips.
///
/// **Filtered like any lane** (03-board-ui.md § Trash) the same predicate `TrashLaneView`
/// applies to the same rows, so a trash-side range walks exactly what the column is showing.
public static func trashEntries(
of kind: SelectionKind,
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> [ItemID] {
TrashModel.entries(of: snapshot)
.filter { $0.isLaneEntry == (kind == .lane) && filter.matches($0) }
.map(\.id)
/// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the
/// filter exactly like any other card") the same predicate `TrashLaneView` applies to the same
/// cards, so a trash-side range walks exactly what the column is showing.
public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trash.filter { filter.matches($0) }.map(\.id)
}
// MARK: - The current selection's kind
/// Which level the selection holds, or `nil` when it holds nothing the board renders on its own
/// side.
/// Which level the selection holds, or `nil` when it holds nothing its container renders.
///
/// **Any member answers, because the set is homogeneous** but the walk is the snapshot's order
/// rather than the set's iteration order, so the answer is deterministic even for a set that
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
/// as empty: a selection the next reload will drop must not decide what a click does now.
///
/// The membership rules are exactly the order lists': on the live side an item counts when its
/// effective liveness is live, and on the trashed side only **rows** count a card under a
/// tombstoned lane has no row of its own (`TrashModel.entries`' absolute ancestor walk), so it
/// is nobody's kind.
/// **The trash answers `.card` or nothing**, because lanes are never trashed which is why the
/// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil }
for lane in snapshot.lanes {
if Liveness(isDeleted: lane.isDeleted) == selection.liveness, selection.ids.contains(lane.id) {
return .lane
}
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live
// and have no trash row of their own.
guard !lane.isDeleted else { continue }
for card in lane.cards
where Liveness(isDeleted: card.isDeleted) == selection.liveness && selection.ids.contains(card.id) {
return .card
switch selection.container {
case .trash:
return snapshot.trash.contains { selection.ids.contains($0.id) } ? .card : nil
case .board:
for lane in snapshot.lanes {
if selection.ids.contains(lane.id) { return .lane }
if lane.cards.contains(where: { selection.ids.contains($0.id) }) { return .card }
}
return nil
}
return nil
}
// MARK: - Successor on delete
/// What selects after tombstoning `ids` 04-interactions.md The map's Finder-style
/// What selects after deleting `ids` 04-interactions.md The map's Finder-style
/// successor sibling, as a pure function of the **pre-write** snapshot.
///
/// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the
@@ -389,49 +373,56 @@ public enum SelectionGrammar {
/// - **`nil` is a legitimate answer** an emptied container selects nothing, and the caller
/// clears.
///
/// **Both stagings of Delete get one** (04, resettled 2026-07-28 "one Delete vocabulary,
/// staged by place"): `container` says which side the gesture ran on, and the trash walks its own
/// ordered cards exactly as a lane walks its own. The permanent delete is as deliberate an act as
/// the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
/// `BoardStore.delete` and by nothing on the reload path.
/// `BoardStore`'s delete paths and by nothing on the reload path.
///
/// **The container is what the lane is *showing*.** Under a search the successor must be a card
/// the user can see "nothing invisible stays selected" is the trash's phrasing of a rule the
/// filter obeys too and picking a hidden neighbour would hand the selection straight back to
/// **The container is what the surface is *showing*.** Under a search the successor must be a
/// card the user can see picking a hidden neighbour would hand the selection straight back to
/// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the
/// filter narrows the container, and repeated walks down the *filtered* lane.
public static func successor(
afterDeleting ids: Set<ItemID>,
in snapshot: BoardModel,
in container: ItemContainer = .board,
snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> ItemID? {
guard !ids.isEmpty else { return nil }
let selection = ItemReferenceSet(ids: ids, liveness: .live)
let selection = ItemReferenceSet(ids: ids, container: container)
guard let kind = kind(of: selection, in: snapshot) else { return nil }
let container: [ItemID]
switch kind {
case .lane:
container = liveLanes(in: snapshot)
case .card:
let siblings: [ItemID]
switch (container, kind) {
case (.trash, _):
siblings = trashCards(in: snapshot, filter: filter)
case (.board, .lane):
siblings = lanes(in: snapshot)
case (.board, .card):
// The last selected card in flatten order names the lane; its lane's rendered cards are
// the container the successor is drawn from.
guard let last = liveCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
guard let last = boardCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
let lane = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
lane.cards.contains { $0.id == last }
})
else { return nil }
container = lane.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
siblings = lane.cards.filter { filter.matches($0) }.map(\.id)
}
let doomed = container.indices.filter { ids.contains(container[$0]) }
let doomed = siblings.indices.filter { ids.contains(siblings[$0]) }
guard let first = doomed.first, let last = doomed.last else { return nil }
if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
return container[..<first].last { !ids.contains($0) }
if let after = siblings[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
return siblings[..<first].last { !ids.contains($0) }
}
}
// 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
/// 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 var id: ItemID
public var kind: SelectionKind
public var side: Liveness
public var container: ItemContainer
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.kind = kind
self.side = side
self.container = container
self.frame = frame
}
}
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the side the
/// band started on (`SelectionGrammarTests`).
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the container
/// the band started in (`SelectionGrammarTests`).
///
/// 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`
/// is a parameter rather than something derived from what the rect happens to touch: a band begun
/// on the board and dragged over the trash column selects live cards and nothing else.
/// - **The band stays on the side of the boundary it started on** ( The trash), which is why
/// `container` is a parameter rather than something derived from what the rect happens to touch: a
/// 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
/// 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 {
/// The ids `rect` sweeps.
///
/// On the **trashed** side the band must additionally stay homogeneous by *kind*, because the
/// trash's two row kinds interleave in one column. The rule is topmost-wins: the kind of the
/// highest intersecting row decides, and rows of the other kind are dropped so a band pulled
/// down from a card row keeps collecting card rows and steps over the lane rows between them,
/// exactly as a -range does.
public static func selection(rect: CGRect, targets: [MarqueeTarget], side: Liveness) -> Set<ItemID> {
let hits = targets.filter { $0.side == side && rect.intersects($0.frame) }
guard !hits.isEmpty else { return [] }
switch side {
case .live:
return Set(hits.lazy.filter { $0.kind == .card }.map(\.id))
case .trashed:
guard let topmost = hits.min(by: isAbove) else { return [] }
return Set(hits.lazy.filter { $0.kind == topmost.kind }.map(\.id))
}
public static func selection(
rect: CGRect,
targets: [MarqueeTarget],
in container: ItemContainer
) -> Set<ItemID> {
Set(
targets.lazy
.filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) }
.map(\.id)
)
}
/// Which of two drawn rows is "higher" top edge, then leading edge, then identity.
///
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
/// the same way twice, or the topmost-kind rule would pick differently on identical input.
/// the same way twice.
///
/// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two
/// candidates that a metric cannot separate must still be separated the same way twice.
/// Used by `NavigationMath`, which breaks its score ties with it: two candidates that a metric
/// cannot separate must still be separated the same way twice.
static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
+3 -3
View File
@@ -154,7 +154,7 @@ public struct StyleEditorSession: Sendable, Equatable {
case .board:
return self
case let .items(ids):
let live = ItemReferenceSet(ids: ids, liveness: .live).resolved(against: snapshot).ids
let live = ItemReferenceSet(ids: ids, container: .board).resolved(against: snapshot).ids
guard !live.isEmpty else { return nil }
return live == ids ? self : StyleEditorSession(target: .items(live))
}
@@ -170,9 +170,9 @@ public struct StyleEditorSession: Sendable, Equatable {
/// the popover lands on the first thing the user's eye would find.
public func presentationAnchor(in snapshot: BoardModel) -> ItemID? {
guard case let .items(ids) = target else { return nil }
for lane in snapshot.lanes where !lane.isDeleted {
for lane in snapshot.lanes {
if ids.contains(lane.id) { return lane.id }
if let card = lane.cards.first(where: { !$0.isDeleted && ids.contains($0.id) }) { return card.id }
if let card = lane.cards.first(where: { ids.contains($0.id) }) { return card.id }
}
return nil
}
+67 -155
View File
@@ -1,82 +1,9 @@
import Foundation
import Observation
// MARK: - Liveness
/// Which side of the live/tombstoned boundary something sits on.
///
/// An item-referencing set is **homogeneous by liveness** (04-interactions.md § The trash): it never
/// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of
/// each member which is exactly what makes re-resolution across a reload a matching rule rather
/// than a partition.
///
/// **`String`-backed and `Codable` because the clipboard manifest carries one** (04-interactions.md
/// Clipboard: a manifest records its entries' "source side (live/trashed)"). The raw spellings are
/// therefore pasteboard API a manifest written before a quit is decoded after the relaunch and
/// they are the case names so nothing has to remember a second vocabulary.
public enum Liveness: String, Codable, Sendable, Equatable {
case live
case trashed
/// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are
/// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed see
/// their doc comments in `BoardModel.swift`.
init(isDeleted: Bool) {
self = isDeleted ? .trashed : .live
}
}
// MARK: - The one definition of a side
extension Liveness {
/// Every item `snapshot` has on this side, visited in **board order** lanes left to right,
/// each lane immediately before its own cards as the lane it lives under and, for a card, the
/// card itself.
///
/// **This is the definition, and it is the only one** (02-architecture.md § Changes from Kanban,
/// settled: "universe and rows are one function, never a broader set with a pointer-side
/// subset"). Everything that asks what is on a side is this walk with a different accumulator:
/// `ItemReferenceSet.idUniverse` collects ids, `TrashModel.entries` builds the trash's rows,
/// `TrashModel.paths` and `emptyTrashTargets` build folders. Two spellings of the rule would be
/// two things to keep in step, and the one they would eventually disagree about is precisely the
/// item below.
///
/// **The whole rule is the `continue`: a tombstoned lane subsumes its subtree.** It contributes
/// one item to the trashed side its own row and its cards contribute nothing to *either*
/// side, whatever their own flags say. That is 03-board-ui.md § Trash's absolute ancestor walk
/// stated as code: "a card that carries its own `deleted:` under a tombstoned lane has **no row
/// of its own**".
///
/// **So the two sides do not partition the board, and that is the point.** A card beneath a
/// tombstoned lane is in *neither* universe, because it renders nowhere no row, no membership.
/// A selection, a drag, a pending cut, a range anchor or a navigation head can therefore never
/// survive a reload sitting on something no surface would draw, and 04-interactions.md § Search's
/// hidden-cards-leave-the-selection rule and 02's reload-survival rule stay one rule rather than
/// two that happen to agree.
///
/// Non-escaping and accumulator-driven rather than array-returning: the callers below run on
/// every reload and on every menu validation, and none of them wants a board-sized copy of the
/// model to throw away.
func walk(_ snapshot: BoardModel, visiting visit: (Lane, Card?) -> Void) {
for lane in snapshot.lanes {
if lane.isDeleted {
// The subsumption, both halves of it: the lane is a trash row, and its cards are
// nobody's so the loop below never runs for them.
if self == .trashed { visit(lane, nil) }
continue
}
if self == .live { visit(lane, nil) }
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == self {
visit(lane, card)
}
}
}
}
// MARK: - ItemReferenceSet
/// A set of UUIDs over the snapshot plus the liveness side it lives on **the one shape every
/// A set of UUIDs over the snapshot plus the container it lives in **the one shape every
/// piece of transient state that points at items wears**: the selection, drag membership, and the
/// pending cut are three values of this type, not three hand-rolled near-copies
/// (02-architecture.md § Live-reload resilience, "Selection and every transient state that
@@ -92,46 +19,52 @@ extension Liveness {
/// and everything else here is that primitive with a universe supplied. Only the universe differs
/// between the two callers:
///
/// - **Reload survival**: the universe is the new snapshot's ids on this set's liveness side, which
/// - **Reload survival**: the universe is the new snapshot's ids in this set's container, which
/// is what `resolved(against:)` computes before delegating.
/// - **The live search filter**: the universe is the visible ids the predicate produced, so
/// 04-interactions.md § Search's "hidden cards leave the selection" needs no second rule it is
/// this one, with a different universe (m5 wires that caller).
/// this one, with a different universe.
///
/// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and
/// storing the result belongs to `TransientBoardState` a set that filtered itself would need to
/// know about snapshots, and the whole point of the value-type snapshot is that nothing has to.
public struct ItemReferenceSet: Sendable, Equatable {
public var ids: Set<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.liveness = liveness
self.container = container
}
/// Nothing referenced, on the live side the state a board opens in, the state a drag with
/// Nothing referenced, on the board side the state a board opens in, the state a drag with
/// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to.
public static let empty = ItemReferenceSet()
public var isEmpty: Bool { ids.isEmpty }
/// This set narrowed to `universe`: members that are in it, **side unchanged**.
/// This set narrowed to `universe`: members that are in it, **container unchanged**.
///
/// The primitive both directions are built from intersection and nothing else. It is
/// deliberately ignorant of what a universe *is*: a snapshot's ids on one liveness side
/// deliberately ignorant of what a universe *is*: a snapshot's ids in one container
/// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as
/// the rule is concerned, which is what lets one rule be stated once and mean both.
///
/// The liveness side survives even when the membership does not: an emptied set is still a set
/// on a side, and re-populating it (a fresh click, a new drag) is the caller's business.
/// The container survives even when the membership does not: an emptied set is still a set in a
/// container, and re-populating it (a fresh click, a new drag) is the caller's business.
public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return ItemReferenceSet(ids: ids.intersection(universe), liveness: liveness)
return ItemReferenceSet(ids: ids.intersection(universe), container: container)
}
/// This set re-grounded on `snapshot`: the members that are still there, **on the same liveness
/// side**, and nothing else.
/// This set re-grounded on `snapshot`: the members that are still there, **in the same
/// container**, and nothing else.
///
/// Two rules, both settled in 02-architecture.md § Live-reload resilience:
///
@@ -139,35 +72,19 @@ public struct ItemReferenceSet: Sendable, Equatable {
/// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different:
/// selects the successor sibling, because that is an act rather than a surprise
/// 04-interactions.md The map. That belongs to the delete command, not here.)
/// - **A liveness flip is a vanish.** A foreign edit that tombstones a selected live card or
/// restores a selected tombstoned one ejects it, keeping 04-interactions.md's
/// homogeneous-by-liveness invariant true across reloads so menu validation never sees a
/// mixed selection. The pending cut inherits the same rule for free (04 Clipboard: "a cut
/// item that is tombstoned or vanishes externally before paste drops out of the pending
/// cut"), and so does drag membership (04 Drag and drop's emptied-drag rule).
/// - **A container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a
/// foreign move that trashes a selected board card or restores a selected trash card
/// ejects it from the selection (and from the pending cut)", keeping 04's container-boundary
/// invariant true across reloads so menu validation never sees a mixed selection. Drag
/// membership inherits it for free (04 Drag and drop's emptied-drag rule).
///
/// The liveness that is matched is **effective ancestor-walked** (settled), and the trashed
/// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read.
/// Tombstoning a lane therefore ejects its cards from a live set even though their own flags
/// never changed and does **not** hand them to a trashed set, because the lane's single entry
/// subsumes them (03-board-ui.md). A card under a tombstoned lane renders nowhere on either
/// side, and nothing invisible may stay selected, drag-included, or pending-cut.
/// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to
/// compute "the old effective-liveness ancestor walk is retired with the tombstone model"
/// (02-architecture.md) because a deleted card's folder has actually moved, and a folder is
/// either in the container or it is not.
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return constrained(to: Self.idUniverse(of: snapshot, on: liveness))
}
/// Every id in `snapshot` on `side` the reload direction's universe.
///
/// One line over `Liveness.walk`, which is where the rule itself lives and is stated: the live
/// side is the live lanes and their unflagged cards, and the trashed side is *exactly* the trash's
/// rows tombstoned lanes, plus cards carrying their own `deleted:` under a live lane. Nothing
/// else is in either, so a card hidden beneath a tombstoned lane belongs to no universe and no
/// set may go on referencing it.
static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
var universe: Set<ItemID> = []
side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) }
return universe
return constrained(to: container.ids(in: snapshot))
}
}
@@ -442,8 +359,8 @@ public final class TransientBoardState {
// MARK: Per-open values
/// The lane that most recently held selection or a creation **in this window session**
/// 04-interactions.md's N target rule's fallback when nothing (or a tombstoned something) is
/// selected, before the last resort of the first lane.
/// 04-interactions.md's N target rule's fallback when nothing (or a trash selection, which
/// never anchors creation) is selected, before the last resort of the first lane.
///
/// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
/// in the snapshot that could reconstruct which lane the user was last working in, which is
@@ -454,7 +371,7 @@ public final class TransientBoardState {
/// is no target at all; `NewCardTarget` then falls through to the first lane.
public private(set) var lastActiveLaneID: ItemID?
/// Whether the trash quasi-lane is showing (03-board-ui.md Trash).
/// Whether the trash column is showing (03-board-ui.md Trash).
///
/// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
/// choice, so it does not belong in the board registry beside window frames
@@ -480,8 +397,8 @@ public final class TransientBoardState {
///
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, liveness: liveness)
public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, container: container)
let sole = ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole
selectionHead = head ?? sole
@@ -666,16 +583,15 @@ public final class TransientBoardState {
///
/// **Every item-referencing set is resolved independently.** They are re-grounded against the
/// same snapshot but never against each other: a card leaving the selection must not disturb a
/// drag in flight or a pending cut that also held it, and each set carries its own liveness
/// side. Independence is what makes that a property of the code rather than of the order the
/// lines happen to be in.
/// drag in flight or a pending cut that also held it, and each set carries its own container.
/// Independence is what makes that a property of the code rather than of the order the lines
/// happen to be in.
///
/// **The placeholder has its own two rules**, because it references a lane rather than items:
///
/// - **Discarded when its anchor lane is gone** absent from the snapshot, or effectively
/// tombstoned. A tombstoned lane renders nowhere (03-board-ui.md collapses it to a single
/// trash entry), so its lane "vanished" in every sense 02-architecture.md means: "if the
/// placeholder's lane vanished in the reload, it is discarded".
/// - **Discarded when its anchor lane is gone** absent from the snapshot. "If the
/// placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane
/// delete is physical now, so gone is the only way a lane goes.
/// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the
/// snapshot. The real card arrived; the overlay's whole job was covering the gap between the
/// Writer's create and the watcher's round trip, and holding it a moment longer would draw the
@@ -687,25 +603,25 @@ public final class TransientBoardState {
///
/// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md
/// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
/// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently.
/// A foreign *move* is deliberately not a vanish the editor follows the UUID and the commit
/// writes wherever the item now lives which falls out for free from matching on identity
/// rather than on position. Liveness is **effective**, so a card under a lane an agent just
/// tombstoned vanishes with it.
/// that is trashed, deleted, or gone discards the editor and its keystrokes silently
/// "entering the trash is a vanish from the board; nothing is ever written into a vanished
/// folder". A foreign *move between lanes* is deliberately not a vanish the editor follows
/// the UUID and the commit writes wherever the card now lives which falls out for free from
/// matching on identity within the board container.
///
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
/// gives every item-referencing piece of transient state: nothing may reference an item the
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
/// optional rather than a set on a side the rule it obeys is the same one.
///
/// **`selectionAnchor` obeys it too**, on the *selection's* side: a range origin that vanished
/// or flipped liveness is gone, and the next -click acts as a plain click rather than ranging
/// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the
/// selection a -click that toggles the anchor's neighbour out leaves the anchor selected and
/// a range from it is still exactly what the user asked for.
/// **`selectionAnchor` obeys it too**, in the *selection's* container: a range origin that
/// vanished or crossed containers is gone, and the next -click acts as a plain click rather
/// than ranging from somewhere that renders nowhere. It deliberately does **not** have to stay
/// *in* the selection a -click that toggles the anchor's neighbour out leaves the anchor
/// selected and a range from it is still exactly what the user asked for.
///
/// **The style editor tracks its target set live** (03-board-ui.md § Styling Controls,
/// settled): a member that vanishes or flips liveness leaves the set so the editor's
/// settled): a member that vanishes or crosses containers leaves the set so the editor's
/// mixed-state display recomputes off the survivors and a set emptied by a foreign reload
/// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
@@ -725,25 +641,22 @@ public final class TransientBoardState {
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
styleEditor = styleEditor?.resolved(against: snapshot)
// One universe computed once and asked three questions the rename target's liveness, the
// One universe computed once and asked three questions the rename target's container, the
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live)
if let editor = renameEditor, !live.contains(editor.targetID) {
let board = ItemContainer.board.ids(in: snapshot)
if let editor = renameEditor, !board.contains(editor.targetID) {
renameEditor = nil
}
if let lane = lastActiveLaneID, !live.contains(lane) {
if let lane = lastActiveLaneID, !board.contains(lane) {
lastActiveLaneID = nil
}
if selectionAnchor != nil || selectionHead != nil {
// The selection's side, because that is the side both cursors live on by construction
// every route that sets either sets the selection to the same side in the same call. A
// vanished or liveness-flipped cursor is gone, which is the rule every item reference
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives
// from the selection's last member on the next arrow, which is the same fallback an
// anchorless -arrow already uses.
let universe = selection.liveness == .live
? live
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
// The selection's container, because that is where both cursors live by construction
// every route that sets either sets the selection to the same container in the same
// call. A vanished or container-crossed cursor is gone, which is the rule every item
// reference here gets. The head then re-derives from the selection's last member on the
// next arrow, which is the same fallback an anchorless -arrow already uses.
let universe = selection.container == .board ? board : selection.container.ids(in: snapshot)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
@@ -776,13 +689,14 @@ public final class TransientBoardState {
/// under a live filter is a gesture in flight, not a set the filter has any claim on. The
/// editor's absence is the settled ruling in person: "an open inline rename survives the filter
/// hiding its card the vanish-discard rule stays reserved for true liveness flips"
/// (`RenameEditor`), so a foreign edit that stops the renaming card matching drops it from the
/// selection here and leaves the keystrokes exactly where the user left them.
/// (`RenameEditor`) read for the materialized trash, true container crossings so a foreign
/// edit that stops the renaming card matching drops it from the selection here and leaves the
/// keystrokes exactly where the user left them.
public func constrainToSearch(in snapshot: BoardModel) {
let filter = SearchFilter(query: searchQuery)
guard filter.isActive else { return }
let universe = filter.visibleIDs(in: snapshot, on: selection.liveness)
let universe = filter.visibleIDs(in: snapshot, container: selection.container)
selection = selection.constrained(to: universe)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
@@ -792,9 +706,7 @@ public final class TransientBoardState {
private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? {
guard let placeholder = newCardPlaceholder else { return nil }
guard let anchor = snapshot.lanes.first(where: { $0.id == placeholder.laneID }),
!anchor.isDeleted
else { return nil }
guard snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil }
if case let .awaitingArrival(id) = placeholder.phase,
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
+77 -339
View File
@@ -1,409 +1,147 @@
import Foundation
// MARK: - TrashEntry
/// One row of the trash quasi-lane (03-board-ui.md § Trash).
///
/// **Two cases, not one**, because a tombstoned lane is not a tombstoned card wearing a different
/// symbol: it is "a single restorable entry" that *subsumes* everything beneath it, and Put Back on
/// it "returns [the lane] whole, cards and all" (04-interactions.md The trash). The card count it
/// carries is part of the entry rather than something the row re-derives, because the rule for what
/// that number means is subtle enough to want one home see `TrashModel.entries(of:)`.
///
/// The card case carries its lane's id for the same reason `BoardStore.liveItem` returns two
/// components rather than a URL: the row's folder is `<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
/// The trash quasi-lane's contents, as a pure function of a snapshot (`TrashModelTests`)
/// 03-board-ui.md § Trash's Contents rules with nothing else mixed in.
/// What is left of the trash as a *model* once the trash became a folder 03-board-ui.md § Trash's
/// **materialized** container (resettled 2026-07-28).
///
/// **Pure because the trash is a pure view.** "Tombstoned cards keep their `deleted:` key and stay
/// exactly where they are on disk; nothing about the storage schema is trash-specific", so there is
/// no trash *state* anywhere only this derivation, re-run against whatever snapshot is current.
/// A reload therefore rebuilds the rows for free, exactly as it rebuilds the lanes.
/// ### Almost nothing, and that is the point of the pivot
///
/// The three rules it owns, each of which the design settles explicitly:
/// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a
/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to
/// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a
/// special place", so the trash's contents *are* `snapshot.trash` already parsed by the same card
/// parse the lanes use, already in `order` display order, already newest-first because every arrival
/// mints a rank above the current top. There is nothing to derive, and no second definition to keep
/// in step with the loader's.
///
/// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it "a
/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There
/// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled
/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is
/// held to *is* this row set "universe and rows are one function" (02-architecture.md § Changes
/// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the
/// rows a user sees and the ids a selection may hold cannot drift apart.
/// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board
/// cards without their own flag; individually tombstoned descendants aren't in that number, since
/// they come back to the *trash*".
/// 3. **The deterministic sort.** Newest `deleted` first; ties by folder name ascending; an
/// unparseable stamp sorts as *oldest*, after every dated entry, folder-name-ordered among its
/// kind; lane entries interleave in the same single ordering by their own stamp. The order is
/// load-bearing for input "arrow walks, -ranges, and the rubber band all read it" so it is
/// total, not merely stable.
/// What genuinely remains is what the *commands* need and no view can answer: the two purge
/// confirmations' phrasing, and the menu validation that stages Delete by place. Both are pure
/// functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is testable
/// without an alert on screen.
public enum TrashModel {
// MARK: - Where a row lives
/// An item's folder, as its identity components rather than as a URL.
///
/// Same shape and same reasoning as `BoardStore.liveItem`'s return: the caller builds the URL off
/// the store's *current* `rootURL`, so a board renamed or moved mid-session writes at the new
/// location (02-architecture.md § Write-failure surfacing).
public struct ItemPath: Sendable, Equatable {
public let laneID: ItemID
/// `nil` for a lane the path is then the lane folder itself.
public let cardID: ItemID?
public init(laneID: ItemID, cardID: ItemID?) {
self.laneID = laneID
self.cardID = cardID
}
public var isLane: Bool { cardID == nil }
/// This path resolved under a board root.
public func folder(under root: URL) -> URL {
var url = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
if let cardID {
url.append(component: cardID.rawValue, directoryHint: .isDirectory)
}
return url
}
}
// MARK: - Entries
/// The trash's rows, in the order the quasi-lane shows them.
///
/// **Which items are rows is not decided here** it is `Liveness.trashed.walk`, the same walk
/// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are
/// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane
/// contributes exactly one entry and its cards contribute none, whatever their own flags say.
///
/// What is left for this function is what a row *carries* the returning count and the sort
/// inputs and the order it shows in, neither of which any other caller of the walk wants.
public static func entries(of snapshot: BoardModel) -> [TrashEntry] {
var rows: [Row] = []
Liveness.trashed.walk(snapshot) { lane, card in
if let card {
rows.append(Row(
entry: .card(card, laneID: lane.id),
deleted: card.deleted.value,
name: card.id.rawValue
))
} else {
// Rule 2: only the cards *without* their own flag come back with the lane. The ones
// that carry a flag stay tombstoned and get their rows back in the trash which is
// why Put Back on such a card is deliberately two steps.
let returning = lane.cards.filter { !$0.isDeleted }.count
rows.append(Row(
entry: .lane(lane, returningCardCount: returning),
deleted: lane.deleted.value,
name: lane.id.rawValue
))
}
}
return rows.sorted(by: isOrdered).map(\.entry)
}
/// Whether the board has anything in its trash at all the "non-empty" half of Empty Trash's
/// menu validation, which "reads the board's tombstones, not the filtered view" (03 Trash).
///
/// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is
/// restated and only because stopping early is the whole point: a tombstoned lane is a row
/// outright, and under a live lane any own-flagged card is one. There is deliberately no third
/// clause for a card beneath a tombstoned lane the lane has already answered `true` for it.
/// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift.
public static func isEmpty(_ snapshot: BoardModel) -> Bool {
!snapshot.lanes.contains { lane in
lane.isDeleted || lane.cards.contains(where: \.isDeleted)
}
}
/// One entry's sort inputs, kept beside it so the comparator never re-reads the model.
private struct Row {
let entry: TrashEntry
/// The parsed `deleted` stamp, or `nil` when the value is present but unparseable
/// 01-storage-format.md's unusable-timestamp rule, which still deletes (presence, not
/// validity) but supplies no position in time.
let deleted: Date?
/// The folder name, byte-for-byte the tie-break the loader's display order already uses
/// (`Ranks.sortedForDisplay`, `name: { $0.id.rawValue }`), so the trash breaks ties the same
/// way the board does.
let name: String
}
/// The sort, stated once: newest first among dated entries, then every undated entry.
///
/// **Undated sorts oldest, not first.** "A corrupt stamp must not outrank fresh deletions for the
/// trash's most prominent rows" so an unparseable value loses to every real timestamp, however
/// old, and orders by folder name among its own kind.
private static func isOrdered(_ lhs: Row, _ rhs: Row) -> Bool {
switch (lhs.deleted, rhs.deleted) {
case let (left?, right?):
return left == right ? lhs.name < rhs.name : left > right
case (.some, nil):
return true
case (nil, .some):
return false
case (nil, nil):
return lhs.name < rhs.name
}
}
// MARK: - Paths for the trash's writes
/// The folders `ids` names, in display order, restricted to one liveness side.
///
/// **The membership rule is not restated here** it is `Liveness.walk`'s, the same one
/// `ItemReferenceSet.idUniverse` and `entries(of:)` read spelled in *paths* rather than ids
/// because a write needs to know where the item is. So the trashed side is the trash's rows and
/// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs
/// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee
/// that every path this hands a writer names something the board would draw.
///
/// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than
/// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put
/// Back on it restores the lane and every card that rode along; Delete Immediately on it purges
/// the whole tree, own-flag cards included the outcome the lane entry's confirmation sentence
/// exists to warn about (`message(lanes:unrecoverable:)`).
///
/// Display order lanes left to right, each lane then its cards rather than the caller's set
/// iteration order, which is not an order at all: a batch that fails partway must fail the same
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). The walk
/// visits in exactly that order, so this is a filter over it and never a sort.
public static func paths(of ids: Set<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
/// How many lane entries and card entries a set of entries holds the confirmation dialogs'
/// only input beyond the item titles.
public struct EntryCounts: Sendable, Equatable {
public var lanes: Int = 0
public var cards: Int = 0
public var total: Int { lanes + cards }
public var isEmpty: Bool { total == 0 }
}
public static func counts(of entries: [TrashEntry]) -> EntryCounts {
var counts = EntryCounts()
for entry in entries {
if entry.isLaneEntry { counts.lanes += 1 } else { counts.cards += 1 }
}
return counts
}
/// The counts a set of `ItemPath`s describes the same two numbers, from the shape the write
/// path actually carries.
public static func counts(of paths: [ItemPath]) -> EntryCounts {
var counts = EntryCounts()
for path in paths {
if path.isLane { counts.lanes += 1 } else { counts.cards += 1 }
}
return counts
}
/// "2 lanes and 3 cards", "41 cards", "1 lane" 06-history-undo.md's **plural folding** applied
/// to a mixed trash selection.
///
/// An empty count reads "nothing", which no caller renders: both confirmations refuse to open on
/// an empty scope. It is spelled anyway so the function is total.
public static func phrase(_ counts: EntryCounts) -> String {
switch (counts.lanes, counts.cards) {
case (0, 0): "nothing"
case let (0, cards): plural(cards, "card")
case let (lanes, 0): plural(lanes, "lane")
case let (lanes, cards): "\(plural(lanes, "lane")) and \(plural(cards, "card"))"
}
}
private static func plural(_ count: Int, _ noun: String) -> String {
"\(count) \(noun)\(count == 1 ? "" : "s")"
/// "41 cards", "1 card" 06-history-undo.md's **plural folding**, which is all the folding a
/// cards-only container can need ("Cards only. Lanes are never trashed" 03-board-ui.md).
public static func phrase(_ count: Int) -> String {
"\(count) card\(count == 1 ? "" : "s")"
}
// MARK: - Confirmations
/// A purge confirmation's three strings, built once and rendered by the window's alert.
///
/// A value rather than a view so the phrasing rules plural folding, naming a sole item, the
/// lane caveat, and whether the loss is actually irreversible are testable without an alert on
/// screen (`TrashModelTests`).
/// A value rather than a view so the phrasing rules plural folding, naming a sole item, and
/// whether the loss is actually irreversible are testable without an alert on screen.
public struct PurgePrompt: Sendable, Equatable {
public let title: String
public let message: String
public let confirmTitle: String
}
/// Delete Immediately's alert "the alert stands between one keystroke and unrecoverable
/// deletion" (03-board-ui.md § Trash).
/// The alert in front of a **permanent** card delete the trash's own / and File Delete
/// Immediately alike (03-board-ui.md § Trash: "Both confirm exactly where the loss is real ...
/// the alert stands between one keystroke and unrecoverable deletion").
///
/// A sole item is **named**; several fold into counts. `nil` when the ids name nothing
/// tombstoned, which is also the command's own refusal so the prompt and the action can never
/// `container` is where the command found the cards: the trash for the trash's Delete, the board
/// for a Delete Immediately that skips the trash from a lane. The prompt reads the same either
/// way what is being asked is whether to destroy these cards, and where they happen to be
/// sitting is not the question.
///
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in that
/// container, which is also the command's own refusal so the prompt and the action can never
/// disagree about whether there is anything to purge.
public static func purgePrompt(
for ids: Set<ItemID>,
in snapshot: BoardModel,
in container: ItemContainer,
snapshot: BoardModel,
unrecoverable: Bool
) -> PurgePrompt? {
let targets = paths(of: ids, on: .trashed, in: snapshot)
let targets = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
guard !targets.isEmpty else { return nil }
let counts = counts(of: targets)
let subject: String
if targets.count == 1, let only = targets.first {
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
} else {
subject = phrase(counts)
subject = phrase(targets.count)
}
return PurgePrompt(
title: "Permanently delete \(subject)?",
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
message: message(unrecoverable: unrecoverable),
confirmTitle: "Delete"
)
}
/// Empty Trash's alert **always shown** ("bulk scope, not per-item recoverability, is what it
/// guards"), and always naming the **true count**: every tombstone on the board, never the
/// filtered view.
/// Empty Trash's alert **always shown** ("Empty Trash confirms everywhere"), and always
/// naming the **true count**: every card in `.trash/`, never the filtered view (03-board-ui.md §
/// Trash: "search-independent, the confirmation naming the card count").
///
/// Counts rather than names even for a single entry, because the command is about the trash
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing
/// (06-history-undo.md's plural folding).
/// Counts rather than names even for a single card, because the command is about the trash
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing.
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
let counts = counts(of: emptyTrashTargets(in: snapshot))
guard !counts.isEmpty else { return nil }
guard !snapshot.trash.isEmpty else { return nil }
return PurgePrompt(
title: "Permanently delete \(phrase(counts))?",
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
title: "Permanently delete \(phrase(snapshot.trash.count))?",
message: message(unrecoverable: unrecoverable),
confirmTitle: "Delete"
)
}
/// The alert's body: what a lane takes with it, and whether any of it comes back.
/// The alert's body: whether any of it comes back.
///
/// The lane sentence is not decoration a lane entry's row says "3 cards" (what Put Back would
/// return), while purging the lane folder takes *every* card inside it, individually tombstoned
/// ones included. That gap is exactly what a confirmation is for.
private static func message(lanes: Int, unrecoverable: Bool) -> String {
var parts: [String] = []
if lanes > 0 {
parts.append("Deleting a lane also deletes every card inside it.")
}
/// The tombstone era's second sentence "Deleting a lane also deletes every card inside it"
/// is gone with the lane entries it warned about: no purge path reaches a lane any more
/// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with
/// undo as its net).
private static func message(unrecoverable: Bool) -> String {
// m7-git: on a git board the content stays reachable in history, so the second sentence is
// the honest one and Delete Immediately does not confirm there at all
// (`BoardStore.purgeIsUnrecoverable`).
parts.append(unrecoverable
unrecoverable
? "This can\u{2019}t be undone."
: "The board\u{2019}s history still has them.")
return parts.joined(separator: " ")
: "The board\u{2019}s history still has them."
}
/// What to call an item in a prompt its title, or the "Untitled" rendering.
/// What to call a card in a prompt its title, or the "Untitled" rendering.
///
/// Total by construction: a path whose item has gone since the prompt was asked for reads
/// Total by construction: a path whose card has gone since the prompt was asked for reads
/// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
/// the app gives.
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return "Untitled" }
guard let cardID = path.cardID else { return lane.title.value ?? "Untitled" }
return lane.cards.first { $0.id == cardID }?.title.value ?? "Untitled"
switch path {
case let .lane(id):
return snapshot.lanes.first { $0.id == id }?.title.value ?? "Untitled"
case let .card(lane, id):
return snapshot.lanes.first { $0.id == lane }?
.cards.first { $0.id == id }?.title.value ?? "Untitled"
case let .trashCard(id):
return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
}
}
// MARK: - Menu validation
/// Whether File Delete has something to tombstone a **live**, non-empty selection that still
/// names something the board renders.
/// Whether File Delete has something to act on **staged by place, but validated once**
/// (04-interactions.md The map, resettled 2026-07-28: "File Delete is the chord's only
/// owner no twin menu items, no shared-equivalent routing").
///
/// The liveness side is the whole of the binary: "menu validation stays binary Delete for live
/// selections, Put Back / Delete Immediately for tombstoned ones" (04 The trash). The
/// resolution against the snapshot is what keeps a selection the next reload will drop from
/// enabling an item that would write nothing.
/// One predicate for both stagings, because there is only one item now: a board selection moves
/// into the trash, a trash selection deletes permanently, and the command is enabled whenever
/// either names something the board still holds. The old mirror-image pair
/// (which existed to make two twins enable exactly one of themselves) retired with Put Back.
public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
selection.liveness == .live && !paths(of: selection.ids, on: .live, in: snapshot).isEmpty
!ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).isEmpty
}
/// Whether File Put Back and File Delete Immediately have something to act on the exact
/// mirror of `canDelete`, which is what makes the two twins enable exactly one of themselves.
public static func canActOnTrash(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
selection.liveness == .trashed && !paths(of: selection.ids, on: .trashed, in: snapshot).isEmpty
/// Whether File Delete Immediately has something to purge **a card selection, from anywhere**
/// (11-command-nexus.md: "Board window, card selection skips the trash from anywhere").
///
/// Cards only, in either container: a lane's delete is physical already and has undo as its net,
/// so there is nothing for "skip the trash" to mean on one.
public static func canDeleteImmediately(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).contains { !$0.isLane }
}
}
+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
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
/// carried as its two identity components rather than as a URL, `BoardStore.liveItem`'s convention,
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention,
/// so the write derives its path from the store's *current* root.
public struct LooseCardFiles: Sendable, Equatable {
public let laneID: ItemID
+10 -4
View File
@@ -1391,12 +1391,18 @@ public enum BoardWriter: Sendable {
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
}
// MARK: - Tombstone (retiring)
// MARK: - Tombstone (retired, awaiting removal)
//
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
// app's delete is the physical move above, and no `deleted:` key is ever written again. The
// three calls below are kept only while their callers are still being moved across the
// migration removes the last keys any of them could act on, and they go with the last consumer.
// app's delete is the physical move above, and no `deleted:` key is ever written again.
//
// **These have no app callers left.** Every consumer moved across with the store swap the
// delete is `deleteCardToTrash`, the lane delete is `removeLane`, the restore is an ordinary
// `moveItem`, and the legacy keys are handled by the two `migrate` calls above. They are kept
// here for exactly one more beat because `purgeItem` below is still live (Delete Immediately's
// board-side purge) and the three read as one family; the pair and
// `stripTombstonedChildren` go together in the trash's cleanup pass, with the suites that
// still pin their byte-level behaviour.
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md`
/// 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
}
/// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned
/// selection all answer `nil` "everything edit-shaped is disabled on tombstoned selections"
/// (04 The trash), and a card window is tied to one card.
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
/// selection all answer `nil` "everything edit-shaped is disabled on trash selections Open
/// Card, Rename, Style" (04 The trash), and a card window is tied to one card.
private var soleSelectedCard: ItemID? {
guard let store else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
else { return nil }
return id
}
@@ -161,8 +161,8 @@ struct OpenCardCommand: View {
// holds it and re-checked after, because one of those paths is *the lane vanished*.
let lane = placeholder.laneID
let created = store.commitPlaceholder()
if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
store.select([lane], liveness: .live)
if store.snapshot.lanes.contains(where: { $0.id == lane }) {
store.select([lane], in: .board)
}
if let created { open(created) }
return
@@ -170,7 +170,7 @@ struct OpenCardCommand: View {
if let editor = store.transient.renameEditor {
let target = editor.targetID
let isCard = BoardStore.liveItem(target, in: store.snapshot)?.cardID != nil
let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil
store.commitRename()
if isCard { open(target) }
return
@@ -269,8 +269,8 @@ struct MoveLaneCommands: View {
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
let lanes = SelectionGrammar.lanes(in: store.snapshot)
// A card id is in no lane order, so this is also the "not a lane" test.
guard let from = lanes.firstIndex(of: id) else { return nil }
let to = from + delta
@@ -382,9 +382,9 @@ struct BoardInfoCommand: View {
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
/// never be renamed at all (04-interactions.md Selection).
///
/// Validation is the sole-selected-live-item rule card or lane, either kind, exactly one. A
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
/// selections" (04 The trash), which `ItemReferenceSet`'s liveness side answers directly.
/// Validation is the sole-selected-board-item rule card or lane, either kind, exactly one. A
/// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
/// (04 The trash), which `ItemReferenceSet`'s container answers directly.
struct BoardRenameCommand: View {
@FocusedValue(\.boardStore) private var store
@@ -400,8 +400,8 @@ struct BoardRenameCommand: View {
private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.liveItem(id, in: store.snapshot)
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.boardItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
}
@@ -441,7 +441,7 @@ struct BoardStyleCommand: View {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard !selection.isEmpty else { return .board }
guard selection.liveness == .live else { return nil }
guard selection.container == .board else { return nil }
// Re-resolved against the snapshot on the way in, so the session starts out holding only
// items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: store.snapshot).ids
@@ -498,8 +498,8 @@ struct LaneWidthCommands: View {
private var selectedLanes: [Lane] {
guard let store, store.acceptsBoardMutations else { return [] }
let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return [] }
return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted }
guard selection.container == .board, !selection.isEmpty else { return [] }
return store.snapshot.lanes.filter { selection.ids.contains($0.id) }
}
/// `width` is 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
+22 -28
View File
@@ -140,7 +140,7 @@ struct BoardDropContext {
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
let laneID = proposal.laneID
else { return }
guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return }
guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return }
session.propose(nil)
}
@@ -159,7 +159,7 @@ struct BoardDropContext {
func retargetLanes() {
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) }
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
let slot = DropSlotMath.laneSlot(
cursorX: cursor.x,
@@ -181,14 +181,14 @@ struct BoardDropContext {
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
func retargetCards(inLane laneID: ItemID) {
guard session.isDraggingCards, let cursor = globalCursor() else { return }
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else {
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
revalidateProposal()
return
}
guard let grid = registry.grids[laneID] else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
let rendered = lane.cards.filter { !hidden.contains($0.id) }
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement(
columnCount: grid.columns,
@@ -218,7 +218,7 @@ struct BoardDropContext {
/// answers `nil` there and the proposal simply **holds**, which is the hysteresis contract.
func retargetCardsFromStrip() {
guard session.isDraggingCards, let cursor = stripCursor() else { return }
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
let lanes = store.snapshot.lanes
let index = LaneLayoutMath.laneIndex(
atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
@@ -251,7 +251,7 @@ struct BoardDropContext {
guard let sourceRoot = session.sourceRoot else { return false }
return TrashDrop.accepts(
kind: session.kind,
side: session.side,
container: session.container,
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
operation: session.resolveOperation(destinationRoot: store.rootURL),
isTrashShown: store.transient.isTrashVisible,
@@ -338,14 +338,14 @@ struct BoardDropContext {
/// cursor and the snapshot, so it cannot oscillate the drawn layout never feeds back into it.
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
guard acceptsFileDrop(info), let cursor = globalCursor(),
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
let grid = registry.grids[laneID]
else {
session.proposeFile(nil)
return
}
let rendered = lane.cards.filter { !$0.isDeleted }
let rendered = lane.cards
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement(
columnCount: grid.columns,
@@ -399,7 +399,7 @@ struct BoardDropContext {
session.proposeFile(nil)
return
}
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
let lanes = store.snapshot.lanes
let index = LaneLayoutMath.laneIndex(
atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
@@ -498,7 +498,7 @@ struct BoardDropContext {
/// set's size (DRAG-REORDER.md § The drop commits).
///
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
/// a tombstone the same write performs, through the same `BoardWriter.deleteItem` in the same
/// a delete the same write performs, through the same `BoardWriter.deleteCardToTrash` in the same
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
/// from one deleted by keystroke (04-interactions.md The trash, settled 2026-07-28).
///
@@ -547,15 +547,16 @@ struct BoardDropContext {
case .cards:
if target.isTrash {
// **The pointer's delete gesture** (04-interactions.md The trash, settled
// 2026-07-28): "release tombstones the dragged card(s), exactly the tombstone".
// 2026-07-28): "release moves the dragged card(s) into `.trash/`" exactly the
// delete.
//
// The gate is re-asked here rather than trusted from the hover, because the one input
// that can change between them arrives through no callback at all: pressed after
// the proposal stood would otherwise tombstone an original the copy grammar had just
// the proposal stood would otherwise delete an original the copy grammar had just
// promised to leave alone. A refusal cancels items return, nothing is written.
guard TrashDrop.accepts(
kind: kind,
side: session.side,
container: session.container,
isWithinBoard: within,
operation: operation,
isTrashShown: store.transient.isTrashVisible,
@@ -571,27 +572,20 @@ struct BoardDropContext {
cancelDrop()
return false
}
switch (session.side, within) {
case (.live, true):
// **The trash side needs no branch of its own any more** (04-interactions.md The
// trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a
// trash card into one of its own board's lanes is an ordinary move to the drop
// position"). `moveCards`/`copyCards` resolve their members in either container, so a
// restore *is* the within-board move and a cross-board restore *is* the ordinary
// arrival which is exactly what retiring the restore-specific machinery bought.
if within {
if operation == .copy {
store.copyCards(Set(ids), toLane: laneID, at: target.index)
} else {
store.moveCards(Set(ids), toLane: laneID, at: target.index)
}
case (.live, false):
} else {
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
case (.trashed, true):
// Drag-to-restore, and its twin. "Dropping a tombstoned card into one of its own
// board's lanes restores it at the drop position"; is the copy-out instead a
// live copy lands and the tombstoned original stays (04-interactions.md The trash,
// "C, -drag always yield live copies").
if operation == .copy {
store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index)
} else {
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index)
}
case (.trashed, false):
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
}
}
+71 -71
View File
@@ -240,7 +240,7 @@ struct BoardView: View {
guard ClickModifier.current == .plain else { return }
store.clearSelection()
}
.simultaneousGesture(marqueeControl.gesture(side: .live))
.simultaneousGesture(marqueeControl.gesture(in: .board))
}
/// The lanes and the drag's shadows, plus the trash column when it is shown.
@@ -269,7 +269,7 @@ struct BoardView: View {
if isTrashVisible {
// Trailing, always the quasi-lane has no position of its own to lose, which is
// also why it never appears in the drop proposal's inputs (those are built from
// `liveLanes`) and why the terminal slot clamps in front of it.
// `boardLanes`) and why the terminal slot clamps in front of it.
TrashLaneView(
store: store,
confirmations: confirmations,
@@ -427,11 +427,11 @@ struct BoardView: View {
}
}
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here**
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
/// (a later card), and a lane that is not on the board consumes none of the window's width.
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
/// The lanes the strip lays out, in snapshot order every lane the board has. Deleting a lane
/// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
/// the snapshot is a lane on the board, with no hidden state to filter for.
private var boardLanes: [Lane] {
store.snapshot.lanes
}
// MARK: - Trash
@@ -443,16 +443,15 @@ struct BoardView: View {
store.transient.isTrashVisible
}
/// The trash's rows as the column is showing them the shown trash "participates in the filter
/// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen
/// (`TrashLaneView.entries` applies the identical predicate to the identical rows).
/// The trash's cards as the column is showing them the shown trash's cards "participate in
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
///
/// Read by the three keyboard destinations that reach into the column the arrow origin's
/// order list, /'s container, and 's jump so none of them can walk onto a row the
/// order list, /'s container, and 's jump so none of them can walk onto a card the
/// filter took away.
private var trashEntries: [TrashEntry] {
let filter = store.searchFilter
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
private var trashCards: [ItemID] {
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
}
// MARK: - The drag
@@ -506,7 +505,7 @@ struct BoardView: View {
/// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
units += arrivingLaneUnits
return LaneLayoutMath.standardWidth(
stripWidth: stripWidth,
@@ -569,7 +568,7 @@ struct BoardView: View {
private var stripSlots: [StripSlot] {
let session = appModel.dragSession
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
guard let index = stripProposal else { return slots }
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
@@ -596,10 +595,10 @@ struct BoardView: View {
}
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live,
guard selection.container == .board,
selection.ids.count == 1,
let id = selection.ids.first,
let target = BoardStore.liveItem(id, in: store.snapshot)
let target = BoardStore.boardItem(id, in: store.snapshot)
else { return .ignored }
if target.cardID == nil {
@@ -614,9 +613,11 @@ struct BoardView: View {
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The
/// map).
///
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and 's trash-side
/// role belongs to the twins, not to the bare key. A tombstoned selection is therefore inert
/// here Put Back is a chord.
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain performs the same
/// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28), and the delete
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
/// items sharing a chord. Put Back the reason the bare key had to stay off the trash is
/// retired with the tombstone model.
///
/// Inert while an inline editor is open, like every grammar key: the field owns as backspace,
/// and a stray one reaching the board mid-edit would delete the item being renamed.
@@ -631,7 +632,7 @@ struct BoardView: View {
}
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
guard !selection.isEmpty else { return .ignored }
store.deleteSelection()
return .handled
}
@@ -716,7 +717,7 @@ struct BoardView: View {
guard let origin = arrowOrigin() else { return seed(direction, mode) }
return origin.isLaneDomain
? laneArrow(direction, mode, from: origin.head)
: cardArrow(direction, mode, from: origin.head, on: origin.side)
: cardArrow(direction, mode, from: origin.head, in: origin.container)
}
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
@@ -738,33 +739,32 @@ struct BoardView: View {
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
/// having to name a cursor.
///
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
/// every trash entry in its sorted order card and lane entries alike" (04 The trash). The
/// per-kind lists are the *range*'s business, not the walk's.
/// The **trash's list is its cards**, top to bottom there are no lane entries to interleave
/// any more (03-board-ui.md § Trash: "Cards only").
///
/// Both lists are the **filtered** board (04 § Search: "arrow nav read[s] it"), so the
/// fallback lands on the last *visible* member rather than on a card the query hid.
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
let selection = store.selection
guard !selection.isEmpty else { return nil }
let isLaneDomain: Bool
let list: [ItemID]
switch selection.liveness {
case .live:
switch selection.container {
case .board:
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
isLaneDomain = kind == .lane
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter)
case .trashed:
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
case .trash:
isLaneDomain = false
list = trashEntries.map(\.id)
list = trashCards
}
if let head = store.transient.selectionHead, list.contains(head) {
return (head, selection.liveness, isLaneDomain)
return (head, selection.container, isLaneDomain)
}
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
return (last, selection.liveness, isLaneDomain)
return (last, selection.container, isLaneDomain)
}
/// **An empty selection seeds at the first lane's first card** (04-interactions.md Grammar)
@@ -779,8 +779,8 @@ struct BoardView: View {
if mode == .jump, direction == .left || direction == .right {
return jumpToEndLane(direction)
}
guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled }
replaceSelection(with: first, on: .live)
guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
replaceSelection(with: first, in: .board)
return .handled
}
@@ -790,7 +790,7 @@ struct BoardView: View {
_ direction: NavigationMath.Direction,
_ mode: ArrowMode,
from head: ItemID,
on side: Liveness
in container: ItemContainer
) -> KeyPress.Result {
switch mode {
case .step: step(direction, from: head)
@@ -798,7 +798,7 @@ struct BoardView: View {
case .jump:
switch direction {
case .left, .right: jumpToEndLane(direction)
case .up, .down: jumpWithinContainer(direction, from: head, on: side)
case .up, .down: jumpWithinContainer(direction, from: head, in: container)
}
}
}
@@ -817,7 +817,7 @@ struct BoardView: View {
),
let next = marqueeTargets.targets[nextID]
else { return .handled }
replaceSelection(with: next.id, on: next.side)
replaceSelection(with: next.id, in: next.container)
return .handled
}
@@ -837,7 +837,7 @@ struct BoardView: View {
among: marqueeTargets.all
),
let next = marqueeTargets.targets[nextID],
next.side == origin.side,
next.container == origin.container,
next.kind == origin.kind
else { return .handled }
@@ -849,13 +849,13 @@ struct BoardView: View {
from: anchor,
to: next.id,
kind: next.kind,
on: next.side,
in: store.snapshot,
in: next.container,
snapshot: store.snapshot,
// The span is the *filtered* board's, so a range under a search collects exactly the
// rows between the two endpoints that are on screen (04 § Search: "ranges read it").
// cards between the two endpoints that are on screen (04 § Search: "ranges read it").
filter: store.searchFilter
) else { return .handled }
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
store.select(ids, in: next.container, anchor: anchor, head: next.id)
return .handled
}
@@ -869,30 +869,30 @@ struct BoardView: View {
private func jumpWithinContainer(
_ direction: NavigationMath.Direction,
from head: ItemID,
on side: Liveness
in container: ItemContainer
) -> KeyPress.Result {
let container: [ItemID]
let siblings: [ItemID]
var lane: ItemID?
switch side {
case .trashed:
container = trashEntries.map(\.id)
case .live:
switch container {
case .trash:
siblings = trashCards
case .board:
guard let home = store.snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
lane.cards.contains { $0.id == head }
}) else { return .handled }
lane = home.id
// The container is what the lane is *showing*: a jump to "the lane's first card" under
// a search means its first surviving card, not one the filter animated out.
let filter = store.searchFilter
container = home.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
siblings = home.cards.filter { filter.matches($0) }.map(\.id)
}
guard let target = direction == .up ? container.first : container.last else { return .handled }
guard let target = direction == .up ? siblings.first : siblings.last else { return .handled }
if direction == .up, target == head, let lane, store.selection.ids == [head] {
replaceSelection(with: lane, on: .live)
replaceSelection(with: lane, in: .board)
return .handled
}
replaceSelection(with: target, on: side)
replaceSelection(with: target, in: container)
return .handled
}
@@ -904,11 +904,11 @@ struct BoardView: View {
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
if direction == .right, isTrashVisible, let first = trashEntries.first {
replaceSelection(with: first.id, on: .trashed)
if direction == .right, isTrashVisible, let first = trashCards.first {
replaceSelection(with: first, in: .trash)
return .handled
}
let lanes = liveLanes
let lanes = boardLanes
let filter = store.searchFilter
// A lane the search emptied is scanned past exactly as an empty one is the jump lands on
// the first lane that is *showing* a card, which is what the user can see.
@@ -916,7 +916,7 @@ struct BoardView: View {
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
: Self.firstCard(scanning: lanes, filter: filter)
guard let target else { return .handled }
replaceSelection(with: target, on: .live)
replaceSelection(with: target, in: .board)
return .handled
}
@@ -937,7 +937,7 @@ struct BoardView: View {
_ mode: ArrowMode,
from head: ItemID
) -> KeyPress.Result {
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
let lanes = SelectionGrammar.lanes(in: store.snapshot)
guard let index = lanes.firstIndex(of: head) else { return .handled }
switch (direction, mode) {
@@ -945,30 +945,30 @@ struct BoardView: View {
let next = index + (direction == .left ? -1 : 1)
guard lanes.indices.contains(next) else { return .handled }
if mode == .step {
replaceSelection(with: lanes[next], on: .live)
replaceSelection(with: lanes[next], in: .board)
} else {
let anchor = store.transient.selectionAnchor ?? head
guard let ids = SelectionGrammar.range(
from: anchor,
to: lanes[next],
kind: .lane,
on: .live,
in: store.snapshot
in: .board,
snapshot: store.snapshot
) else { return .handled }
store.select(ids, liveness: .live, anchor: anchor, head: lanes[next])
store.select(ids, in: .board, anchor: anchor, head: lanes[next])
}
case (.left, .jump), (.right, .jump):
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
replaceSelection(with: target, on: .live)
replaceSelection(with: target, in: .board)
case (.down, .step), (.down, .jump):
guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else {
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
return .handled
}
let cards = lane.cards.filter { !$0.isDeleted }
let cards = lane.cards
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
replaceSelection(with: target.id, on: .live)
replaceSelection(with: target.id, in: .board)
case (.up, _), (.down, .extend):
// Nothing above the lane domain, and no vertical range within it.
@@ -980,8 +980,8 @@ struct BoardView: View {
// MARK: Shared
/// A jump's and a plain step's shared landing: one item, both cursors on it.
private func replaceSelection(with id: ItemID, on side: Liveness) {
store.select([id], liveness: side, anchor: id, head: id)
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
store.select([id], in: container, anchor: id, head: id)
}
/// The first rendered card of the first lane that has one the scan every "first/last lane"
@@ -994,7 +994,7 @@ struct BoardView: View {
filter: SearchFilter = .inactive
) -> ItemID? {
for lane in lanes {
if let card = lane.cards.first(where: { !$0.isDeleted && filter.matches($0) }) { return card.id }
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
}
return nil
}
+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.
struct DragPayload: Codable, Sendable, Equatable {
/// Which side of the live/tombstoned boundary the drag started on a trash row's drag is a card
/// drag from the trashed side, and 04-interactions.md The trash gives it its own rules
/// (restore within the board, copy-out across boards).
enum Side: String, Codable, Sendable, Equatable {
case live
case trashed
init(_ liveness: Liveness) {
self = liveness == .live ? .live : .trashed
}
var liveness: Liveness { self == .live ? .live : .trashed }
}
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
struct Item: Codable, Sendable, Equatable {
var id: String
@@ -71,7 +57,11 @@ struct DragPayload: Codable, Sendable, Equatable {
var boardRoot: String
var kind: DragKind
var side: Side
/// Which container the drag started in a trash card's drag is a card drag from `.trash`, which
/// is the whole of what makes its within-board drop a restore (04-interactions.md The trash).
/// `ItemContainer` is `String`-backed and `Codable` precisely so it can ride a pasteboard.
var container: ItemContainer
/// The dragged items **in flatten order** "lane `order` first, then card `order`"
/// (04-interactions.md Drag and drop). The drop commits trust this order rather than
@@ -97,10 +87,10 @@ struct DragPayload: Codable, Sendable, Equatable {
try? JSONEncoder().encode(self)
}
init(boardRoot: URL, kind: DragKind, side: Liveness, items: [Item]) {
init(boardRoot: URL, kind: DragKind, container: ItemContainer, items: [Item]) {
self.boardRoot = boardRoot.path
self.kind = kind
self.side = Side(side)
self.container = container
self.items = items
}
+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
/// written down at all.
///
/// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the
/// snapshot it is `TrashModel.entries` derived from it so there is nothing to put in a
/// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`).
/// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card
/// folders directly same shape as a lane's children, no `index.md` of its own"
/// (01-storage-format.md § Deletion), so there is nothing to put in a `lane`, and its index is
/// not a position the pointer chose either (see `TrashDrop`).
enum Container: Equatable, Sendable {
/// The **lane strip**: the index counts live lanes with the dragged run removed.
/// The **lane strip**: the index counts the board's lanes with the dragged run removed.
case strip
/// That lane's **masonry**: the index is a position in its logical card order
/// (DRAG-REORDER.md § The card masonry).
case lane(ItemID)
/// The **trash quasi-lane**, which a live card drag proposes into to delete it
/// The **trash column**, which a board card drag proposes into to delete it
/// (04-interactions.md The trash, settled 2026-07-28). The index is always the topmost row.
case trash
}
@@ -57,8 +58,9 @@ enum TrashDrop {
/// The row the shadow takes, always: **the topmost**.
///
/// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted`
/// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop
/// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank
/// above the current top" (04-interactions.md The trash), so a fresh delete genuinely lands on
/// top. The drop
/// therefore still lands exactly where the shadow shows the one positional promise every other
/// drop in this app makes while being the only proposal on the board the *pointer* does not
/// choose.
@@ -70,7 +72,7 @@ enum TrashDrop {
/// - **Lanes are not deliverable this way** "a lane drag proposes only lane slots". (The strip's
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
/// because a guard that is only true by construction is one refactor from being false.)
/// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out;
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
/// dropping it back where it came from writes nothing.
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
@@ -82,14 +84,14 @@ enum TrashDrop {
/// - **The mutating-gesture rule**, like every other write the pointer can start.
static func accepts(
kind: DragKind?,
side: Liveness,
container: ItemContainer,
isWithinBoard: Bool,
operation: TransferOperation,
isTrashShown: Bool,
acceptsMutations: Bool
) -> Bool {
guard isTrashShown, acceptsMutations else { return false }
guard kind == .cards, side == .live, isWithinBoard else { return false }
guard kind == .cards, container == .board, isWithinBoard else { return false }
return operation == .move
}
}
@@ -207,14 +209,14 @@ enum DragLocality {
/// - **Lane drags never copy within their board.** is simply ignored there: the drag stays a
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
/// its home is the clipboard ( Clipboard, Lane paste).
/// - **A trash row's drag is copy-out grammar** ( The trash). Within its own board it is the
/// restore a move, no badge; across boards the default is the live copy that leaves the
/// tombstoned original in place, exactly as C out of the trash behaves. forces the true
/// restore-move either way, and forces the live copy either way ("C, -drag, and the
/// cross-board drag default always yield *live* copies").
/// - **A trash card's drag is the restore** ( The trash). Within its own board it is "an
/// ordinary move to the drop position"; across boards the default is the copy that leaves the
/// original in the source trash, and "-drag forces the true cross-board restore-move". Both
/// fall out of the ordinary locality rule with no trash clause at all, which is the pivot's
/// whole point.
static func operation(
kind: DragKind,
side: Liveness,
container: ItemContainer,
isWithinBoard: Bool,
modifiers: NSEvent.ModifierFlags
) -> TransferOperation {
@@ -227,7 +229,7 @@ enum DragLocality {
if forcesMove { return .move }
if forcesCopy { return .copy }
_ = side // the side changes which commit runs, never which operation the badge shows
_ = container // the container changes which commit runs, never which operation the badge shows
return isWithinBoard ? .move : .copy
}
}
@@ -272,9 +274,9 @@ final class DragSession {
/// means here rather than a separate flag.
private(set) var kind: DragKind?
/// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed`
/// side, and that is the whole of what makes it one (04-interactions.md The trash).
private(set) var side: Liveness = .live
/// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`,
/// and that is the whole of what makes it a restore (04-interactions.md The trash).
private(set) var container: ItemContainer = .board
/// The dragged items in **flatten order** the order they will land in.
private(set) var members: [ItemID] = []
@@ -358,8 +360,8 @@ final class DragSession {
/// The items to **leave out of the resting layout** on the board rooted at `root`.
///
/// Only the source board hides anything, and only for a live-side session: a trash row's drag
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
/// card to it.
///
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
@@ -368,7 +370,7 @@ final class DragSession {
/// originals reappear when the write lands the hold keeps them lifted for that round trip, so
/// the arrangement on screen is the one the release proposed and stays still until the echo.
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
guard isActive, side == .live, let sourceRoot,
guard isActive, container == .board, let sourceRoot,
DragLocality.isSameBoard(root, sourceRoot)
else { return [] }
return memberSet
@@ -473,27 +475,27 @@ final class DragSession {
// MARK: Lifecycle
/// Begins a card session live faces or trash rows.
/// Begins a card session board faces or trash cards.
///
/// - Parameters:
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's
/// own sorted order for a trash-row drag).
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
/// trash's own order for a trash-card drag).
/// - heights: their measured heights, captured **before** the pickup transition starts.
func beginCards(
_ members: [ItemID],
folders: [URL],
heights: [CGFloat],
side: Liveness,
container: ItemContainer,
source: BoardStore
) {
begin(kind: .cards, members: members, folders: folders, side: side, source: source)
begin(kind: .cards, members: members, folders: folders, container: container, source: source)
cardHeights = heights
laneUnits = []
}
/// Begins a lane session.
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
begin(kind: .lanes, members: members, folders: folders, container: .board, source: source)
laneUnits = units
cardHeights = []
}
@@ -502,7 +504,7 @@ final class DragSession {
kind: DragKind,
members: [ItemID],
folders: [URL],
side: Liveness,
container: ItemContainer,
source: BoardStore
) {
endHold()
@@ -510,14 +512,14 @@ final class DragSession {
self.members = members
self.memberSet = Set(members)
self.folders = folders
self.side = side
self.container = container
self.sourceStore = source
self.sourceRoot = source.rootURL
self.proposal = nil
self.operation = .move
// The reload-resolved drag set: vanished members leave it silently, which is what
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, liveness: side)
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container)
armWatchdog()
}
@@ -538,7 +540,7 @@ final class DragSession {
guard let kind, let sourceRoot else { return operation }
let resolved = DragLocality.operation(
kind: kind,
side: side,
container: container,
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
modifiers: NSEvent.modifierFlags
)
+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.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current,
togglesOnRepeat: true
)
@@ -232,17 +232,17 @@ struct LaneView: View {
/// alone standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
/// Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
return .items([lane.id])
}
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<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).
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 store.selection.ids
@@ -352,20 +352,20 @@ struct LaneView: View {
private func startLaneDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(lane.id)
&& selection.ids.count > 1
? selection.ids
: [lane.id]
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
let members = store.snapshot.lanes.filter { ids.contains($0.id) }
guard !members.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .lanes,
side: .live,
container: .board,
items: members.map {
DragPayload.Item(
id: $0.id.rawValue,
@@ -405,7 +405,7 @@ struct LaneView: View {
private var draggedLaneCount: Int {
let selection = store.selection
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 }
return selection.ids.count
}
@@ -544,7 +544,7 @@ struct LaneView: View {
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current,
togglesOnRepeat: true
)
@@ -552,7 +552,7 @@ struct LaneView: View {
// The rubber band's first surface "click-drag rubber-bands across lanes". Simultaneous
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
// started on a card face out of it (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(side: .live))
.simultaneousGesture(marquee.gesture(in: .board))
// The same menu the header carries "one menu, invoked on the header or lane empty
// space alike" (03-board-ui.md § Lane, settled).
.contextMenu { laneMenu }
@@ -688,7 +688,7 @@ struct LaneView: View {
renaming: ItemID?
) -> [Card] {
cards.filter { card in
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
guard !hidden.contains(card.id) else { return false }
return filter.matches(card) || card.id == renaming
}
}
@@ -696,7 +696,7 @@ struct LaneView: View {
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
store.selection.container == .board && store.selection.ids.contains(lane.id)
}
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet
@@ -922,7 +922,7 @@ private struct CardFaceView: View {
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current)
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
}
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
@@ -948,7 +948,7 @@ private struct CardFaceView: View {
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
@@ -960,7 +960,7 @@ private struct CardFaceView: View {
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
@@ -969,7 +969,7 @@ private struct CardFaceView: View {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
@@ -978,21 +978,21 @@ private struct CardFaceView: View {
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String?] = [:]
for lane in snapshot.lanes where !lane.isDeleted {
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
var titles: [ItemID: String] = [:]
for lane in snapshot.lanes {
for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
side: .live,
container: .board,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
@@ -1001,7 +1001,7 @@ private struct CardFaceView: View {
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id] ?? nil
title: titles[id]
)
}
)
@@ -1012,7 +1012,7 @@ private struct CardFaceView: View {
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
side: .live,
container: .board,
source: store
)
return payload.itemProvider()
@@ -1021,7 +1021,7 @@ private struct CardFaceView: View {
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
@@ -1098,17 +1098,17 @@ private struct CardFaceView: View {
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return .items([card.id])
}
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<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).
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 store.selection.ids
@@ -1205,7 +1205,7 @@ private struct CardFaceView: View {
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(card.id)
store.selection.container == .board && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
@@ -1330,14 +1330,14 @@ private struct NewCardStubView: View {
///
/// The lane is read before the commit, because every discard path clears the overlay that holds
/// it and re-checked after, because one of those paths is *the lane vanished*, and selecting
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
/// something that renders nowhere would break the one-container invariant until the
/// next reload swept it away.
@discardableResult
private func commit() -> ItemID? {
let lane = store.transient.newCardPlaceholder?.laneID
let id = store.commitPlaceholder()
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
store.select([lane], liveness: .live)
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
store.select([lane], in: .board)
}
return id
}
+6 -6
View File
@@ -30,7 +30,7 @@ final class MarqueeSession {
private(set) var current: CGPoint = .zero
/// The side of the live/trash boundary this band selects on, frozen at `begin`.
private(set) var side: Liveness = .live
private(set) var container: ItemContainer = .board
/// How far the pointer must travel before a drag on empty space becomes a band. Larger than the
/// lane header's threshold because this gesture arms on *any* empty surface, and a click that
@@ -51,10 +51,10 @@ final class MarqueeSession {
)
}
func begin(at point: CGPoint, side: Liveness) {
func begin(at point: CGPoint, in container: ItemContainer) {
origin = point
current = point
self.side = side
self.container = container
}
func update(to point: CGPoint) {
@@ -81,9 +81,9 @@ final class MarqueeSession {
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
/// flight, and a foreign reload the frames simply re-register.
///
/// **Lanes are never registered.** The band selects cards, and trash rows on the trash side; a lane
/// has no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands
/// across lanes" made structural rather than filtered.
/// **Lanes are never registered.** The band selects cards board cards or trash cards; a lane has
/// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across
/// lanes" made structural rather than filtered.
///
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
+5 -5
View File
@@ -41,7 +41,7 @@ enum NewCardTarget {
/// the two can never disagree.
///
/// - Parameters:
/// - selection: the board's current selection, liveness side included. A `.trashed` selection
/// - selection: the board's current selection, container included. A `.trash` selection
/// "never anchors creation" and is treated exactly as an empty one the settled precedent
/// 04 Clipboard cites for paste, applied here to its source rule ("a trashed card's live
/// disk-lane never leaks in as 'the selected card's lane'").
@@ -53,7 +53,7 @@ enum NewCardTarget {
lastActiveLaneID: ItemID?,
snapshot: BoardModel
) -> Resolution? {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
let lanes = snapshot.lanes
guard !lanes.isEmpty else { return nil }
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
@@ -86,21 +86,21 @@ enum NewCardTarget {
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
/// nothing the board renders.
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
guard selection.liveness == .live, !selection.ids.isEmpty else { return nil }
guard selection.container == .board, !selection.ids.isEmpty else { return nil }
// The snapshot's lanes and cards are already in display order, so the flatten order is one
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
// one-member case of the same rule.
var anchor: Resolution?
for lane in snapshot.lanes where !lane.isDeleted {
for lane in snapshot.lanes {
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
// the lane itself.
if selection.ids.contains(lane.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
}
// A selected card: its lane, immediately after it paste-anchor consistency.
for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) {
for card in lane.cards where selection.ids.contains(card.id) {
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
}
}
+3 -3
View File
@@ -50,10 +50,10 @@ enum PasteTarget {
lastActiveLaneID: lastActiveLaneID,
snapshot: snapshot
),
let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID && !$0.isDeleted })
let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID })
else { return nil }
let rendered = lane.cards.filter { !$0.isDeleted }
let rendered = lane.cards
// `insertionIndex` answers `nil` for "append", which is `rendered.count` the same position
// said two ways, and the creation path's own degradation for an anchor that has since gone.
let index = BoardStore.insertionIndex(after: resolution.anchorCardID, among: rendered) ?? rendered.count
@@ -64,7 +64,7 @@ enum PasteTarget {
/// included, because lane paste "stays enabled and lands at the board's right end" whatever the
/// board holds. That is what makes it the other way out of a board with no lanes.
static func lanes(selection: ItemReferenceSet, snapshot: BoardModel) -> Int {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
let lanes = snapshot.lanes
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
else {
+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
/// by gesture priority is what keeps the two from fighting, and it stays correct as the
/// masonry reflows.
/// - **The side is fixed at the origin** 04-interactions.md The trash's rule, stored in the
/// session so a band dragged across the boundary keeps its meaning.
/// - **The container is fixed at the origin** 04-interactions.md The trash's rule ("the
/// rubber band stays on the side it started on"), stored in the session so a band dragged
/// across the boundary keeps its meaning.
/// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the
/// band, so the selection follows the cursor both ways. An empty band clears rather than
/// leaving the last non-empty one standing.
/// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The
/// lock's scope), and no `isEditingInline` guard either a click-away mid-rename already
/// commits through the field's own focus loss.
func gesture(side: Liveness) -> some Gesture {
func gesture(in container: ItemContainer) -> some Gesture {
DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace))
.onChanged { value in
if !session.isActive {
guard !registry.contains(value.startLocation) else { return }
session.begin(at: value.startLocation, side: side)
session.begin(at: value.startLocation, in: container)
}
session.update(to: value.location)
guard let rect = session.rect else { return }
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, side: session.side)
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, in: session.container)
if ids.isEmpty {
store.clearSelection()
} else {
@@ -74,7 +75,7 @@ struct MarqueeControl {
// Both are spelled out rather than defaulted, because `select`'s sole-member
// default would otherwise pick one up the moment a band happened to sweep
// exactly one card.
store.select(ids, liveness: session.side, anchor: nil, head: nil)
store.select(ids, in: session.container, anchor: nil, head: nil)
}
}
.onEnded { _ in session.end() }
@@ -111,7 +112,7 @@ extension View {
func marqueeTarget(
_ id: ItemID,
kind: SelectionKind,
side: Liveness,
container: ItemContainer,
in registry: MarqueeTargetRegistry
) -> some View {
// The space name is read here, on the main actor, rather than inside the measuring closure:
@@ -120,7 +121,7 @@ extension View {
return onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .named(space))
} action: { frame in
registry.update(MarqueeTarget(id: id, kind: kind, side: side, frame: frame))
registry.update(MarqueeTarget(id: id, kind: kind, container: container, frame: frame))
}
.onDisappear { registry.remove(id) }
}
+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
/// user is being asked about the trash as it was when they invoked the command, and a foreign
/// reload landing mid-alert must not silently change the sentence they are reading. The *action*
/// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on an
/// item that has since gone `BoardWriter.purgeItem` treats an absent folder as success.
/// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on a
/// card that has since gone the writer treats an absent folder as success.
private(set) var pending: Pending?
struct Pending: Identifiable, Equatable {
@@ -30,20 +30,46 @@ final class TrashConfirmations {
let prompt: TrashModel.PurgePrompt
let action: Action
/// What the confirmation is standing in front of. Two cases, because the two commands have
/// genuinely different scopes: one names a selection, the other names the whole trash and
/// re-derives its targets at the moment it runs.
/// What the confirmation is standing in front of. Three cases, because the three commands
/// have genuinely different scopes and two different writes: the trash's own staged Delete,
/// Delete Immediately (which skips the trash from either container), and Empty Trash (which
/// names the whole container and re-derives its targets at the moment it runs).
enum Action: Equatable {
case deleteTrashCards(Set<ItemID>)
case purge(Set<ItemID>)
case emptyTrash
}
}
/// **File Delete, staged by place** (04-interactions.md The map) with the confirmation the
/// trash side owes and the board side does not.
///
/// A board selection goes straight through: moving a card into the trash and deleting a lane are
/// both recoverable (the trash itself, and native undo 03-board-ui.md § Trash), so neither
/// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where
/// the loss is real": `purgeIsUnrecoverable` decides, exactly as it does for Delete Immediately.
///
/// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and
/// nothing else the two can never disagree about which write a performs.
func requestDelete(in store: BoardStore) {
guard store.selection.container == .trash, store.purgeIsUnrecoverable else {
store.deleteSelection()
return
}
guard let prompt = TrashModel.purgePrompt(
for: store.selection.ids,
in: .trash,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids))
}
/// Raises Delete Immediately's alert **or purges outright** where the loss is not real.
///
/// The mode check is the one thing that decides between the two, and it lives on the store as a
/// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one
/// expression rather than two call sites.
/// expression rather than three call sites.
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids)
@@ -51,7 +77,8 @@ final class TrashConfirmations {
}
guard let prompt = TrashModel.purgePrompt(
for: ids,
in: store.snapshot,
in: store.selection.container,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .purge(ids))
@@ -73,6 +100,7 @@ final class TrashConfirmations {
guard let pending else { return }
self.pending = nil
switch pending.action {
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
case let .purge(ids): store.deleteImmediately(ids)
case .emptyTrash: store.emptyTrash()
}
@@ -96,22 +124,17 @@ extension FocusedValues {
}
}
// MARK: - File Delete / Put Back / Delete Immediately / Empty Trash
// MARK: - File Delete / Delete Immediately / Empty Trash
/// The File menu's trash rows (11-command-nexus.md).
///
/// ### The chord twins
/// ### One Delete, staged by place
///
/// Delete and Put Back are **two items sharing one key equivalent**, and validation enables exactly
/// one of them: "AppKit routes a shared key equivalent to the enabled item" (04-interactions.md
/// The map, which names Finder's own Move to Trash/Put Back pair as the precedent). The two
/// predicates are mirror images over the selection's liveness side
/// (`TrashModel.canDelete`/`canActOnTrash`), so they can neither both enable nor both disable while
/// something is selected and a selection can never be mixed, because
/// `ItemReferenceSet.resolved(against:)` treats a liveness flip as a vanish.
///
/// **Both titles stay stable** (titles-are-API): each remaps independently through the system
/// mechanism, and remapping one never moves the other's role.
/// **Put Back is retired with the tombstone model** (04-interactions.md The map, resettled
/// 2026-07-28): "File Delete is the chord's only owner no twin menu items, no shared-equivalent
/// routing". The chord therefore has exactly one owner, its validation is one predicate
/// (`TrashModel.canDelete`), and which write it performs is decided by the selection's *container*
/// inside the store rather than by AppKit picking whichever of two items happened to be enabled.
struct TrashCommands: View {
@FocusedValue(\.boardStore) private var store
@@ -119,24 +142,18 @@ struct TrashCommands: View {
var body: some View {
Button("Delete") {
store?.deleteSelection()
guard let store, let confirmations else { return }
confirmations.requestDelete(in: store)
}
.keyboardShortcut(.delete, modifiers: .command)
.disabled(!canDelete)
Button("Put Back") {
guard let store else { return }
store.putBack(store.selection.ids)
}
.keyboardShortcut(.delete, modifiers: .command)
.disabled(!canActOnTrash)
.disabled(!canDelete || confirmations == nil)
Button("Delete Immediately") {
guard let store, let confirmations else { return }
confirmations.requestPurge(of: store.selection.ids, in: store)
}
.keyboardShortcut(.delete, modifiers: [.option, .command])
.disabled(!canActOnTrash || confirmations == nil)
.disabled(!canDeleteImmediately || confirmations == nil)
Button("Empty Trash…") {
guard let store, let confirmations else { return }
@@ -146,29 +163,29 @@ struct TrashCommands: View {
.disabled(!canEmptyTrash)
}
/// A live, non-empty selection on a board that accepts writes.
/// A non-empty selection that still names something, on a board that accepts writes both
/// stagings at once, which is what having one item means.
private var canDelete: Bool {
guard let store, store.acceptsBoardMutations else { return false }
return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
}
/// A tombstoned, non-empty selection Put Back's condition and Delete Immediately's alike, the
/// two being the trash side's pair (04-interactions.md The trash: "menu validation stays
/// binary").
private var canActOnTrash: Bool {
/// A **card** selection, in either container "skips the trash from anywhere"
/// (11-command-nexus.md).
private var canDeleteImmediately: Bool {
guard let store, store.acceptsBoardMutations else { return false }
return TrashModel.canActOnTrash(selection: store.selection, in: store.snapshot)
return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot)
}
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) where
/// "non-empty" reads the *board's* tombstones and never the filtered view (03-board-ui.md §
/// Trash: "a bulk command about the trash itself never silently narrows to the visible subset").
/// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu
/// validation's non-empty reads `.trash/`, not the filtered view").
///
/// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
/// invisible to every gesture".
private var canEmptyTrash: Bool {
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
return !TrashModel.isEmpty(store.snapshot)
return !store.snapshot.trash.isEmpty
}
}
@@ -197,12 +214,10 @@ struct ShowTrashCommand: View {
/// The toggle's binding and the one place hiding the trash has a consequence beyond layout.
///
/// **Hiding drops a tombstoned selection.** The rows it pointed at are no longer on screen, and
/// "nothing invisible may stay selected" is the invariant every item-referencing set in this app
/// already obeys (`ItemReferenceSet`); leaving one behind would also leave Put Back and Delete
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched the
/// board it names is still right there.
/// **Hiding drops a trash selection** (04-interactions.md The trash: "hiding it clears a
/// selection of trash cards nothing invisible stays selected, so the toggle-off drops the
/// selection rather than leave commands enabled against rows nobody can see"). A *board*
/// selection is untouched the board it names is still right there.
///
/// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
/// Show Trash item is this same command with a different face (03-board-ui.md Toolbar: "toggle
@@ -237,7 +252,7 @@ extension BoardStore {
// very animation, and a selection that cleared outside it would be the highlight easing
// on its own which 03 § Motion rules out ("the selection highlight rides whatever
// transaction is active").
if !shown, selection.liveness == .trashed {
if !shown, selection.container == .trash {
clearSelection()
}
}
+121 -177
View File
@@ -4,63 +4,55 @@ import SwiftUI
// MARK: - TrashLaneView
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
/// (03-board-ui.md § Trash).
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
/// (03-board-ui.md § Trash, resettled 2026-07-28 the materialized trash).
///
/// ### A pure view, and a quasi-lane
/// ### A rendering of `snapshot.trash`, and a quasi-lane
///
/// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay
/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more.
/// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery:
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column
/// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use
/// and sorted by `order` like any lane's children. Newest-first falls out of the ranks (every
/// arrival mints one above the current top), so there is no timestamp sort and no entry type here at
/// all. It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's
/// machinery:
///
/// - it spans a **fixed one width unit** no `width` frontmatter, no stepper, no resize handle, and
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
/// so there is no `Lane` value for any of those to act on);
/// - it is **not draggable and not reorderable** the header carries no gesture, and it is absent
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
/// snapshot's live lanes;
/// - it is **no destination for a move** "no move or paste ever targets the trash"
/// (04-interactions.md The trash). It is a destination for exactly one thing, below;
/// snapshot's lanes;
/// - it has **no new-card button**: nothing is created in the trash.
///
/// ### The one drop it takes: the delete gesture
/// ### The drop it takes, and the drag it starts
///
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md The trash, settled
/// 2026-07-28) "the drag becomes the pointer's delete gesture; release tombstones the dragged
/// card(s), exactly the tombstone". So the column does declare an `onDrop`
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this**
/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would
/// be a transfer-and-delete compound), is not (copying into the trash is not a thing), and hidden
/// the column is not rendered at all, so it has no region to enter. Every one of those refusals hands
/// the session back to the strip's own logic, which is exactly what happened here before this target
/// existed so nothing about the column's behaviour changed except the gesture that is new
/// (`TrashDrop`).
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md The trash) the
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
/// row**, because every arrival mints a rank above the current top.
///
/// It diverges from every other drop on the board in one way, and the sort is what makes the
/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by
/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly
/// where the shadow shows.
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
/// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it
/// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an
/// ordinary move out there is no restore-specific machinery and no Put Back" (03 § Trash).
///
/// **Finder file drops stay inert** "attachment import on tombstoned cards is inert" ( The trash)
/// and now say so directly: the delegate clears the file highlight over the column rather than
/// relying on the strip resolving to no lane.
/// **Finder file drops stay inert** "Finder file drops on trash cards are inert" ( The trash)
/// and say so directly: the delegate clears the file highlight over the column.
///
/// ### No editing in the trash
///
/// "Tombstoned cards don't open double-click does nothing beyond selection; Put Back or drag out
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style row: the
/// trash is for restoring or purging, not working.
/// "No editing in the trash: trash cards don't open double-click stops at selection; move it out
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style row.
///
/// ### What is still a later card's
/// ### What is still phase 3's
///
/// **C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like
/// any lane") arrived with m5 and is one line see `entries`, which every other surface here reads
/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The
/// **keyboard** reaches the column entirely through the frames the rows register arrow walks in
/// and out, -arrows inert at both the liveness and the kind boundary so nothing in this file
/// implements it beyond keeping every row drawn and registered (see `rows`).
/// The column renders the materialized trash correctly and its selection, drag, drop, filter and
/// context menu all speak the new container vocabulary but its *visual* treatment is still the
/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
struct TrashLaneView: View {
let store: BoardStore
@@ -71,13 +63,13 @@ struct TrashLaneView: View {
/// does.
let confirmations: TrashConfirmations
/// The board window's drop machinery a row's drag is an ordinary card session on the
/// **trashed** side (`DragSession`, 04-interactions.md The trash).
/// The board window's drop machinery a card's drag is an ordinary card session in the
/// **trash** container (`DragSession`, 04-interactions.md The trash).
let drops: BoardDropContext
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
/// side "a rubber-band stays on the side of the boundary it started on" (04-interactions.md
/// The trash) and every row registers its frame into the same registry.
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
/// container "the rubber band stays on the side it started on" (04-interactions.md The
/// trash) and every row registers its frame into the same registry.
let marquee: MarqueeControl
/// Reduce Motion, for the row transition below 10-accessibility.md names the trash
@@ -91,9 +83,9 @@ struct TrashLaneView: View {
private let rowSpacing: CGFloat = 6
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
/// lines, plus a lane entry's count line) and the cards being proposed have no row yet to be
/// measured, so the shadow is drawn at the nominal single-line plate `LaneDropRegistry`'s own
/// answer to the same question, in this column's smaller idiom.
/// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
/// the nominal single-line plate `LaneDropRegistry`'s own answer to the same question, in this
/// column's smaller idiom.
private let nominalRowHeight: CGFloat = 32
var body: some View {
@@ -113,16 +105,16 @@ struct TrashLaneView: View {
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
}
/// The rows the column shows.
/// The cards the column shows.
///
/// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so
/// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards`
/// card rows and lane rows alike, each by its own title and body (`SearchFilter`) and the
/// count badge follows for free, because it reads this same value. Hidden, the column renders
/// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all.
private var entries: [TrashEntry] {
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
/// (03-board-ui.md § Trash "the point of the pivot"), so the search predicate narrows this
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
/// free because it reads this same value. Hidden, the column renders nothing and registers
/// nothing, so "hidden trash is invisible to search" needs no code at all.
private var cards: [Card] {
let filter = store.searchFilter
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
return store.snapshot.trash.filter { filter.matches($0) }
}
// MARK: - The delete gesture's landing
@@ -140,7 +132,7 @@ struct TrashLaneView: View {
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
/// other container's does (`CommittedHold`).
private var slots: [TrashSlot] {
var result = entries.map(TrashSlot.entry)
var result = cards.map(TrashSlot.card)
guard let proposal else { return result }
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
@@ -186,11 +178,10 @@ struct TrashLaneView: View {
.accessibilityElement(children: .combine)
}
/// The entry count the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second
/// change here).
/// The card count the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule).
private var countBadge: some View {
Text("\(entries.count)")
Text("\(cards.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
@@ -210,7 +201,7 @@ struct TrashLaneView: View {
ScrollViewReader { proxy in
scrollableRows
.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))
}
}
@@ -227,10 +218,10 @@ struct TrashLaneView: View {
ForEach(slots) { slot in
Group {
switch slot {
case let .entry(entry):
TrashEntryRow(
case let .card(card):
TrashCardRow(
store: store,
entry: entry,
card: card,
confirmations: confirmations,
drops: drops,
registry: marquee.registry
@@ -243,10 +234,10 @@ struct TrashLaneView: View {
.frame(height: nominalRowHeight)
}
}
// A row is a tombstoned item, so it arrives and leaves in the card's dialect
// a delete files one in, a Put Back or a purge takes one out, and both halves of
// that pair should read alike from either side of the strip. The transaction is
// the reload's, like the lanes' (`Motion.reloadAnimates`).
// A row is a card, so it arrives and leaves in the card's dialect a delete
// files one in, a restore or a purge takes one out, and both halves of that pair
// should read alike from either side of the strip. The transaction is the
// reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion))
// The scroll target `LaneView`'s rule, and outermost for its reason.
.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
// row's drag-out, and the begin guard makes that geometric rather than a matter of
// gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(side: .trashed))
.simultaneousGesture(marquee.gesture(in: .trash))
}
}
}
@@ -289,13 +280,10 @@ private struct TrashRowPlate: View {
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).
let title: String?
/// A lane entry's returning-card count, and nothing else takes a second line.
let subtitle: String?
var isSelected: Bool = false
private let cornerRadius: CGFloat = 6
@@ -305,18 +293,11 @@ private struct TrashRowPlate: View {
Image(systemName: symbol)
.foregroundStyle(.secondary)
.imageScale(.small)
VStack(alignment: .leading, spacing: 2) {
Text(title ?? "Untitled")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(title ?? "Untitled")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
@@ -336,22 +317,22 @@ private struct TrashRowPlate: View {
/// echo reload reads as a *swap* or as a removal and an insertion.
private enum TrashSlot: Identifiable {
/// A tombstone the snapshot already holds.
case entry(TrashEntry)
/// A card the snapshot's trash already holds.
case card(Card)
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md The trash).
case shadow(index: Int)
var id: String {
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
// than blinking (`LaneSlot`'s rule).
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:)`).
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
}
@@ -381,145 +362,113 @@ private struct DiagonalHatch: Shape {
// MARK: - Rows
/// One trash row: a compact, dimmed plate carrying the item's symbol and title and, for a lane
/// entry, the count of cards Put Back would return with it.
/// One trash row: a compact, dimmed plate carrying the card's symbol and title.
///
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
/// Style. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather
/// than as a pile of `disabled` modifiers.
private struct TrashEntryRow: View {
/// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's see
/// `TrashLaneView`.)
private struct TrashCardRow: View {
let store: BoardStore
let entry: TrashEntry
let card: Card
let confirmations: TrashConfirmations
let drops: BoardDropContext
/// Where the rubber band looks up what it is sweeping the card face's rule, on the trashed
/// side (`View.marqueeTarget`).
/// Where the rubber band looks up what it is sweeping the card face's rule, in the trash
/// container (`View.marqueeTarget`).
let registry: MarqueeTargetRegistry
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
/// simply *absent* for them rather than refused no session, no image, no snap-back. A click
/// still selects either way.
@ViewBuilder
var body: some View {
if entry.isLaneEntry {
plate
} else {
plate.onDrag(startRowDrag, preview: { dragReplica })
}
plate.onDrag(startRowDrag, preview: { dragReplica })
}
private var plate: some View {
rowFace
// The row being dragged out dims in place the source stays visible in the trash,
// because a restore is not a removal until the write lands.
.opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1)
// The deferred cut wears the same dim wherever it lands, so the treatment is stated for
// every surface a `pendingCut` could name rather than for two of the three. In practice
// it never fires here: X is disabled on tombstoned selections (04-interactions.md The
// trash), and a pending cut is homogeneous by liveness a reload that tombstones a cut
// card *ejects* it from the set rather than moving it to the other side.
.cutTreatment(of: entry.id, in: store)
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
// The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
// "X works cut in the trash, paste into a lane is the keyboard-native restore"
// (04-interactions.md The trash, resettled 2026-07-28).
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle())
.onTapGesture { select() }
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
.contextMenu { menu }
}
/// This entry as the shared plate draws it appearance only, no gesture, no context menu and
/// This card as the shared plate draws it appearance only, no gesture, no context menu and
/// crucially no marquee registration, which is what makes it safe for the drag replica to render
/// (see `TrashRowPlate`).
private var rowFace: some View {
TrashRowPlate(
symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback),
title: entry.title,
subtitle: laneSubtitle,
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
title: card.title.value,
isSelected: isSelected
)
}
private var symbolFallback: String {
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
}
/// "N cards" what Put Back brings back with a tombstoned lane, not how many folders sit inside
/// it (`TrashModel.entries`' returning-count rule). Card entries have no second line.
private var laneSubtitle: String? {
guard case let .lane(_, returning) = entry else { return nil }
return "\(returning) card\(returning == 1 ? "" : "s")"
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
store.selection.container == .trash && store.selection.ids.contains(card.id)
}
/// A click selects this row on the **trashed** side, through the same grammar the board's
/// A click selects this card in the **trash** container, through the same grammar the board's
/// surfaces use plain replaces, toggles, ranges (`SelectionGrammar`).
///
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
/// axis true: a -click across the card/lane-entry boundary replaces rather than mixing, and a
/// -range walks only its own kind's rows (04-interactions.md The trash). No `togglesOnRepeat`
/// click-again-to-unselect is the lane's behaviour, not a row's.
/// The container travels with the click, and that is what keeps the one remaining homogeneity
/// boundary true: a -click across it replaces rather than mixing (04-interactions.md The
/// trash). There is no kind axis inside the trash any more lanes are never trashed. No
/// `togglesOnRepeat` click-again-to-unselect is the lane's behaviour, not a card's.
///
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
private func select() {
store.click(
SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
SelectionTarget(id: card.id, kind: .card, container: .trash),
modifier: .current
)
}
// MARK: - Drag out
/// Begins the row's drag out of the trash an ordinary **card session on the trashed side**,
/// which is the whole of what makes it a restore rather than a move (04-interactions.md The
/// trash; `DragLocality.operation`).
/// Begins the card's drag out of the trash an ordinary **card session in the trash container**,
/// which is the whole of what makes it a restore (04-interactions.md The trash;
/// `DragLocality.operation`).
///
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
/// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives
/// in one place (`BoardDropContext.commitDrop`): within the board a restore, a live copy-out,
/// across boards a live copy with forcing the true restore-move.
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
/// every other card drop uses, committed by the same `moveCards`.
///
/// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order the
/// order the rows are drawn in, which is the only relative order a set of tombstones has.
/// **Multi-drag carries the whole trash selection**, in the column's own order the order the
/// rows are drawn in, which is `order` ascending like any lane's.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture.
private func startRowDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .trashed
&& selection.ids.contains(entry.id)
let ids: Set<ItemID> = selection.container == .trash
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
: [entry.id]
: [card.id]
// Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed
// selection is simply not carried. (The selection is homogeneous by kind anyway this is
// belt over braces.)
let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot)
.compactMap { candidate in
guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil }
return (card.id, laneID, card.title.value)
}
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
side: .trashed,
container: .trash,
items: rows.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path,
title: $0.title
folder: ItemPath.trashCard($0.id).folder(under: root).path,
title: $0.title.value
)
}
)
@@ -527,7 +476,7 @@ private struct TrashEntryRow: View {
rows.map(\.id),
folders: payload.folders,
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
side: .trashed,
container: .trash,
source: store
)
return payload.itemProvider()
@@ -536,7 +485,7 @@ private struct TrashEntryRow: View {
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
/// multi-drag the card replica's treatment, at a trash row's size.
private var dragReplica: some View {
let count = store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
@@ -549,22 +498,18 @@ private struct TrashEntryRow: View {
.padding(12)
}
// MARK: - The trash entry's context menu
// MARK: - The trash card's context menu
/// Put Back, Delete Immediately, Reveal in Finder the three rows 11-command-nexus.md gives a
/// trash entry, and no others.
/// Delete and Reveal in Finder the two rows 11-command-nexus.md gives a trash card, and no
/// others ("Trash cards | Delete (permanent 03's recoverability confirm), Reveal in Finder").
///
/// **Put Back is gone** with the tombstone model: restoring is drag-out or X/V (03 § Trash).
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
/// enabled on tombstoned selections", read-only lock included inspecting a folder before a
/// purge is exactly the errand it exists for.
/// enabled on trash selections", read-only lock included inspecting a folder before a purge is
/// exactly the errand it exists for.
@ViewBuilder
private var menu: some View {
Button("Put Back") {
store.putBack(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") {
Button("Delete") {
confirmations.requestPurge(of: targetIDs, in: store)
}
.disabled(!store.acceptsBoardMutations)
@@ -578,17 +523,16 @@ private struct TrashEntryRow: View {
/// What this row's menu acts on: the whole selection when this row is part of it, else this row
/// alone standard macOS context-menu targeting, and the same rule the card face and the lane
/// header apply to Style. Right-clicking something outside the selection acts on what was
/// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set.
/// header apply to Style.
private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
return [entry.id]
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
private var targetFolders: [URL] {
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
}