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
+40 -7
View File
@@ -55,6 +55,11 @@ final class CardWindowSession: CardSessionFlushing {
/// rather than pretending to have written it.
let body: CardBodyEditSession
/// The window header's title field `body`'s sibling, driven by the exact same begin/end/flush
/// calls (`CardTitleEditSession`'s own doc comment). Created here for `body`'s reason: it needs a
/// save target once the window has joined its board.
let title = CardTitleEditSession()
/// **This window's own undo stack** 13-native-undo.md Rules' second level (re-ruled
/// 2026-07-31): "a card window owns its own stack for the session it represents ... and
/// `window.undoManager` answers with it".
@@ -129,7 +134,7 @@ final class CardWindowSession: CardSessionFlushing {
/// place rather than unsaved work. An inline comment edit *is* the ordinary kind, so it counts
/// exactly as the body's does (`CardComments.holdsUnsavedContent`).
var holdsUnsavedContent: Bool {
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
body.isDirty || title.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
}
/// **What a restore or a branch switch asks this window to settle** (06-history-undo.md Rules
@@ -150,11 +155,13 @@ final class CardWindowSession: CardSessionFlushing {
var settlement: CardSessionSettlement? {
CardSessionSettlement(
needsSettling: { [self] in
body.isEditing || body.isDirty || rawSourceIsActive?() == true
body.isEditing || body.isDirty || title.isDirty || rawSourceIsActive?() == true
},
saveAll: { [self] in
// The Edit session ends with its normal commit "each card's EditPreview flip".
// The Edit session ends with its normal commit "each card's EditPreview flip"
// title alongside body, the same door either way.
body.endEditSession()
title.endEditSession()
// Apply validates; a refusal is the whole operation's cancellation, and the alert it
// raised is already on the offending window.
guard rawSourceIsActive?() == true else { return true }
@@ -165,6 +172,7 @@ final class CardWindowSession: CardSessionFlushing {
// part of the wholesale operation itself, which is the only party that knows what it
// is reconciling this card's folder towards (`SessionSettleGate`).
body.discardBuffer()
title.discardBuffer()
rawSourceCancel?()
}
)
@@ -182,12 +190,21 @@ final class CardWindowSession: CardSessionFlushing {
init() {
let body = CardBodyEditSession()
self.body = body
// Already default-initialized (`let title = CardTitleEditSession()`, above): captured into a
// local here for `body`'s own reason the closures below must not close over `self`, which
// would retain this object through `bufferGuard` right back at itself.
let title = self.title
bufferGuard = DirtyBufferGuard(
attemptSave: { () throws(BoardWriteError) -> Void in try body.flushOrThrow() },
attemptSave: { () throws(BoardWriteError) -> Void in
try body.flushOrThrow()
try title.flushOrThrow()
},
// "Save a copy elsewhere" writes the *buffer*, not the card: the destination is
// somewhere outside the board the user picked in a panel, so what lands there is the
// text they were typing, as a file, and nothing about frontmatter or identity travels
// with it.
// with it. The title rides along with the body here only in spirit the copy is the
// Markdown text, unchanged, since the panel's own file name already carries the title
// (`CardWindowHost.copyDestination`).
writeCopy: { url in try Data(body.text.utf8).write(to: url) }
)
}
@@ -203,6 +220,9 @@ final class CardWindowSession: CardSessionFlushing {
// Rules) once the session is known to be over. It is also where the body's *last* fine step
// joins this window's stack, which is why it has to precede the fold below.
body.endEditSession()
// The title field's own session, ended the same door see `CardTitleEditSession`'s doc
// comment on why there is no session boundary of its own to end.
title.endEditSession()
// **The saves, in the order the comments build fixed**: the inline session's flush, then the
// draft's (`CardComments.endSession`). Both may register their own last fine step, so both
// land before the fold.
@@ -475,6 +495,7 @@ struct CardWindowHost: View {
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
bodyPresentation: bodyPresentation,
bodySession: session.body,
titleSession: session.title,
rawSource: rawSource,
// "Under the read-only lock the controls disable in place an in-content mutation
// menu validation can't reach" (05 Preview). The checkbox is that control, and
@@ -708,12 +729,18 @@ struct CardWindowHost: View {
guard let store else { return .vanished }
return store.writeCardBody(inCard: cardID, body: text)
}
session.title.save = { [weak store] title in
guard let store else { return .vanished }
return store.commitCardTitle(inCard: cardID, title: title)
}
Self.configureUndo(session, store: store, cardID: cardID)
bodyPresentation.flushEdits = { [session] in
session.body.endEditSession()
session.title.endEditSession()
}
bodyPresentation.beginEdits = { [session] in
session.body.beginEditSession()
session.title.beginEditSession()
}
// **No stage-around wire here any more** (06-history-undo.md Rules Auto-commit, widened
// 2026-07-31 recorded because its absence is the change): the EditPreview flip used to open
@@ -724,6 +751,7 @@ struct CardWindowHost: View {
Self.configureRawSource(
rawSource,
body: session.body,
title: session.title,
presentation: bodyPresentation,
store: store,
cardID: cardID
@@ -780,6 +808,9 @@ struct CardWindowHost: View {
session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
}
session.title.registerUndo = { [weak store, undo = session.undo] priorTitle, newTitle in
store?.registerTitleEdit(inCard: cardID, priorTitle: priorTitle, newTitle: newTitle, on: undo)
}
session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
session.registerSessionStep = { [weak store] undo, purge in
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
@@ -936,13 +967,15 @@ struct CardWindowHost: View {
static func configureRawSource(
_ rawSource: CardRawSourceSession,
body: CardBodyEditSession,
title: CardTitleEditSession,
presentation: CardBodyPresentation,
store: BoardStore,
cardID: ItemID
) {
rawSource.flushPendingEdits = { [body, presentation] in
rawSource.flushPendingEdits = { [body, title, presentation] in
presentation.setMode(.preview)
body.flush()
title.flush()
}
rawSource.read = { [weak store] in
guard let store else { return .vanished }
@@ -1081,7 +1114,7 @@ struct CardWindowHost: View {
/// land and both were already visible to the user as the standing lock row or a card that left
/// the board (`CardBodyEditSession.flushOrThrow`).
private func closeAfterFlushing() {
guard session.body.isDirty else {
guard session.body.isDirty || session.title.isDirty else {
windowController.closeAfterFlush()
return
}