The card window's title learns Edit mode — a sibling session rides the body's own doors

CardWindowView's header is now the title, live: a static Text in Preview, an editable
single-line TextField in Edit, both reading a new CardTitleEditSession's buffer rather
than the card's own snapshot value — the same "buffer outranks the snapshot" reason the
body surface already reads bodySession.text instead of card.body.

CardTitleEditSession is CardBodyEditSession's shape one field over: the one-isDirty write
gate, dirty-buffer-wins on adopt(diskTitle:), the ~700ms injectable debounce, flush() /
flushOrThrow() for DirtyBufferGuard, and beginEditSession()/endEditSession() with a
session-coalesced undo step (one per session, never per debounced tick). It rides the
body's own begin/end/flush doors rather than opening a second session boundary — title is
only ever editable while the body column is in Edit mode — because the two write through
different WriteOperations with different validation and merging them would conflate two
unrelated frontmatter keys behind one buffer.

BoardStore.commitCardTitle(inCard:title:) reuses the same private setTitle helper and the
same .rename WriteOperation the board's own inline rename commits through, so trimming,
empty-removes-the-key, unchanged-writes-nothing and banner enrichment are one code path,
not a re-implementation. It resolves through cardBodyTarget (spans lanes and the trash),
not boardItem (board only), because a card window's title field stays live through the
same dismissal-into-trash flush the body already gets — the one deliberate divergence
from the board's own rename, which treats a trashed target as vanished.
registerTitleEdit(inCard:priorTitle:newTitle🔛) mirrors registerBodyEdit, anchored by
card identity and the already-reserved ExpectedField.title, folding into the same
one-coarse-step-per-window-close undo model.

Every place the body's Edit buffer flushes, the title's now does too: mode exit
(bodyPresentation.flushEdits/beginEdits), window close and the dismissal path
(CardWindowSession.endSession()), raw-source entry (configureRawSource), the close-time
DirtyBufferGuard modal (attemptSave tries body then title), and the fast-path close gate
(closeAfterFlushing() now checks title.isDirty alongside body.isDirty).
holdsUnsavedContent and settlement carry the title too.

Tests: CardTitleEditSessionTests.swift mirrors CardBodyEditSessionTests.swift (write
gates, normalization and newline-stripping, dirty-buffer-wins, debounce, undo-step
coalescing). CardTitleWriteTests.swift covers commitCardTitle/registerTitleEdit:
byte-identity no-op, empty-removes-key, vanished, the trashed-card-is-still-writable
divergence, a readable-but-uneditable target refusing and bannering, and a read-only
board suspending quietly. CardSessionUndoTests.swift gains coverage that a title edit
folds into the coarse close step alongside a body edit and registers on the window's own
stack, never the board's. RawSourceTests.swift's hand-wired rig picks up the title
session configureRawSource now also flushes.

3148 KanbanTests pass, 0 failures.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 10:37:55 -04:00
parent aa8c54fc7a
commit c27cc93ec1
8 changed files with 1164 additions and 24 deletions
+83
View File
@@ -2965,6 +2965,89 @@ public final class BoardStore: HealHost {
}
}
// MARK: - Card window title field
/// Saves the card window header's title field `writeCardBody`'s own shape, aimed at the
/// `.rename` vocabulary instead of the body span (05-card-window.md Edit, extended to the title;
/// `CardTitleEditSession.save`).
///
/// **Validation mirrors `commitRename` exactly, deliberately reusing its write** (`Self.setTitle`):
/// the caller hands over an already-normalized title trimmed, empty turned to `nil` and this
/// compares it against the card's *current* title before writing anything, so an unchanged commit
/// stamps nothing (`commitRename`'s "an unchanged title writes nothing at all"). The comparison is
/// against the store's own idea of the current title, freshly read off the snapshot, which is the
/// same belt-and-braces `CardBodyEditSession`'s doc comment describes for the body: the session's
/// own `isDirty` already gated the call, and this re-checks anyway.
///
/// **Resolved by `cardBodyTarget`, not `boardItem`** the one deliberate difference from
/// `commitRename`, and for `writeCardBody`'s own reason: the card window's title field stays live
/// through the same dismissal-into-trash flush the body does (05 Deletion & lifecycle), so the
/// write has to reach a card that was just moved into `.trash/` out from under the buffer. A card
/// resolving nowhere at all purged, or moved to another board is `.vanished`, "nowhere left to
/// write" exactly as the body's own outcome reads it.
///
/// Outcomes are `CardBodyWriteOutcome` rather than a title-shaped enum of its own: the five cases
/// mean the same things for a title as they do for a body (the bytes landed, nothing needed to,
/// the lock suspended the save, the card is gone, or the write failed and the banner already named
/// it), and a second enum with the same five doors would only be a second name to keep in step.
public func commitCardTitle(inCard cardID: ItemID, title: String?) -> CardBodyWriteOutcome {
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished }
let currentTitle = Self.cardTitle(at: target, in: snapshot)
guard title != currentTitle else { return .unchanged }
let folder = target.folder(under: rootURL)
do {
try performWrite { () throws(BoardWriteError) -> Void in
try Self.setTitle(title, at: folder)
}
return .written
} catch let refusal as BoardStoreWriteRefusal {
guard case let .readOnlyLocked(reason) = refusal else { return .unchanged }
return .suspended(reason)
} catch let error as BoardWriteError {
return .failed(error)
} catch {
// `performWrite`'s `throws` is untyped only because its two error types have not been
// unified yet (`BoardStoreWriteRefusal`); there is no third thing it can throw.
Self.logger.error("unexpected error saving a card title: \(String(describing: error), privacy: .public)")
return .unchanged
}
}
/// Registers **one title-edit session** as one undo step `registerBodyEdit`'s own reason,
/// applied to the header's title field instead of the body span (13-native-undo.md Rules).
///
/// The step's after-value is `ExpectedField.title`, the same field kind `commitRename`'s own step
/// declares reserved in `CardWindowUndo.Fold.fieldOrder` since before this milestone existed
/// so a title edit made in the window folds against a foreign board-side rename of the same card
/// exactly the way two board-side renames would stale each other: last write, field-level.
///
/// Anchored by card identity (`HistoryAnchor.card`), never by path, for `registerBodyEdit`'s own
/// reason: a lane move mid-session must not stale a step about the card's *name* any more than one
/// about its body.
public func registerTitleEdit(
inCard cardID: ItemID,
priorTitle: String?,
newTitle: String?,
on window: CardWindowUndo? = nil
) {
guard priorTitle != newTitle, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let card = HistoryAnchor.card(cardID)
let operation = WriteOperation.rename(title: Self.cardTitle(at: target, in: snapshot))
registerStep(
HistoryPhrase.name(.rename, kind: .card),
subject: newTitle ?? priorTitle,
on: window,
undoExpects: [.present(card, .title(newTitle))],
redoExpects: [.present(card, .title(priorTitle))]
) { store in
try Self.setTitle(priorTitle, at: try store.requiredFolder(for: card, operation))
} redo: { store in
try Self.setTitle(newTitle, at: try store.requiredFolder(for: card, operation))
}
}
/// Registers **one Edit session** as one undo step 13-native-undo.md Rules' coalescing
/// sentence, stated where the session ends rather than where the bytes land.
///