A reserved key comes to life — the card window's sidebar grows a Labels section, and labels stops being somebody else's
`labels` has been a reserved tracker key since the rewrite: preserved verbatim, never interpreted, drawn only as an anonymous row in the Details section beside `assignees` and `due`. The owner's cards claim it for first-party use, so it joins the schema — read leniently (a list of names, a bare scalar coercing to one, a mapping malformed and preserved), written canonically (a quoted flow list in the order the user arranged, no auto-sort), and removed outright when the last label goes, the way an expanded lane drops `collapsed`. Identity is case-insensitive and display is case-preserving, so a card carries `bug` once however many ways the board spells it, and entries the reading cannot name ride through the write untouched at the tail. The section sits second, above Details — which is the point rather than a layout preference: Details is where keys the app does *not* own are shown, and this key just stopped being one. Rows rather than chips, because the sidebar is twenty-six characters wide. The add field autocompletes against the board's own used-labels universe, derived from every live and trashed card with no store beside the files, and says out loud when Return would mint a word the board has never used. Writes ride a `.relabel` operation of their own, because the commit composer has said "Relabel card 'X'" since long before there was a control to press — and now the undo row says it too. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -256,6 +256,33 @@ public final class BoardStore: HealHost {
|
||||
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
|
||||
public private(set) var snapshot: BoardModel
|
||||
|
||||
/// **The board's used-labels universe, cached** (`LabelIndex`; `FrontmatterKeys.labels`, activated
|
||||
/// 2026-08-09) — the one derived value in this store that exists purely so a *view body* need not
|
||||
/// derive it.
|
||||
///
|
||||
/// ### Why it is stored rather than computed
|
||||
///
|
||||
/// The context menu's `labels` submenu has to name twelve labels, and `.contextMenu`'s content
|
||||
/// closure **is not lazy**: SwiftUI evaluates it on every ordinary body pass of every card face,
|
||||
/// not only when a menu opens (`CardFaceView.copyLinkEnabled`'s doc comment, and the O(board)
|
||||
/// regression `BoardRenderPerformanceTests.selectionStillRepaints` caught the hard way). A
|
||||
/// computed property here would put a whole-board walk inside that closure, once per face, on
|
||||
/// every pass — the exact shape of the cost this file's render work exists to shed.
|
||||
///
|
||||
/// So the walk happens **once per applied snapshot**, here, and a face reads a value that is
|
||||
/// already built. The remaining per-face work is `LabelRanking.ranked`, whose size is the board's
|
||||
/// *label vocabulary* — tens of entries, bounded by how many distinct labels exist and not by how
|
||||
/// many cards there are.
|
||||
///
|
||||
/// ### The assignment is equality-gated, and that is load-bearing
|
||||
///
|
||||
/// `@Observable` notifies on **every** set, equal or not (`BoardZoomStore.setLevel`'s own note), so
|
||||
/// an ungated re-derivation on each reload would invalidate every card face that reads this on
|
||||
/// every reload — reintroducing board-wide invalidation through the back door. Gated, this
|
||||
/// property changes only when the board's labels genuinely change, which is a user-visible content
|
||||
/// change that was going to re-render those faces anyway.
|
||||
public private(set) var labelIndex: LabelIndex
|
||||
|
||||
/// How many snapshots this store has **applied**, ever — a counter, not a version.
|
||||
///
|
||||
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
|
||||
@@ -782,6 +809,7 @@ public final class BoardStore: HealHost {
|
||||
self.rootURL = rootURL
|
||||
self.rootKey = BoardRootKey(rootURL)
|
||||
self.snapshot = result.model
|
||||
self.labelIndex = LabelIndex.derive(from: result.model)
|
||||
self.loadWarnings = result.warnings
|
||||
self.defects = result.defects
|
||||
// The opening walk was cold by definition; what it parsed is the first reload's memo.
|
||||
@@ -1020,6 +1048,13 @@ public final class BoardStore: HealHost {
|
||||
)) {
|
||||
snapshot = result.model
|
||||
snapshotGeneration += 1
|
||||
// **The used-labels universe, re-derived with the tree it describes** — inside the
|
||||
// same transaction as the snapshot for the reason the re-grounding below is: a view
|
||||
// woken by the snapshot's change must never observe a universe still describing the
|
||||
// old one. Equality-gated, which is what keeps a reload that moved a card from
|
||||
// invalidating every face that reads it (see the property's own note).
|
||||
let labels = LabelIndex.derive(from: result.model)
|
||||
if labels != labelIndex { labelIndex = labels }
|
||||
// The one place transient state is re-grounded. It goes last, after `snapshot` is
|
||||
// the new one, because a view woken by the snapshot's change must never observe a
|
||||
// selection still pointing at the old tree.
|
||||
@@ -4275,6 +4310,127 @@ public final class BoardStore: HealHost {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Labels
|
||||
|
||||
/// **Rewrites a card's `labels` list** — the one write behind both label surfaces, the card
|
||||
/// window's sidebar section and the board card menu's submenu (`FrontmatterKeys.labels`, activated
|
||||
/// 2026-08-09; `CardLabels` has the rules, `FrontmatterDocument.setLabels` the canonical form).
|
||||
///
|
||||
/// ### It is `setHero`'s shape, one key over
|
||||
///
|
||||
/// One key on one card's `index.md`, through `updateIndex` inside one `performWrite` bracket,
|
||||
/// registering one step. Everything that makes that shape right there makes it right here: the
|
||||
/// churn rounds back as one app-mediated reload, the read-only lock refuses it like every other
|
||||
/// mutation, its failures reach the banner strip, and the step's before-value is the list the
|
||||
/// snapshot last read.
|
||||
///
|
||||
/// **It takes `.relabel`, not `.style`.** A restyle picks an appearance; this edits what the card
|
||||
/// is about, and the vocabulary has had a separate word for it since before there was a control
|
||||
/// (`WriteOperation.relabel`; 06-history-undo.md's "Relabel card 'X'").
|
||||
///
|
||||
/// ### The list is the unit
|
||||
///
|
||||
/// The parameter is the **whole** new list rather than a label plus a direction, and every caller
|
||||
/// composes it through `CardLabels` (`toggling`, `adding`, `removing`) from the list the snapshot
|
||||
/// already shows. That keeps the "case-insensitive uniqueness, first spelling wins, order of
|
||||
/// addition preserved" rules in exactly one place, and it makes the undo step's after-value
|
||||
/// trivially honest: the step declares the list it wrote (`ExpectedField.labels`).
|
||||
///
|
||||
/// **A no-op costs nothing.** A call whose normalized list equals what the card already reads
|
||||
/// writes nothing, reloads nothing and registers no undo step — `applyStyle`'s own
|
||||
/// redundant-dimension rule, which matters more here than there because a menu row toggled twice
|
||||
/// in a row is a thing users do.
|
||||
///
|
||||
/// ### The guards are `setHero`'s
|
||||
///
|
||||
/// **The board container and only it** — a trashed card's labels are not editable from a window
|
||||
/// that is dismissing itself — and a lane id is refused because labels are a card field
|
||||
/// (`Card.labels`).
|
||||
///
|
||||
/// - Parameter names: the card's whole new label list, in the order it should be written.
|
||||
/// - Parameter window: the card window whose stack the step belongs on, when the gesture came from
|
||||
/// one (13-native-undo.md ▸ Rules ▸ two levels). A window-issued gesture also anchors by **card
|
||||
/// identity** rather than by path, `setHero`'s rule verbatim, so a lane move under an open window
|
||||
/// never stales it.
|
||||
/// - Returns: whether bytes reached disk.
|
||||
@discardableResult
|
||||
public func setLabels(_ names: [String], onCard cardID: ItemID, on window: CardWindowUndo? = nil) -> Bool {
|
||||
guard let item = Self.boardItem(cardID, in: snapshot),
|
||||
let card = item.cardID,
|
||||
let subject = Self.card(cardID, in: snapshot)
|
||||
else { return false }
|
||||
|
||||
let prior = subject.labels
|
||||
let next = CardLabels.deduplicated(names)
|
||||
// A malformed prior matches no list, so a first write onto `labels: {a: 1}` always lands —
|
||||
// which is right: the value had no reading, and the user is replacing it with one.
|
||||
guard prior.value != next else { return false }
|
||||
|
||||
// **Whether the key is held open by entries the reading cannot name** — read off the
|
||||
// snapshot's own document, exactly as `prior` is, so the two facts the expectations are built
|
||||
// from come from one reading of one file. It decides whether an emptied list *removes* the key
|
||||
// or leaves `[]` behind, which is the difference between two after-values a step could declare
|
||||
// (`CardLabels.readingAfterWrite`).
|
||||
let preserving = !subject.document.preservedLabelEntries.isEmpty
|
||||
let landedReading = CardLabels.readingAfterWrite(next, preservingEntries: preserving)
|
||||
let restoredReading = CardLabels.readingAfterWrite(prior.value ?? [], preservingEntries: preserving)
|
||||
|
||||
let folder = rootURL
|
||||
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(card.rawValue, isDirectory: true)
|
||||
let anchor: HistoryAnchor = window != nil ? .card(cardID) : .path(folder)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// `.relabel(title: nil)`: `updateIndex` enriches it off the document it reads, so a failure
|
||||
// names the card by the title it still has.
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .relabel(title: nil)) { document in
|
||||
document.setLabels(next)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
|
||||
registerStep(
|
||||
HistoryPhrase.name(.relabel, kind: .card),
|
||||
subject: item.title,
|
||||
on: window,
|
||||
undoExpects: [.present(anchor, .labels(landedReading))],
|
||||
redoExpects: [.present(anchor, .labels(restoredReading))]
|
||||
) { store in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: try store.requiredFolder(for: anchor, .relabel(title: nil)),
|
||||
operation: .relabel(title: nil)
|
||||
) { document in
|
||||
// **A malformed prior inverses to the removed key**, `restoreCollapsed`'s rule and the
|
||||
// one the redo expectation above is written against: this app writes `labels` in
|
||||
// exactly one shape, and reproducing somebody's `labels: {a: 1}` would be the undo
|
||||
// inventing a value. The reading is restored exactly either way — both read as no
|
||||
// labels — which is what an inverse owes the user.
|
||||
document.setLabels(prior.value ?? [])
|
||||
}
|
||||
} redo: { store in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: try store.requiredFolder(for: anchor, .relabel(title: nil)),
|
||||
operation: .relabel(title: nil)
|
||||
) { document in
|
||||
document.setLabels(next)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// The labels a card currently carries, as the last reload read them — `[]` for a card with no
|
||||
/// key, an empty list, or a value with no list reading at all.
|
||||
///
|
||||
/// The one read every label surface starts from, so the sidebar's chips, the menu's checkmarks and
|
||||
/// the write path's "what am I toggling against" can never disagree. It looks in **both**
|
||||
/// containers (`cardBodyTarget`'s walk, through `Self.card`/`boardItem`'s live-only lookup plus the
|
||||
/// trash), because the card window stays open over a card that was trashed under it and its
|
||||
/// sidebar must keep showing the truth even though `setLabels` will refuse to write there.
|
||||
public func labels(ofCard cardID: ItemID) -> [String] {
|
||||
if let card = Self.card(cardID, in: snapshot) { return card.labels.value ?? [] }
|
||||
return snapshot.trash.first { $0.id == cardID }?.labels.value ?? []
|
||||
}
|
||||
|
||||
/// One live board-side card off a snapshot, by identity — `boardItem`'s sibling for a caller that
|
||||
/// needs the card's *fields* rather than its position.
|
||||
nonisolated static func card(_ id: ItemID, in snapshot: BoardModel) -> Card? {
|
||||
|
||||
Reference in New Issue
Block a user