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