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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user