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,89 @@
|
||||
import Foundation
|
||||
|
||||
/// **The one funnel every label gesture goes through** — the write plus the recents record, so no
|
||||
/// anchor can do one without the other (`StyleCommand`'s shape and its reason exactly:
|
||||
/// `StyleEditor.swift`).
|
||||
///
|
||||
/// Two anchors exist today and they are in different windows — the card window's sidebar section and
|
||||
/// the board card menu's `labels` submenu, plus that submenu's More… dialog. Every one of them ends
|
||||
/// up here, which is what keeps "the MRU is updated on every label apply" a fact rather than three
|
||||
/// call sites' good intentions.
|
||||
///
|
||||
/// ### Recording the add half only
|
||||
///
|
||||
/// A **removal records nothing**. The MRU exists to answer "what is this user reaching for", and
|
||||
/// taking `bug` off a card is evidence of the opposite — recording it would float a label to the top
|
||||
/// of the very menu the user is trying to get away from. `StyleRecents`' own "the None well is not a
|
||||
/// colour" carve-out, one field over (`LabelRecents`).
|
||||
///
|
||||
/// ### Spelling is the board's, not the typist's
|
||||
///
|
||||
/// A name typed into the sidebar's field or the dialog's create box is resolved against the board's
|
||||
/// own universe first (`LabelIndex.canonicalSpelling(of:)`), so typing `BUG` onto a board that already
|
||||
/// says `bug` tags the card `bug` rather than minting a second variant nothing can tell apart. Only a
|
||||
/// genuinely new name keeps the typist's capitalisation — which is exactly right, because for a new
|
||||
/// label the typist *is* the board.
|
||||
@MainActor
|
||||
enum LabelCommand {
|
||||
|
||||
/// Adds or removes `name` on one card, whichever the card's current list calls for — the context
|
||||
/// menu's rows and the dialog's checkboxes.
|
||||
///
|
||||
/// - Parameter undo: the issuing window's own stack, for the one anchor that has one — the card
|
||||
/// window's sidebar (13-native-undo.md ▸ Rules ▸ two levels). `nil`, which every board-side
|
||||
/// anchor passes, is the board's stack.
|
||||
/// - Returns: whether bytes reached disk.
|
||||
@discardableResult
|
||||
static func toggle(
|
||||
_ name: String,
|
||||
onCard cardID: ItemID,
|
||||
in store: BoardStore,
|
||||
recents: LabelRecents,
|
||||
on undo: CardWindowUndo? = nil
|
||||
) -> Bool {
|
||||
let current = store.labels(ofCard: cardID)
|
||||
guard CardLabels.contains(name, in: current) else {
|
||||
return add(name, onCard: cardID, in: store, recents: recents, on: undo)
|
||||
}
|
||||
return store.setLabels(CardLabels.removing(name, from: current), onCard: cardID, on: undo)
|
||||
}
|
||||
|
||||
/// Adds `name` to one card — the sidebar's add field and the dialog's create box, which both mean
|
||||
/// "put this on the card" rather than "flip whatever it is now".
|
||||
///
|
||||
/// A name the card already carries is a no-op that **still records**: the user reached for it, and
|
||||
/// the MRU's whole subject is what they reach for.
|
||||
@discardableResult
|
||||
static func add(
|
||||
_ name: String,
|
||||
onCard cardID: ItemID,
|
||||
in store: BoardStore,
|
||||
recents: LabelRecents,
|
||||
on undo: CardWindowUndo? = nil
|
||||
) -> Bool {
|
||||
guard let name = resolved(name, in: store) else { return false }
|
||||
recents.record(name)
|
||||
let current = store.labels(ofCard: cardID)
|
||||
return store.setLabels(CardLabels.adding(name, to: current), onCard: cardID, on: undo)
|
||||
}
|
||||
|
||||
/// Removes `name` from one card — the chip's ⊗. Records nothing (see the type comment).
|
||||
@discardableResult
|
||||
static func remove(
|
||||
_ name: String,
|
||||
fromCard cardID: ItemID,
|
||||
in store: BoardStore,
|
||||
recents _: LabelRecents,
|
||||
on undo: CardWindowUndo? = nil
|
||||
) -> Bool {
|
||||
let current = store.labels(ofCard: cardID)
|
||||
return store.setLabels(CardLabels.removing(name, from: current), onCard: cardID, on: undo)
|
||||
}
|
||||
|
||||
/// A typed name in the board's own spelling — see the type comment. `nil` for a name that trims to
|
||||
/// nothing, which is not a label anybody meant.
|
||||
static func resolved(_ name: String, in store: BoardStore) -> String? {
|
||||
guard let name = CardLabels.normalized(name) else { return nil }
|
||||
return store.labelIndex.canonicalSpelling(of: name) ?? name
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user