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:
@@ -87,8 +87,15 @@ private func openWindow(_ fixture: WriterFixture, store: BoardStore, board: Nati
|
||||
session.body.save = { [weak store] text in
|
||||
store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
|
||||
}
|
||||
// The title field's own write target — `configureSession`'s seam, spelled the same way as the
|
||||
// body's just above it.
|
||||
session.title.save = { [weak store] title in
|
||||
store?.commitCardTitle(inCard: cardID, title: title) ?? .vanished
|
||||
}
|
||||
session.comments.open()
|
||||
session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
|
||||
// `makeCommentBoard`'s fixture card is always titled "Fix login" — `coarseStep`'s own constant.
|
||||
session.title.adopt(diskTitle: "Fix login")
|
||||
return Window(store: store, board: board, session: session)
|
||||
}
|
||||
|
||||
@@ -98,6 +105,12 @@ private func editBody(_ window: Window, to text: String) {
|
||||
window.body.endEditSession()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func editTitle(_ window: Window, to text: String) {
|
||||
window.session.title.edited(text)
|
||||
window.session.title.endEditSession()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func postComment(_ window: Window, body: String) -> ItemID? {
|
||||
window.comments.composer.edited(body)
|
||||
@@ -1012,3 +1025,67 @@ struct CardSessionFoldTests {
|
||||
#expect(restored.expectations == [.present(live, .body("original\n"))])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The title field
|
||||
|
||||
/// `registerTitleEdit`'s own routing and fold, over the production wiring `CardWindowHost.configureUndo`
|
||||
/// installs — `CardSessionRoutingTests`' and `CardSessionCloseTests`' own claims, restated for the
|
||||
/// header's title field rather than the body (`CardTitleWriteTests.swift` pins the write itself).
|
||||
@MainActor
|
||||
@Suite("Card session undo ▸ the title field")
|
||||
struct CardSessionTitleTests {
|
||||
|
||||
@Test("A title edit registers on the window's own stack, never on the board's")
|
||||
func windowGesturesStayOffTheBoardStack() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
|
||||
editTitle(window, to: "Fix login, take two")
|
||||
|
||||
#expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps")
|
||||
#expect(window.window.stack.canUndo)
|
||||
#expect(window.window.stack.undoActionName == "Rename Card")
|
||||
}
|
||||
|
||||
@Test("A title edit and a body edit in one session fold into one coarse step")
|
||||
func foldsIntoTheCoarseStepAlongsideTheBody() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
let originalBody = try body(fixture, cardPath)
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
editTitle(window, to: "Fix login, take two")
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(window.board.undoActionName == coarseStep, "named for the card, not for either fine gesture")
|
||||
|
||||
window.board.undo()
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
let restored = model.lanes.flatMap(\.cards).first { $0.id == cardID }
|
||||
#expect(restored?.title == .valid("Fix login"), "the title is back too")
|
||||
#expect(try body(fixture, cardPath) == originalBody)
|
||||
|
||||
window.board.redo()
|
||||
let after = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
#expect(after.lanes.flatMap(\.cards).first { $0.id == cardID }?.title == .valid("Fix login, take two"))
|
||||
#expect(try body(fixture, cardPath) == "Edited in the window.\n")
|
||||
}
|
||||
|
||||
@Test("A title typed back to its starting value registers nothing")
|
||||
func aRoundTripRegistersNothing() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
|
||||
window.session.title.edited("Detour")
|
||||
_ = window.session.title.flush()
|
||||
editTitle(window, to: "Fix login")
|
||||
|
||||
#expect(!window.window.stack.canUndo, "the session's net effect on the title is nothing")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user