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:
@@ -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