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:
@@ -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 Edit→Preview flip".
|
||||
// The Edit session ends with its normal commit — "each card's Edit→Preview 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 Edit→Preview 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user