Files
lanework/Kanban/LiveStore/ItemLocation.swift
T
rzen bec75e4282 Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 18:35:07 -04:00

299 lines
13 KiB
Swift

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)".
///
/// **Both kinds live on both sides** (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes trash
/// too"): the trash's universe is its cards *and* its trashed lanes, flat and interleaved by rank.
/// What the tombstone model needed and this does not is the old two-sided machinery — the ancestor
/// walk, effective liveness, the entry-vs-universe split — not the kind axis, which is the board's
/// own cards-XOR-lanes rule reaching a second container.
///
/// `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 its
/// cards **and its trashed lanes** — every id the container holds, which is what a universe is.
/// A trashed lane's own cards are deliberately not in it: they are not in the snapshot at all
/// (the entry is opaque — 03-board-ui.md § Trash), so a selected or cut card whose lane was
/// trashed leaves every referencing set by the ordinary vanish rule, with no clause of its own.
///
/// 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)
}
for lane in snapshot.trashedLanes {
universe.insert(lane.id)
}
}
return universe
}
}
// MARK: - The trash's two kinds, in one order
/// One row of the trash column — a card, or a trashed lane's opaque unit (03-board-ui.md § Trash,
/// re-ruled 2026-07-31: "Lane rows and cards interleave in the one trash column by `modified`
/// descending").
///
/// **It exists so the interleave is written once.** The container's two kinds are two arrays on the
/// snapshot, for the reason `BoardModel.trash` states — a trashed card is an ordinary card every
/// card-shaped surface already reads, a trashed lane is an opaque row none of them may — but *column
/// order* is a question about the container as a whole, and it is asked by the column that draws the
/// rows, by the grammar that ranges and navigates over them, and by the path resolver that batches
/// them. Three merges would be three chances to disagree about what "the row below this one" is.
///
/// It carries the values rather than only the ids because the column needs them to draw; every other
/// consumer reads the two derived facts, `id` and `kind`.
public enum TrashEntry: Identifiable, Sendable, Equatable {
case card(Card)
case lane(TrashedLane)
public var id: ItemID {
switch self {
case let .card(card): card.id
case let .lane(lane): lane.id
}
}
/// **The stamp that decides where this row sits among the others** — the trash sorts by
/// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
/// move is what writes it. `nil` for an entry whose mover skipped the restamp, which sorts below
/// every dated sibling (`Ranks.isOrderedForTrash`).
public var modified: Date? {
switch self {
case let .card(card): card.modified.value
case let .lane(lane): lane.modified.value
}
}
/// The first tie-break's key — the title as the row draws it, `nil` when the entry has none.
public var title: String? {
switch self {
case let .card(card): card.title.value
case let .lane(lane): lane.title.value
}
}
/// The rank the entry is *carrying*, untouched by the trash move — its position among the lane
/// (or board) siblings it left, which a restore returns it to. Deliberately **not** what orders
/// this row: the trash is sorted by `modified` (above).
public var order: Double {
switch self {
case let .card(card): card.order
case let .lane(lane): lane.order
}
}
/// Which of the board's two selectable levels this row is — the kind axis, which reaches into
/// the trash exactly as it governs the live board (04-interactions.md ▸ The trash: "a trash
/// selection is either cards or lane rows, kind-homogeneous like the live board's own grammar").
public var kind: SelectionKind {
switch self {
case .card: .card
case .lane: .lane
}
}
/// The folder this row names.
public var path: ItemPath {
switch self {
case let .card(card): .trashCard(card.id)
case let .lane(lane): .trashLane(lane.id)
}
}
}
extension BoardModel {
/// The trash's rows, top to bottom — **the container's one order**, both kinds interleaved by
/// `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "The merged order is one
/// derivation … a second implementation of 'the row below this one' is a bug by definition").
///
/// The tail is `Ranks.isOrderedForTrash`'s — title case-insensitively, then folder name — which
/// is what the loader already applied within each kind, so the merge of two sorted arrays and
/// each array alone agree everywhere they overlap.
public var trashEntries: [TrashEntry] {
Ranks.sortedForTrash(
trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane),
modified: \.modified,
title: \.title,
name: { $0.id.rawValue }
)
}
}
// 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).
///
/// **Four cases, because the board has exactly four kinds of place an identity-bearing folder can
/// be** — `<root>/<lane>`, `<root>/<lane>/<card>`, and, since lanes rejoined the trash (2026-07-29),
/// `<root>/.trash/<card>` and `<root>/.trash/<lane>`. The old two-optional-fields shape could spell
/// things that do not exist; this cannot.
///
/// **The trash's two cases share a path *shape* and differ in kind**, which is exactly the fact
/// `kind:` exists to record (01-storage-format.md § Deletion): `.trash/` is flat, so the path alone
/// cannot say what an entry is, and a caller that has resolved one through the snapshot knows —
/// which is why the kind is in the case rather than re-derived from disk at every use.
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)
/// A lane in the board's trash: `<root>/.trash/<lane>/` — an opaque unit, subtree intact
/// (03-board-ui.md § Trash).
case trashLane(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
case let .trashLane(id): id
}
}
/// Whether this path names a lane, **in either container** — the kind axis, which the board's
/// cards-XOR-lanes rule asks about on both sides of the container boundary.
public var isLane: Bool {
switch self {
case .lane, .trashLane: true
case .card, .trashCard: false
}
}
/// Which container this path is in — the board for a lane or a lane's card, the trash for
/// either kind of trash entry. Derived rather than stored: the case *is* the answer.
public var container: ItemContainer {
switch self {
case .trashCard, .trashLane: .trash
case .lane, .card: .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), let .trashLane(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).
///
/// **The trash's order interleaves its two kinds by `modified`** (03-board-ui.md § Trash: "lane
/// rows and cards interleave in the one trash column by `modified` descending"), which is why the
/// walk is `trashEntries` rather than the two arrays concatenated: the column's order is the
/// batch's order, and it is stated in exactly one place.
///
/// 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 entry in snapshot.trashEntries where ids.contains(entry.id) {
result.append(entry.path)
}
}
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) }
}
if snapshot.trash.contains(where: { $0.id == id }) { return .trashCard(id) }
return snapshot.trashedLanes.contains { $0.id == id } ? .trashLane(id) : nil
}
}