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:
@@ -449,6 +449,11 @@ public final class BannerCenter {
|
||||
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
||||
case let .resize(title):
|
||||
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
|
||||
case let .rename(title):
|
||||
// The title here is the item's name *before* the edit — the one the user is still
|
||||
// looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example
|
||||
// sentence) identify the right row rather than a name that never landed.
|
||||
if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" }
|
||||
case let .importAttachment(filename):
|
||||
"Couldn't import '\(filename)'"
|
||||
case .listAttachments:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -155,18 +155,81 @@ public struct NewCardPlaceholder: Sendable, Equatable {
|
||||
/// has before its title commits.
|
||||
public var laneID: ItemID
|
||||
|
||||
/// The card the new one is being born **immediately after**, or `nil` for the lane's bottom.
|
||||
///
|
||||
/// It exists because 04-interactions.md's ⌘N target rule is a *position*, not just a lane:
|
||||
/// "with a card selected, the new card is created in that card's lane, immediately after it
|
||||
/// (paste-anchor consistency)". Every other entry point — Return on a lane, the header button,
|
||||
/// a double-click on empty space, ⌘N with nothing selected — appends at the bottom and passes
|
||||
/// `nil`.
|
||||
///
|
||||
/// **A position, deliberately not a rank.** The `order` is computed at *commit* time against
|
||||
/// the snapshot as it is then (`BoardStore.commitPlaceholder`), never frozen when the editor
|
||||
/// opened: an agent filing a card into the gap mid-typing must move the new card, not be
|
||||
/// overwritten by a stale midpoint. An anchor that has vanished by commit time degrades to the
|
||||
/// lane's bottom rather than failing — the lane is the anchor that matters, and the card being
|
||||
/// created is the user's, not the vanished neighbour's.
|
||||
public var anchorCardID: ItemID?
|
||||
|
||||
/// What the user has typed so far. Lives here and nowhere else: there is no file to hold it.
|
||||
public var draftTitle: String
|
||||
|
||||
public var phase: Phase
|
||||
|
||||
public init(laneID: ItemID, draftTitle: String = "", phase: Phase = .editing) {
|
||||
public init(
|
||||
laneID: ItemID,
|
||||
anchorCardID: ItemID? = nil,
|
||||
draftTitle: String = "",
|
||||
phase: Phase = .editing
|
||||
) {
|
||||
self.laneID = laneID
|
||||
self.anchorCardID = anchorCardID
|
||||
self.draftTitle = draftTitle
|
||||
self.phase = phase
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RenameEditor
|
||||
|
||||
/// The **third inline editor** (04-interactions.md ▸ Grammar): a card's or a lane's title being
|
||||
/// edited in place, on the card face or in the lane header.
|
||||
///
|
||||
/// It is `NewCardPlaceholder`'s sibling in shape and its opposite in almost every rule, and the
|
||||
/// contrast is worth stating once:
|
||||
///
|
||||
/// | | `NewCardPlaceholder` | `RenameEditor` |
|
||||
/// |---|---|---|
|
||||
/// | References | a *lane* (no identity yet) | an **item**, by UUID |
|
||||
/// | Click-away | discards — nothing is on disk | **commits** — the item is real |
|
||||
/// | Empty commit | discards the whole draft | removes the `title` key |
|
||||
/// | A reload can | drop it, or hand it off | only drop it |
|
||||
///
|
||||
/// **Tracked by UUID, which makes a foreign move invisible.** "A foreign *move* mid-rename is
|
||||
/// invisible — the editor follows the UUID and the commit writes the title wherever the card now
|
||||
/// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed
|
||||
/// into another lane mid-typing is still renamed correctly.
|
||||
///
|
||||
/// There is no phase enum. The placeholder needs one because it outlives its own commit (the
|
||||
/// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for —
|
||||
/// the item is already on screen, and the commit's round trip simply updates it.
|
||||
public struct RenameEditor: Sendable, Equatable {
|
||||
|
||||
/// The card or lane being renamed. Deliberately untyped as to *kind*: the commit derives the
|
||||
/// folder by finding the id in the snapshot, and every rule here — the vanish discard, the
|
||||
/// UUID tracking, the empty-commit removal — reads identically for both levels.
|
||||
public var targetID: ItemID
|
||||
|
||||
/// What the user has typed. **Seeded from the current title** when the editor opens (an
|
||||
/// untitled item seeds empty, since "Untitled" is a rendering, not a value —
|
||||
/// 03-board-ui.md § Card face), and thereafter the only place the draft exists.
|
||||
public var draftTitle: String
|
||||
|
||||
public init(targetID: ItemID, draftTitle: String = "") {
|
||||
self.targetID = targetID
|
||||
self.draftTitle = draftTitle
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TransientBoardState
|
||||
|
||||
/// Everything a board's windows share that is **not on disk** — one per `BoardStore`, created with
|
||||
@@ -186,12 +249,18 @@ public struct NewCardPlaceholder: Sendable, Equatable {
|
||||
/// reload lands and a card edited to no longer match animates out. A stored result set would be
|
||||
/// a second, staler answer to a question the snapshot can always answer, and would need a
|
||||
/// re-resolution rule of its own — which is exactly the accretion this type exists to stop.
|
||||
/// 3. **The overlay** — `newCardPlaceholder`, anchored to a lane rather than to items, discarded
|
||||
/// when the lane it is anchored to goes away and handed off when the real card arrives.
|
||||
/// 3. **The inline editors** — `newCardPlaceholder`, anchored to a lane rather than to items,
|
||||
/// discarded when the lane it is anchored to goes away and handed off when the real card
|
||||
/// arrives; and `renameEditor`, anchored to an *item* and discarded when that item vanishes.
|
||||
/// Two editors, one at a time: they share the app's single keyboard focus, so beginning either
|
||||
/// ends the other.
|
||||
///
|
||||
/// The remainder is plain per-open values: `isTrashVisible` is hidden on every open and **never
|
||||
/// persisted** — visiting the trash is an errand, not a layout choice. It needs no reset logic
|
||||
/// because this object is built fresh with its store; closing the board is the reset.
|
||||
/// `lastActiveLaneID` is per-open in the same sense — "in this window session" is exactly the
|
||||
/// scope of a container built with its store — but it *does* reference an item, so `resolve` has
|
||||
/// something to say about it.
|
||||
///
|
||||
/// `@MainActor` because it is read by SwiftUI on the main actor and mutated by gestures there;
|
||||
/// `@Observable` so the board window and its card windows re-render off the same truth.
|
||||
@@ -238,7 +307,7 @@ public final class TransientBoardState {
|
||||
/// of its own.
|
||||
public var searchQuery: String = ""
|
||||
|
||||
// MARK: The overlay
|
||||
// MARK: The inline editors
|
||||
|
||||
/// The new-card placeholder, or `nil` when no card is being created. See `NewCardPlaceholder`
|
||||
/// for what it is and `resolve(against:)` for what a reload does to it.
|
||||
@@ -247,8 +316,40 @@ public final class TransientBoardState {
|
||||
/// card is exactly the kind of state that rots if anyone may assign it.
|
||||
public private(set) var newCardPlaceholder: NewCardPlaceholder?
|
||||
|
||||
/// The inline rename in flight, or `nil` when no title is being edited. See `RenameEditor`.
|
||||
///
|
||||
/// `private(set)` for the placeholder's reason, plus one of its own: the *commit* is a write
|
||||
/// that only `BoardStore` can perform, so an editor assignable from anywhere could be cleared
|
||||
/// out from under a commit that was about to read its draft.
|
||||
public private(set) var renameEditor: RenameEditor?
|
||||
|
||||
/// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one
|
||||
/// boolean: "while an inline title editor — rename or the new-card placeholder — is focused,
|
||||
/// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu
|
||||
/// validation".
|
||||
///
|
||||
/// Every board-mutating menu item validates against this, so the rule is stated once rather
|
||||
/// than re-derived per item. The one carve-out the design names — Open Card ⌘↩, which stays
|
||||
/// enabled to commit the edit and open the window — is the item's business, not this flag's.
|
||||
public var isEditingInline: Bool {
|
||||
newCardPlaceholder != nil || renameEditor != nil
|
||||
}
|
||||
|
||||
// MARK: Per-open values
|
||||
|
||||
/// The lane that most recently held selection or a creation **in this window session** —
|
||||
/// 04-interactions.md's ⌘N target rule's fallback when nothing (or a tombstoned something) is
|
||||
/// selected, before the last resort of the first lane.
|
||||
///
|
||||
/// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
|
||||
/// in the snapshot that could reconstruct which lane the user was last working in, which is
|
||||
/// precisely why the rule exists — a ⌘N after an Escape should file the card where the user
|
||||
/// has been, not at the far left of the board.
|
||||
///
|
||||
/// `resolve(against:)` clears it when the lane vanishes, because a target that renders nowhere
|
||||
/// is no target at all; `NewCardTarget` then falls through to the first lane.
|
||||
public private(set) var lastActiveLaneID: ItemID?
|
||||
|
||||
/// Whether the trash quasi-lane is showing (03-board-ui.md ▸ Trash).
|
||||
///
|
||||
/// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
|
||||
@@ -276,15 +377,37 @@ public final class TransientBoardState {
|
||||
selection = .empty
|
||||
}
|
||||
|
||||
/// Records that `laneID` is where the user is working — a lane selected, or created into.
|
||||
///
|
||||
/// **`nil` is a no-op, not a clear.** "Last-active" is a high-water mark: clearing the
|
||||
/// selection does not un-happen the lane the user was just in, and 04-interactions.md's rule
|
||||
/// leans on exactly that (⌘N *with nothing selected* is the case the memory serves). The only
|
||||
/// thing that clears it is the lane going away, which `resolve(against:)` owns.
|
||||
public func noteActiveLane(_ laneID: ItemID?) {
|
||||
guard let laneID else { return }
|
||||
lastActiveLaneID = laneID
|
||||
}
|
||||
|
||||
// MARK: - The placeholder's lifecycle
|
||||
|
||||
/// Opens the inline editor for a new card in `laneID`, replacing any placeholder already open.
|
||||
/// Opens the inline editor for a new card in `laneID`, replacing any editor already open and
|
||||
/// marking the lane active.
|
||||
///
|
||||
/// Replacing rather than refusing: two placeholders can never be open at once (one inline editor,
|
||||
/// one focus), so a second begin is the first one being abandoned — 04-interactions.md's
|
||||
/// click-away discard, arriving as a new creation instead of a click.
|
||||
public func beginPlaceholder(inLane laneID: ItemID) {
|
||||
newCardPlaceholder = NewCardPlaceholder(laneID: laneID)
|
||||
/// Replacing rather than refusing: two inline editors can never be open at once (one focus),
|
||||
/// so a second begin is the first one being abandoned — 04-interactions.md's click-away
|
||||
/// discard, arriving as a new creation instead of a click (02-architecture.md § Layering
|
||||
/// states it outright: "Starting a new creation while a placeholder is open is a click-away
|
||||
/// for the draft"). A rename in flight is dropped for the same reason; a rename's click-away
|
||||
/// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the
|
||||
/// focus is being taken by another editor before the user has said they are done.
|
||||
///
|
||||
/// - Parameter anchorCardID: the card the new one is born immediately after (04's ⌘N target
|
||||
/// rule), or `nil` for the lane's bottom — which is what Return, the header button, and a
|
||||
/// double-click on empty space all pass.
|
||||
public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
|
||||
renameEditor = nil
|
||||
newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
|
||||
noteActiveLane(laneID)
|
||||
}
|
||||
|
||||
/// Records what the user has typed. A no-op with no placeholder open — the draft has nowhere to
|
||||
@@ -312,6 +435,37 @@ public final class TransientBoardState {
|
||||
newCardPlaceholder = nil
|
||||
}
|
||||
|
||||
// MARK: - The rename editor's lifecycle
|
||||
|
||||
/// Opens the inline rename of `targetID`, seeded with `currentTitle`.
|
||||
///
|
||||
/// The seed is the caller's because this type holds no snapshot: the two entry points (Return
|
||||
/// on a sole selected card, Board ▸ Rename) both have the item in hand already. `nil` seeds an
|
||||
/// empty field — an untitled item has no title to edit, and "Untitled" is a rendering that
|
||||
/// must never be typed into the file (03-board-ui.md § Card face).
|
||||
///
|
||||
/// Replaces whatever editor was open, for `beginPlaceholder`'s reason: one focus, one editor.
|
||||
public func beginRename(of targetID: ItemID, currentTitle: String?) {
|
||||
newCardPlaceholder = nil
|
||||
renameEditor = RenameEditor(targetID: targetID, draftTitle: currentTitle ?? "")
|
||||
}
|
||||
|
||||
/// Records what the user has typed. A no-op with no editor open, like `updateDraft`.
|
||||
public func updateRenameDraft(_ title: String) {
|
||||
renameEditor?.draftTitle = title
|
||||
}
|
||||
|
||||
/// Closes the rename editor without writing — **Escape's abandon**, and also how
|
||||
/// `BoardStore.commitRename` retires the editor once its write has been issued (or refused).
|
||||
///
|
||||
/// There is no `commitRename` here for the same reason there is no create here: this type
|
||||
/// stores no URLs and performs no I/O. The keystrokes are simply dropped; on the abandon path
|
||||
/// disk was never touched, and on the commit path disk has already been touched by the time
|
||||
/// this runs.
|
||||
public func discardRename() {
|
||||
renameEditor = nil
|
||||
}
|
||||
|
||||
// MARK: - Reload
|
||||
|
||||
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
|
||||
@@ -341,6 +495,19 @@ public final class TransientBoardState {
|
||||
/// Otherwise the placeholder survives untouched: reloads swap the snapshot *underneath* the
|
||||
/// overlay, exactly as they do underneath the selection.
|
||||
///
|
||||
/// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸
|
||||
/// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
|
||||
/// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently.
|
||||
/// A foreign *move* is deliberately not a vanish — the editor follows the UUID and the commit
|
||||
/// writes wherever the item now lives — which falls out for free from matching on identity
|
||||
/// rather than on position. Liveness is **effective**, so a card under a lane an agent just
|
||||
/// tombstoned vanishes with it.
|
||||
///
|
||||
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
|
||||
/// gives every item-referencing piece of transient state: nothing may reference an item the
|
||||
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
|
||||
/// optional rather than a set on a side — the rule it obeys is the same one.
|
||||
///
|
||||
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references
|
||||
/// an item, so no snapshot can invalidate either — the query's *results* change with every
|
||||
/// snapshot, which is precisely why the results are not stored here.
|
||||
@@ -349,6 +516,16 @@ public final class TransientBoardState {
|
||||
dragMembers = dragMembers.resolved(against: snapshot)
|
||||
pendingCut = pendingCut.resolved(against: snapshot)
|
||||
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
|
||||
|
||||
// One universe computed once and asked three questions — the rename target's liveness, the
|
||||
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
|
||||
let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live)
|
||||
if let editor = renameEditor, !live.contains(editor.targetID) {
|
||||
renameEditor = nil
|
||||
}
|
||||
if let lane = lastActiveLaneID, !live.contains(lane) {
|
||||
lastActiveLaneID = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
|
||||
|
||||
Reference in New Issue
Block a user