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:
@@ -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 — …").
|
||||
///
|
||||
|
||||
+705
-658
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } }) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user