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:
@@ -927,6 +927,15 @@ public final class BannerCenter {
|
||||
if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
|
||||
case let .style(title):
|
||||
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
||||
case let .relabel(title):
|
||||
// **"relabel", in both directions** — `.collapse`/`.expand`'s two-word split turned down
|
||||
// for the reason `WriteOperation.relabel` gives: adding and removing a label are the same
|
||||
// control pressed twice, so one verb is the honest one. The word is 06-history-undo.md's
|
||||
// own for a change to this key ("Relabel card 'X'"), which the commit composer has spoken
|
||||
// since before there was a control to press. It says "the card" rather than "the item"
|
||||
// where every other row here says the latter, because labels are a card field and nothing
|
||||
// else can reach this sentence.
|
||||
if let title { "Couldn't relabel '\(title)'" } else { "Couldn't relabel the card" }
|
||||
case .setBoardBackground:
|
||||
// **"generate", because that is the button they pressed**, and no title because there is
|
||||
// one board and they are looking at it. It deliberately says nothing about the *file* —
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - One label's standing on a board
|
||||
|
||||
/// One label the board actually uses, and how many cards use it.
|
||||
public struct LabelTally: Sendable, Equatable {
|
||||
|
||||
/// The label as it is **spelled on the board** — the first spelling the derivation met, in board
|
||||
/// order (lanes left to right, each lane's cards top to bottom, then the trash).
|
||||
///
|
||||
/// Case-insensitive uniqueness is a per-*card* rule (`CardLabels`), so two cards may perfectly
|
||||
/// legally spell one label two ways. The universe has to pick one to offer, and the first is the
|
||||
/// only choice that does not depend on how many cards happen to carry each variant — a count-based
|
||||
/// pick would make a menu row's capitalisation flicker as cards are tagged.
|
||||
public let name: String
|
||||
|
||||
/// How many **cards** carry it — never how many times it appears, which is the same number: a
|
||||
/// card's own list is deduplicated by the reading.
|
||||
public let count: Int
|
||||
|
||||
public init(name: String, count: Int) {
|
||||
self.name = name
|
||||
self.count = count
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The universe
|
||||
|
||||
/// **Every label the board uses, with its frequency** — the "used labels" universe both label
|
||||
/// surfaces draw from (the sidebar's autocomplete and the context menu's twelve rows plus its More…
|
||||
/// dialog).
|
||||
///
|
||||
/// ### Derived from the snapshot, and there is no other store
|
||||
///
|
||||
/// The owner's ruling, and the format's own posture: the files *are* the board, so the set of labels
|
||||
/// in use is a fact about the cards rather than a list the app maintains beside them. A label exists
|
||||
/// because a card carries it and stops existing when the last card drops it — there is no rename, no
|
||||
/// palette, no orphan to garbage-collect, and an agent that hand-writes `labels: [spike]` into an
|
||||
/// `index.md` has created a label as fully as the menu can.
|
||||
///
|
||||
/// **The trash counts.** `BoardModel.trash` is walked beside `lanes` because a trashed card is "an
|
||||
/// ordinary card in a special place" (03-board-ui.md § Trash) and, more to the point, because the
|
||||
/// alternative is worse: deleting the only card carrying `spike` would silently evict the label from
|
||||
/// the autocomplete, and restoring the card would bring it back — a vocabulary that flickers with the
|
||||
/// trash is not a vocabulary. `trashedLanes` is deliberately *not* walked: a trashed lane is an opaque
|
||||
/// unit whose cards the snapshot never loads (`TrashedLane`), so there is nothing there to count.
|
||||
///
|
||||
/// ### Cost
|
||||
///
|
||||
/// One pass over every live and trashed card, reading a field the loader already parsed — the same
|
||||
/// cost class as any other whole-board derivation, and paid **once per snapshot** (`BoardStore`
|
||||
/// caches it, equality-gated) rather than once per view body. That is what keeps the context menu's
|
||||
/// builder free of O(board) work: everything a card face reads is this value, already built.
|
||||
public struct LabelIndex: Sendable, Equatable {
|
||||
|
||||
/// Every used label, **ordered by frequency descending, then case-insensitively alphabetically**
|
||||
/// — a total, snapshot-only order, so two runs over the same board produce the identical array.
|
||||
///
|
||||
/// The alphabetical tie-break is here rather than left to the ranking because this ordering has to
|
||||
/// be deterministic on its own: it is what `universe` shows and what the ranking starts from, and
|
||||
/// a derivation whose ties fell out of dictionary iteration order would make the More… dialog
|
||||
/// reshuffle itself between reloads that changed nothing.
|
||||
public let tallies: [LabelTally]
|
||||
|
||||
public init(tallies: [LabelTally]) {
|
||||
self.tallies = tallies
|
||||
}
|
||||
|
||||
/// The empty board's index — no cards, or no card carrying a label.
|
||||
public static let empty = LabelIndex(tallies: [])
|
||||
|
||||
/// Every used label's display name, in `tallies`' order.
|
||||
public var names: [String] { tallies.map(\.name) }
|
||||
|
||||
public var isEmpty: Bool { tallies.isEmpty }
|
||||
|
||||
/// The universe as the More… dialog lists it: **alphabetically, case-insensitively**, because a
|
||||
/// dialog whose whole job is "find the one you mean among all of them" is a lookup, and a
|
||||
/// frequency order is a lookup you cannot do.
|
||||
public var alphabetical: [String] {
|
||||
tallies.map(\.name).sorted { Self.precedesAlphabetically($0, $1) }
|
||||
}
|
||||
|
||||
/// Whether the board already uses a case-insensitively equal name — the create field's "this is
|
||||
/// not new" test.
|
||||
public func contains(_ name: String) -> Bool {
|
||||
CardLabels.contains(name, in: names)
|
||||
}
|
||||
|
||||
/// The board's own spelling of a name, when it has one — so a user who types `BUG` into the create
|
||||
/// field adds the `bug` the rest of the board already says, rather than minting a second variant.
|
||||
public func canonicalSpelling(of name: String) -> String? {
|
||||
guard let name = CardLabels.normalized(name) else { return nil }
|
||||
let key = CardLabels.canonical(name)
|
||||
return tallies.first { CardLabels.canonical($0.name) == key }?.name
|
||||
}
|
||||
|
||||
// MARK: Derivation
|
||||
|
||||
/// The universe of `snapshot`, live cards and trash alike.
|
||||
public static func derive(from snapshot: BoardModel) -> LabelIndex {
|
||||
var order: [String] = []
|
||||
var counts: [String: Int] = [:]
|
||||
var spellings: [String: String] = [:]
|
||||
|
||||
func absorb(_ card: Card) {
|
||||
for name in card.labels.value ?? [] {
|
||||
let key = CardLabels.canonical(name)
|
||||
if spellings[key] == nil {
|
||||
spellings[key] = name
|
||||
order.append(key)
|
||||
}
|
||||
counts[key, default: 0] += 1
|
||||
}
|
||||
}
|
||||
|
||||
for lane in snapshot.lanes {
|
||||
for card in lane.cards { absorb(card) }
|
||||
}
|
||||
for card in snapshot.trash { absorb(card) }
|
||||
|
||||
let tallies = order.map { key in
|
||||
LabelTally(name: spellings[key] ?? key, count: counts[key] ?? 0)
|
||||
}
|
||||
return LabelIndex(tallies: tallies.sorted(by: precedes))
|
||||
}
|
||||
|
||||
/// Frequency descending, then case-insensitively alphabetical, then by raw spelling — a **total**
|
||||
/// order, which is what makes the derivation reproducible.
|
||||
private static func precedes(_ lhs: LabelTally, _ rhs: LabelTally) -> Bool {
|
||||
if lhs.count != rhs.count { return lhs.count > rhs.count }
|
||||
return precedesAlphabetically(lhs.name, rhs.name)
|
||||
}
|
||||
|
||||
/// Case-insensitive alphabetical, with the raw spelling as the last resort so `Bug` and `bug`
|
||||
/// never compare equal and the sort stays total.
|
||||
static func precedesAlphabetically(_ lhs: String, _ rhs: String) -> Bool {
|
||||
let folded = CardLabels.canonical(lhs).compare(CardLabels.canonical(rhs))
|
||||
if folded != .orderedSame { return folded == .orderedAscending }
|
||||
return lhs < rhs
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The menu's twelve
|
||||
|
||||
/// **"The most frequently and recently used labels"** — the owner's phrase for the card context
|
||||
/// menu's twelve rows, turned into arithmetic.
|
||||
///
|
||||
/// ### The ranking, exactly
|
||||
///
|
||||
/// 1. **Frequency, descending** — how many of the board's cards (live and trashed) carry the label
|
||||
/// (`LabelIndex`).
|
||||
/// 2. **Recency, as the tie-break** — among labels used by the same number of cards, the one this
|
||||
/// user applied most recently comes first (`LabelRecents`, an app-side MRU updated on every apply).
|
||||
/// A label the MRU has never seen sorts behind every label it has.
|
||||
/// 3. **Case-insensitively alphabetical**, then raw spelling — so the order is *total* and a menu
|
||||
/// never reshuffles between two openings that changed nothing.
|
||||
///
|
||||
/// Then the first twelve.
|
||||
///
|
||||
/// ### Why frequency leads and recency follows, and what that costs
|
||||
///
|
||||
/// This is the ruling as given, implemented as given. It is worth being explicit about the
|
||||
/// consequence, because it is visible: on a board with more than twelve labels in heavy use, a label
|
||||
/// **invented today** will not appear in these rows until enough cards carry it to beat the
|
||||
/// twelfth-most-common one — even though it is by far the most *recently* used. The recency half only
|
||||
/// separates labels that are already tied on frequency, which on a board with an even spread of usage
|
||||
/// is often most of them, and on a lopsided board is almost none.
|
||||
///
|
||||
/// The alternative — a blended score, or reserving a slot or two for pure recency — would surface a
|
||||
/// fresh label immediately at the cost of a menu whose contents move under the user's hand. Neither
|
||||
/// is obviously right, so the literal reading of the ruling is what shipped, and the observation is
|
||||
/// journaled for the owner rather than quietly designed around. **More…** is the escape hatch either
|
||||
/// way: every used label is one row further in, and the create field is beside it.
|
||||
public enum LabelRanking {
|
||||
|
||||
/// How many rows the context menu's `labels` submenu shows before its separator — the owner's
|
||||
/// "up to 12".
|
||||
public static let menuLimit = 12
|
||||
|
||||
/// The top `limit` of `index`, ranked as the type comment describes.
|
||||
///
|
||||
/// - Parameter recents: most-recent-first, `LabelRecents.labels`' own order. Entries naming labels
|
||||
/// the board no longer uses are simply never matched — the MRU is app-wide and a board is not
|
||||
/// obliged to know about labels from another one.
|
||||
public static func ranked(_ index: LabelIndex, recents: [String], limit: Int = menuLimit) -> [String] {
|
||||
guard limit > 0 else { return [] }
|
||||
// Position in the MRU, by canonical name — built once rather than searched per comparison, so
|
||||
// the sort stays O(n log n) instead of O(n² ) in the number of distinct labels.
|
||||
var recency: [String: Int] = [:]
|
||||
for (position, name) in recents.enumerated() {
|
||||
let key = CardLabels.canonical(name)
|
||||
if recency[key] == nil { recency[key] = position }
|
||||
}
|
||||
|
||||
let ordered = index.tallies.sorted { lhs, rhs in
|
||||
if lhs.count != rhs.count { return lhs.count > rhs.count }
|
||||
let left = recency[CardLabels.canonical(lhs.name)] ?? Int.max
|
||||
let right = recency[CardLabels.canonical(rhs.name)] ?? Int.max
|
||||
if left != right { return left < right }
|
||||
return LabelIndex.precedesAlphabetically(lhs.name, rhs.name)
|
||||
}
|
||||
return ordered.prefix(limit).map(\.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// The labels this user applied lately, most-recent-first — the **tie-break** half of "most
|
||||
/// frequently and recently used" (`LabelRanking`; `FrontmatterKeys.labels`, activated 2026-08-09).
|
||||
///
|
||||
/// `StyleRecents`' shape exactly, for its reasons: the list *rule* is a static pure function, this
|
||||
/// object is its persistence and its observability, and `defaults` is injectable so a test drives a
|
||||
/// suite of its own rather than the user's.
|
||||
///
|
||||
/// ### App-wide, not per board — and it does not matter much
|
||||
///
|
||||
/// A board's label *universe* is board data, derived from its own cards (`LabelIndex`). This is not
|
||||
/// that. It is a memory of what this **user** reached for, and it exists only to order labels that are
|
||||
/// already tied on frequency within one board's universe — so an entry naming a label another board
|
||||
/// uses simply never matches anything here and is inert. App-wide is `StyleRecents`' own posture ("no
|
||||
/// board owns the list"), it needs no per-board storage key, and it survives a board being closed and
|
||||
/// reopened, which a window-lived list would not.
|
||||
///
|
||||
/// ### What enters it
|
||||
///
|
||||
/// **Applications, not removals.** Taking `bug` off a card is not evidence the user is reaching for
|
||||
/// `bug`; recording it would push a label the user is actively getting rid of to the front of the very
|
||||
/// menu they are trying to leave. So `LabelCommand` records on the add half of a toggle and on a
|
||||
/// create, and never on a remove — the same "the None well is not a colour" carve-out `StyleRecents`
|
||||
/// makes one field over.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class LabelRecents {
|
||||
|
||||
/// How many the list keeps. **Twice the menu's twelve**, deliberately: unlike `StyleRecents`' six,
|
||||
/// this list is never *displayed* — it only breaks ties inside a twelve-row menu — so its useful
|
||||
/// depth is "enough to order a tie group that could fill the menu", and a cap equal to the menu
|
||||
/// size would leave the thirteenth-most-recent label indistinguishable from one never used at all.
|
||||
public static let cap = 24
|
||||
|
||||
/// The labels, most-recent-first. Display spellings, exactly as written to frontmatter.
|
||||
public private(set) var labels: [String]
|
||||
|
||||
@ObservationIgnored
|
||||
private let defaults: UserDefaults
|
||||
|
||||
/// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason — a test
|
||||
/// must be able to hold its own without touching the user's.
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
// Anything but an array of strings reads as an empty list rather than as an error —
|
||||
// `StyleRecents`' rule and its reason: a hand-edited or truncated preference must never be why
|
||||
// a context menu cannot open.
|
||||
labels = (defaults.array(forKey: AppPreferences.labelRecentsKey) as? [String]) ?? []
|
||||
}
|
||||
|
||||
/// Records `name` as the most recently applied label and persists the new list.
|
||||
public func record(_ name: String) {
|
||||
let updated = Self.updated(labels, with: name)
|
||||
guard updated != labels else { return }
|
||||
labels = updated
|
||||
defaults.set(labels, forKey: AppPreferences.labelRecentsKey)
|
||||
}
|
||||
|
||||
/// The list rule, as a pure function: `name` to the front, its **case-insensitive** earlier
|
||||
/// occurrence removed, the tail truncated to `cap`.
|
||||
///
|
||||
/// Case-insensitive because that is what a label's identity is (`CardLabels.canonical`), and a list
|
||||
/// holding both `Bug` and `bug` would spend two of its slots ordering one label against itself. The
|
||||
/// spelling kept is the one just applied, which is the board's own by construction — every write
|
||||
/// path resolves a typed name against `LabelIndex.canonicalSpelling(of:)` before it lands.
|
||||
///
|
||||
/// A name that trims to nothing returns the list unchanged: it is not a label anybody applied.
|
||||
public static func updated(_ list: [String], with name: String, cap: Int = cap) -> [String] {
|
||||
guard let name = CardLabels.normalized(name) else { return list }
|
||||
let key = CardLabels.canonical(name)
|
||||
var updated = list.filter { CardLabels.canonical($0) != key }
|
||||
updated.insert(name, at: 0)
|
||||
return Array(updated.prefix(cap))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user