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
172 lines
7.3 KiB
Swift
172 lines
7.3 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)".
|
|
///
|
|
/// **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
|
|
}
|
|
}
|