Build lane chrome — title bar, badge, inline rename

The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 08:52:24 -04:00
parent ff3ba298f0
commit b35566e0fe
21 changed files with 2855 additions and 79 deletions
+271 -3
View File
@@ -645,32 +645,300 @@ public final class BoardStore {
}
}
// MARK: - Creation
/// Creates a lane at the board's right end File New Lane N (11-command-nexus.md).
///
/// **Untitled, and deliberately with no inline editor.** 03-board-ui.md gives lane titles one
/// editing surface "Inline rename on the header" and 04-interactions.md gives that surface
/// one entry point, Board Rename, "since Return on a lane creates a card". Nothing in either
/// doc opens an editor *at creation*, so a new lane appears with the untitled placeholder and
/// the user renames it if they want a name. Titles are optional at every level; a lane with no
/// `title` key is a legitimate resting state, not a half-finished one.
///
/// Like `setLaneWidth`, the rethrow is swallowed: `performWrite` has already posted the banner,
/// and a menu item has no second thing to do about a failure.
public func createLane() {
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.createLane(inBoard: root, title: nil)
}
}
// MARK: - The new-card placeholder's commit
/// Turns the open placeholder into a real card the write half of 02-architecture.md §
/// Layering's one named exception to the one-way flow.
///
/// The five outcomes, all settled:
///
/// - **No placeholder, or one already committed** nothing to do. (Idempotence matters: Return
/// commits, and the field's focus-loss handler fires immediately afterwards.)
/// - **An empty title discards it** "creating-then-abandoning never leaves an empty card
/// behind" (04-interactions.md Grammar). Whitespace counts as empty: a title of three
/// spaces is a slip, not a deliberate untitled card.
/// - **A vanished lane discards it** the anchor is gone, so there is nowhere to file the
/// card; the reload that removed the lane is the authority.
/// - **A failed create discards it too** (settled, 02 § Layering): "the overlay never waits for
/// a card that cannot arrive". The failure is already the banner's.
/// - **A successful create hands off**: the overlay flips to `.awaitingArrival` and stands until
/// the watcher round-trips the real card, so the user never sees a hole where they just typed.
///
/// - Returns: the created card's id, or `nil` on any of the discard paths which is what the
/// call site needs to know whether it has a card window to open.
@discardableResult
public func commitPlaceholder() -> ItemID? {
guard let placeholder = transient.newCardPlaceholder, placeholder.phase == .editing else { return nil }
let title = placeholder.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty,
let lane = snapshot.lanes.first(where: { $0.id == placeholder.laneID && !$0.isDeleted })
else {
transient.discardPlaceholder()
return nil
}
let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue)
let visible = lane.cards.filter { !$0.isDeleted }
// `nil` means "append", which is `createCard`'s own default so the anchored case is the
// only one that needs a rank at all.
let position = Self.insertionIndex(after: placeholder.anchorCardID, among: visible)
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
let id = try BoardWriter.createCard(inLane: laneFolder, title: title)
guard let position else { return id }
// The rank is computed here rather than passed to `createCard` because the create's
// contract is "append after the visible siblings" and widening it would give every
// caller a position to think about. The reposition rides the Writer's own same-parent
// degenerate reorder "a move whose destination is the item's current parent degrades
// to a plain reorder" inside the *same* `performWrite`, so the pair rounds back as
// one app-mediated reload rather than showing the card at the bottom for a frame.
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
if rank == nil {
// Midpoint precision exhausted between the anchor and its neighbour
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
// the new card is not among the renumbered siblings it was appended past them
// so the compacted ladder lines up one-for-one with `visible`.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
}
guard let rank else { return id }
_ = try BoardWriter.moveItem(
at: laneFolder.appendingPathComponent(id.rawValue),
toParent: laneFolder,
sourceBoardRoot: rootURL,
destinationBoardRoot: rootURL,
order: rank
)
return id
}
guard let created else {
transient.discardPlaceholder()
return nil
}
transient.commitPlaceholder(expecting: created)
return created
}
/// The display position a new card takes, or `nil` for "append at the bottom".
///
/// An anchor that is not among `visible` degrades to `nil` rather than failing: the card the
/// N target rule named was deleted or moved away mid-typing, and the lane the anchor that
/// actually matters is still there. Appending is the honest fallback; refusing to create
/// would punish the user for someone else's edit.
nonisolated static func insertionIndex(after anchor: ItemID?, among visible: [Card]) -> Int? {
guard let anchor, let index = visible.firstIndex(where: { $0.id == anchor }) else { return nil }
// Already last: "immediately after it" and "at the bottom" are the same position, and
// append needs no rank of its own.
return index + 1 < visible.count ? index + 1 : nil
}
// MARK: - Inline rename
/// Writes the open rename editor's draft the third inline editor's commit
/// (04-interactions.md Grammar), reached by Return **and** by focus loss ("a rename commits
/// the deliberate exception being the placeholder, because nothing exists on disk yet").
///
/// Four rules, all from 04 and 03:
///
/// - **The editor closes first, unconditionally.** Every path below ends with it gone, and
/// retiring it up front is what makes this idempotent Return commits and the field's
/// focus-loss handler fires an instant later against no editor at all.
/// - **A vanished target writes nothing, silently.** "A target that is tombstoned, deleted, or
/// gone at commit time discards the editor and its keystrokes silently nothing is ever
/// written into a vanished folder, and no partial `index.md` can resurrect deleted data."
/// Liveness is effective a card under a tombstoned lane is vanished too.
/// - **An empty commit removes the `title` key** (03-board-ui.md § Card face; 04 Selection:
/// "Committing an empty rename on an existing item removes its `title` key"), rather than
/// writing `title: ""` titles are optional, and the face shows the untitled placeholder.
/// - **An unchanged title writes nothing.** `setLaneWidth`'s rule, for the same reason: an
/// editor opened and dismissed with Return must not stamp `modified` or mint a commit.
///
/// The folder is re-derived from the *current* snapshot, which is what makes a foreign move
/// mid-rename invisible: the editor follows the UUID, and the write lands wherever the item is
/// now.
public func commitRename() {
guard let editor = transient.renameEditor else { return }
transient.discardRename()
guard let target = Self.liveItem(editor.targetID, in: snapshot) else { return }
let typed = editor.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let newTitle: String? = typed.isEmpty ? nil : typed
guard newTitle != target.title else { return }
var folder = rootURL.appendingPathComponent(target.laneID.rawValue)
if let cardID = target.cardID {
folder.append(component: cardID.rawValue)
}
try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the
// banner names the item by the title it still has rather than the one that failed to
// land (see `WriteOperation.rename`).
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
}
/// Where a live item lives and what it is currently called, or `nil` when the id names nothing
/// the board renders.
///
/// **Effective liveness, ancestor-walked** the same rule `CardWindowHost.cardWindowFate`
/// applies to a card window and `ItemReferenceSet` applies to the selection: a card under a
/// tombstoned lane renders nowhere, so it is as gone as a deleted one. The path is returned as
/// its two identity components rather than as a URL so the caller builds it off the store's
/// *current* `rootURL`, which a mid-session folder rename may have moved.
nonisolated static func liveItem(
_ id: ItemID,
in snapshot: BoardModel
) -> (laneID: ItemID, cardID: ItemID?, title: String?)? {
for lane in snapshot.lanes where !lane.isDeleted {
if lane.id == id {
return (laneID: lane.id, cardID: nil, title: lane.title.value)
}
if let card = lane.cards.first(where: { $0.id == id && !$0.isDeleted }) {
return (laneID: lane.id, cardID: card.id, title: card.title.value)
}
}
return nil
}
// MARK: - Lane reorder
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
/// counted **with the dragged lane itself removed** which is the index
/// `LaneReorderMath.proposedIndex` produces.
///
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
/// the same URL, so `moveItem` takes its same-parent degenerate-reorder path and rewrites
/// exactly one file the moved lane's `order`.
///
/// **A drag that ends where it started writes nothing**: `index == from` re-inserts the lane in
/// its own slot, and a no-op must not stamp `modified` or mint a commit the resize drag's
/// rule, and for the same reason.
public func moveLane(_ id: ItemID, toIndex index: Int) {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard let from = lanes.firstIndex(where: { $0.id == id }) else { return }
var remaining = lanes
remaining.remove(at: from)
let target = min(max(0, index), remaining.count)
guard target != from else { return }
let root = rootURL
let folder = root.appendingPathComponent(id.rawValue)
try? performWrite { () throws(BoardWriteError) -> Void in
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
if rank == nil {
// Compact and place again. Unlike the card case the dragged lane *is* among the
// renumbered children it is a real folder on disk so its fresh rank is dropped
// from the ladder before the neighbours are consulted.
try BoardWriter.renumberVisibleChildren(of: root)
var compacted = Ranks.renumbered(count: lanes.count)
compacted.remove(at: from)
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
}
guard let rank else { return }
_ = try BoardWriter.moveItem(
at: folder,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
// MARK: - Selection (delegated)
// The three thin pass-throughs to `transient`, and the only ones.
// The thin pass-throughs to `transient`, and the only ones.
//
// **Conveniences, not a second home.** The selection is the transient state every command site
// touches menu validation, , paste anchoring, Select All and `store.selection` reads better
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
// is stored here: `selection` is computed and the two mutators forward, so there is no second
// copy to go stale. Drag membership, the pending cut, the query and the placeholder get no such
// copy to go stale. Drag membership, the pending cut, the query and the editors get no such
// shortcuts they have one or two call sites each, and a delegate per field would be the
// grab-bag reassembling itself on this class.
//
// `isEditingInline` earns one for the selection's reason and no other: **every** board-mutating
// menu item validates against it (04-interactions.md's focused-editor rule), and a rule read
// that often should read as one word.
/// The board's selection, re-resolved against every snapshot this store applies
/// `TransientBoardState.selection` under a shorter name.
public var selection: ItemReferenceSet { transient.selection }
/// Replaces the selection `TransientBoardState.select(_:liveness:)`, which owns the semantics.
/// Whether an inline title editor is open `TransientBoardState.isEditingInline`, which owns
/// what it means and why every mutating command reads it.
public var isEditingInline: Bool { transient.isEditingInline }
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
///
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
/// takes a snapshot to answer "which lane is that". 04-interactions.md's N target rule calls
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
transient.select(ids, liveness: liveness)
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
///
/// The last-active lane deliberately survives: it is a high-water mark of where the user has
/// been working, and N after a deselect is exactly the case it exists to answer.
public func clearSelection() {
transient.clearSelection()
}
/// The lane a selection sits in, or `nil` when it names no single one a live lane selects
/// itself; live cards select their lane, but only when they all share one (a cross-lane
/// selection has no single home to remember).
nonisolated static func lane(holding ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
guard !ids.isEmpty else { return nil }
var found: ItemID?
for lane in snapshot.lanes where !lane.isDeleted {
let names = ids.contains(lane.id) || lane.cards.contains { !$0.isDeleted && ids.contains($0.id) }
guard names else { continue }
guard found == nil else { return nil }
found = lane.id
}
return found
}
// MARK: - Quiescence
/// Suspends until no reload is running and none is owed.