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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
// MARK: - CardTitleEditSession
|
||||
|
||||
/// The card window header's title field — **a body-edit session in miniature**
|
||||
/// (`CardBodyEditSession`'s own phrase for `CommentEditSession`, applied one level up), scoped to the
|
||||
/// single line the header shows in Edit mode.
|
||||
///
|
||||
/// ### What it borrows from `CardBodyEditSession`, verbatim
|
||||
///
|
||||
/// The buffer/disk pair, the single write predicate (*write if and only if the buffer differs from
|
||||
/// disk*), dirty-buffer-wins on `adopt(diskTitle:)`, the ~700 ms trailing debounce, `flush()` /
|
||||
/// `flushOrThrow()`, and the begin/end pair whose end registers the session's one undo step. **There
|
||||
/// is no session boundary of this type's own** — the card's title is only ever editable while the
|
||||
/// body column is in Edit mode (m6-card-body's rule, stated on `CardWindowView`'s header), so this
|
||||
/// session's `beginEditSession()` / `endEditSession()` are called from the exact places
|
||||
/// `CardBodyEditSession`'s are (`CardWindowHost.configureSession`'s `bodyPresentation.flushEdits` /
|
||||
/// `.beginEdits`, and `CardWindowSession.endSession()`), never on a schedule of their own. That is the
|
||||
/// literal reading of "title dirtiness rides the same gates/flush points as body".
|
||||
///
|
||||
/// ### What differs, and why it is a sibling rather than a merge
|
||||
///
|
||||
/// The write target is a different `WriteOperation` — a title commits through the same `.rename`
|
||||
/// vocabulary the board's inline rename uses (`BoardStore.commitCardTitle`, `BoardStore.setTitle`),
|
||||
/// never `.editBody`. Folding the two into one session would mean one buffer standing for two
|
||||
/// unrelated frontmatter keys with two different validation rules (a title trims and empty-removes;
|
||||
/// a body does neither), so a sibling object — driven by the same triggers, writing through its own
|
||||
/// seam — is the shape that keeps each write's rules in one place.
|
||||
///
|
||||
/// ### The value is `String?`, so "not yet captured" needs its own bit
|
||||
///
|
||||
/// `CardBodyEditSession.sessionOriginBody` uses `nil` for "no save has landed this session" because a
|
||||
/// body is never itself optional. A title's on-disk value legitimately *is* `nil` — a card can be
|
||||
/// untitled — so this type keeps `sessionHasOrigin` beside `sessionOriginTitle` rather than reusing
|
||||
/// `nil` for two different meanings.
|
||||
///
|
||||
/// ### Single-line, by construction and by filter
|
||||
///
|
||||
/// A plain `TextField` already turns Return into a submit rather than a newline (`CardWindowView`'s
|
||||
/// header wires `.onSubmit` to `flush()`), which is the whole of "Return ends editing rather than
|
||||
/// inserting" on the keystroke that matters. `edited(_:)` additionally strips `\n`/`\r` from
|
||||
/// whatever arrives — typing cannot produce either, but a paste can — so the buffer this type shows
|
||||
/// and writes is single-line even when the text came in some other way. Trimming leading/trailing
|
||||
/// whitespace and turning an empty result into `nil` happen at commit (`normalize(_:)`), mirroring
|
||||
/// the board's inline rename exactly (`BoardStore.commitRename`): a title of three spaces reads as
|
||||
/// untouched, never as a real (if blank) name.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CardTitleEditSession {
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// What the field is showing — raw, as typed, never trimmed. Preview's header reads this too
|
||||
/// (`CardWindowView.bodyColumn`), for `CardBodyEditSession.text`'s own reason: a flushed buffer is
|
||||
/// ahead of the snapshot by a reload, so the buffer is the truer of the two in both modes.
|
||||
public private(set) var text: String = ""
|
||||
|
||||
/// What the last snapshot said the card's `title` key holds. `nil` is untitled, a legitimate
|
||||
/// resting state rather than a missing reading.
|
||||
public private(set) var disk: String?
|
||||
|
||||
/// Whether the buffer, once normalized the way a commit would, differs from disk. Comparing the
|
||||
/// *normalized* text rather than the raw one is what keeps typing spaces around an untouched title
|
||||
/// from reading as dirty — `commitRename`'s own "whitespace commits as empty" rule, read one level
|
||||
/// earlier.
|
||||
public var isDirty: Bool { Self.normalize(text) != disk }
|
||||
|
||||
/// Whether an Edit session is open right now — `CardBodyEditSession.isEditing`'s own bookkeeping,
|
||||
/// kept here too because this type's undo step coalesces the same way body's does: one step per
|
||||
/// session, captured from the first landed save to the session's end.
|
||||
public private(set) var isEditing = false
|
||||
|
||||
// MARK: Seams
|
||||
|
||||
/// The debounce interval — body's own ~700 ms default, and settable for the same reason
|
||||
/// (`DragSession.holdTimeout`'s precedent: production default on the property, a test dials it
|
||||
/// down).
|
||||
@ObservationIgnored
|
||||
public var debounceInterval: Duration = .milliseconds(700)
|
||||
|
||||
/// Where a save goes — `BoardStore.commitCardTitle(inCard:title:)`, filled in by the window once it
|
||||
/// has joined its board. Takes the *normalized* title (trimmed, empty turned to `nil`), which is
|
||||
/// what the store's own write gate compares against the card's current title.
|
||||
@ObservationIgnored
|
||||
public var save: ((String?) -> CardBodyWriteOutcome)?
|
||||
|
||||
/// Where this session's one undo step goes, called at `endEditSession()` with the title disk held
|
||||
/// when the session's first save landed and the title it holds now —
|
||||
/// `CardBodyEditSession.registerUndo`'s seam, one field over.
|
||||
@ObservationIgnored
|
||||
public var registerUndo: ((_ priorTitle: String?, _ newTitle: String?) -> Void)?
|
||||
|
||||
/// Whether a save has landed this session — `CardBodyEditSession.sessionOriginBody`'s `nil` check,
|
||||
/// spelled as its own flag because `String?` is a real value here, not a "not captured" marker.
|
||||
@ObservationIgnored
|
||||
private var sessionHasOrigin = false
|
||||
|
||||
/// What disk said before this session's first landed save, when `sessionHasOrigin` is `true`.
|
||||
@ObservationIgnored
|
||||
private var sessionOriginTitle: String?
|
||||
|
||||
@ObservationIgnored
|
||||
private var pending: Task<Void, Never>?
|
||||
|
||||
/// How many saves have actually been attempted through `save`.
|
||||
@ObservationIgnored
|
||||
public private(set) var saveAttempts = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Disk → buffer
|
||||
|
||||
/// A snapshot arrived. Dirty-buffer-wins, `CardBodyEditSession.adopt(diskBody:)`'s exact shape:
|
||||
/// `disk` always follows it; `text` follows it only when the buffer had nothing unsaved.
|
||||
public func adopt(diskTitle: String?) {
|
||||
let wasDirty = isDirty
|
||||
disk = diskTitle
|
||||
guard !wasDirty else { return }
|
||||
let shown = diskTitle ?? ""
|
||||
if text != shown { text = shown }
|
||||
}
|
||||
|
||||
// MARK: - Buffer → disk
|
||||
|
||||
/// The field changed. Strips embedded newlines first (paste's own hazard — typing a single Return
|
||||
/// never reaches here at all, since a plain `TextField` treats it as a submit), then restarts the
|
||||
/// debounce, or cancels it when the change brought the buffer back to what a commit of it would
|
||||
/// leave on disk.
|
||||
public func edited(_ newText: String) {
|
||||
let sanitized = Self.stripNewlines(newText)
|
||||
guard text != sanitized else { return }
|
||||
text = sanitized
|
||||
guard isDirty else {
|
||||
cancelPending()
|
||||
return
|
||||
}
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/// Saves now if there is anything to save, cancelling the pending debounce first — the mode-exit
|
||||
/// and window-close flush, `CardBodyEditSession.flush()`'s own reason.
|
||||
@discardableResult
|
||||
public func flush() -> CardBodyWriteOutcome {
|
||||
cancelPending()
|
||||
return saveNow()
|
||||
}
|
||||
|
||||
/// The start of one Edit session, called alongside `CardBodyEditSession.beginEditSession()` from
|
||||
/// the same door (`CardBodyPresentation.beginEdits`). Idempotent for the same reason: a
|
||||
/// re-published focus value or a redundant menu validation must not renotify observers over an
|
||||
/// already-open session.
|
||||
public func beginEditSession() {
|
||||
guard !isEditing else { return }
|
||||
isEditing = true
|
||||
}
|
||||
|
||||
/// The end of one Edit session, called alongside `CardBodyEditSession.endEditSession()` from the
|
||||
/// same door. Flushes, then registers the session's one undo step when something of this session's
|
||||
/// actually landed and left a net change.
|
||||
@discardableResult
|
||||
public func endEditSession() -> CardBodyWriteOutcome {
|
||||
let outcome = flush()
|
||||
if sessionHasOrigin, sessionOriginTitle != disk {
|
||||
registerUndo?(sessionOriginTitle, disk)
|
||||
}
|
||||
sessionHasOrigin = false
|
||||
sessionOriginTitle = nil
|
||||
if isEditing { isEditing = false }
|
||||
return outcome
|
||||
}
|
||||
|
||||
/// Throws the buffer away and takes disk's word for it — the Discard branch of the save-or-discard
|
||||
/// step, `CardBodyEditSession.discardBuffer()`'s exact shape.
|
||||
public func discardBuffer() {
|
||||
cancelPending()
|
||||
text = disk ?? ""
|
||||
sessionHasOrigin = false
|
||||
sessionOriginTitle = nil
|
||||
if isEditing { isEditing = false }
|
||||
}
|
||||
|
||||
/// `DirtyBufferGuard`'s `attemptSave`, folded together with the body's own in
|
||||
/// `CardWindowSession.init()`: the same flush, with a real failure raised instead of reported. The
|
||||
/// three non-failures are `CardBodyEditSession.flushOrThrow()`'s own — written/unchanged land the
|
||||
/// text or need not, vanished has nowhere to write, suspended is the standing read-only lock row
|
||||
/// rather than a failure — and none of them is a reason to block the close.
|
||||
public func flushOrThrow() throws(BoardWriteError) {
|
||||
switch flush() {
|
||||
case .written, .unchanged, .vanished, .suspended:
|
||||
return
|
||||
case let .failed(error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func scheduleSave() {
|
||||
cancelPending()
|
||||
let interval = debounceInterval
|
||||
pending = Task { [weak self] in
|
||||
try? await Task.sleep(for: interval)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.pending = nil
|
||||
_ = self.saveNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPending() {
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
}
|
||||
|
||||
/// The gate, and the one place `save` is called. A landing moves `disk` up to the normalized text
|
||||
/// that landed, so the echo arriving a reload later finds the buffer already clean.
|
||||
private func saveNow() -> CardBodyWriteOutcome {
|
||||
guard isDirty else { return .unchanged }
|
||||
guard let save else { return .vanished }
|
||||
|
||||
saveAttempts += 1
|
||||
let priorTitle = disk
|
||||
let normalized = Self.normalize(text)
|
||||
let outcome = save(normalized)
|
||||
switch outcome {
|
||||
case .written:
|
||||
if !sessionHasOrigin {
|
||||
sessionHasOrigin = true
|
||||
sessionOriginTitle = priorTitle
|
||||
}
|
||||
disk = normalized
|
||||
case .unchanged:
|
||||
disk = normalized
|
||||
case .suspended, .vanished, .failed:
|
||||
break
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
/// Strips `\r\n`, `\n` and `\r` outright — the single-line field's own hard rule, applied to
|
||||
/// whatever arrives rather than only to what a commit would write, so the on-screen text never
|
||||
/// shows a line break a paste tried to bring in.
|
||||
private static func stripNewlines(_ raw: String) -> String {
|
||||
raw
|
||||
.replacingOccurrences(of: "\r\n", with: "")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
}
|
||||
|
||||
/// Trimmed, empty turned to `nil` — `BoardStore.commitRename`'s own reading of a typed title,
|
||||
/// applied here so `isDirty` and the value handed to `save` agree with what the board's inline
|
||||
/// rename would do with the same keystrokes.
|
||||
private static func normalize(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -32,16 +32,18 @@ import UniformTypeIdentifiers
|
||||
///
|
||||
/// ### What this milestone builds, and what it deliberately does not
|
||||
///
|
||||
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
||||
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the header's
|
||||
/// renderings of what the loader already knows — the card's title and its created/modified line —
|
||||
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the
|
||||
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
|
||||
/// reads or writes beyond that is later work and is marked where it lands:
|
||||
/// raw-source outlet swapping the pair of columns out entirely when it is active.
|
||||
///
|
||||
/// - the title as an editable field (commit on Return / focus loss, Escape abandons).
|
||||
/// **The title is an editable field in Edit mode, a static rendering in Preview** (m6-card-body,
|
||||
/// widened): it rides the body column's own mode rather than opening a second editor of its own, so
|
||||
/// "Edit mode" means both surfaces at once, and its own session (`titleSession`, `CardTitleEditSession`)
|
||||
/// begins and ends with the body's — see that type's doc comment for the whole of what that buys.
|
||||
///
|
||||
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
|
||||
/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
||||
/// The placeholders that remain are structural rather than apologetic: the sidebar's inventory and its
|
||||
/// order are settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
||||
/// underneath without the composition moving.
|
||||
///
|
||||
/// ### The width rule, in one line
|
||||
@@ -91,6 +93,11 @@ struct CardWindowView: View {
|
||||
/// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say —
|
||||
/// which is exactly the point at which dirty-buffer-wins is decided.
|
||||
let bodySession: CardBodyEditSession
|
||||
/// This window's title field — `bodySession`'s sibling, over the header's single line rather than
|
||||
/// the body span (m6-card-body, `CardTitleEditSession`). Read in both modes for `bodySession`'s
|
||||
/// own reason: a flushed buffer is ahead of the snapshot by a reload, so it is the truer of the
|
||||
/// two whether the header is showing static text or the editable field.
|
||||
let titleSession: CardTitleEditSession
|
||||
/// This window's raw-source outlet. While it is active the two columns are gone entirely — see
|
||||
/// `body`.
|
||||
let rawSource: CardRawSourceSession
|
||||
@@ -325,15 +332,7 @@ struct CardWindowView: View {
|
||||
private var bodyColumn: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
|
||||
// m6-card-body: the title *field* — large and borderless, committing to frontmatter
|
||||
// on Return or focus loss, clearing to remove the `title` key, Escape abandoning to
|
||||
// the on-disk title. Read-only here; the placeholder rendering is already final.
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.largeTitle)
|
||||
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the
|
||||
// same secondary treatment the face gives it.
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
titleRow
|
||||
|
||||
if let dateLine {
|
||||
Text(dateLine)
|
||||
@@ -368,12 +367,62 @@ struct CardWindowView: View {
|
||||
.onChange(of: card.body, initial: true) { _, body in
|
||||
bodySession.adopt(diskBody: body)
|
||||
}
|
||||
// The title field's own dirty-buffer-wins, `bodySession`'s exact rule one property over — see
|
||||
// `titleSession`'s doc comment.
|
||||
.onChange(of: card.title.value, initial: true) { _, title in
|
||||
titleSession.adopt(diskTitle: title)
|
||||
}
|
||||
// **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
|
||||
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
|
||||
// "once" — a later reload that empties the file must not drag a reader into Edit.
|
||||
.task { bodyPresentation.openIfNeeded(body: card.body) }
|
||||
}
|
||||
|
||||
/// The header's title — large and borderless in both readings (m6-card-body). **Edit mode makes it
|
||||
/// an editable field; Preview keeps it static text** — the same surfaces `CardBodySurface` gives
|
||||
/// the body one column down, over the smaller session one property to the side.
|
||||
///
|
||||
/// Both branches read `titleSession.text`, never `card.title.value` directly, for the reason
|
||||
/// `titleSession`'s doc comment gives: the buffer outranks the snapshot while it is dirty, and
|
||||
/// outruns it by exactly one reload the instant a save lands, so reading the snapshot in Preview
|
||||
/// would show a stale title for that reload's length — precisely what a card window must never do
|
||||
/// (05-card-window.md ▸ Window).
|
||||
@ViewBuilder
|
||||
private var titleRow: some View {
|
||||
if bodyPresentation.mode == .edit {
|
||||
TextField(
|
||||
"Card title",
|
||||
text: Binding(
|
||||
get: { titleSession.text },
|
||||
set: { titleSession.edited($0) }
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(1)
|
||||
.font(.largeTitle)
|
||||
// Return commits the field rather than exiting Edit mode — the title's own session ends
|
||||
// with the body's (mode exit, window close), not with this key. `.onSubmit` never sees a
|
||||
// literal newline in the first place: a plain `TextField` treats Return as a submit, which
|
||||
// is the other half of "no newlines" beside `CardTitleEditSession.edited(_:)`'s paste
|
||||
// filter.
|
||||
.onSubmit { titleSession.flush() }
|
||||
} else {
|
||||
// Trimmed for the read-only rendering only — the field above shows `titleSession.text`
|
||||
// raw, so a mid-typed run of padding never has its cursor position disturbed
|
||||
// (`CardTitleEditSession.saveNow()` never rewrites the live buffer either, for the same
|
||||
// reason). Preview only ever renders while nothing is being typed, so trimming purely for
|
||||
// display is free of that hazard and is what keeps a title committed down to nothing
|
||||
// (spaces alone) reading as "Untitled" here rather than as a run of blank space.
|
||||
let displayed = titleSession.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Text(displayed.isEmpty ? "Untitled" : displayed)
|
||||
.font(.largeTitle)
|
||||
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the same
|
||||
// secondary treatment the face gives it.
|
||||
.foregroundStyle(displayed.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
|
||||
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
||||
///
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The title field's buffer state machine — `CardBodyEditSessionTests`' own shape, one field over
|
||||
/// (`CardTitleEditSession`'s doc comment: "a body-edit session in miniature").
|
||||
///
|
||||
/// The write gates, dirty-buffer-wins, the debounce and the mode-flip flush are the same claims
|
||||
/// `CardBodyEditSessionTests` makes about the body, restated here because the two types are siblings
|
||||
/// rather than one type reused — see `CardTitleEditSession`'s doc comment for why. What is new to this
|
||||
/// suite is normalization (trim, empty → `nil`) and the newline filter; those get their own section.
|
||||
|
||||
// MARK: - The fake destination
|
||||
|
||||
/// A stand-in for `BoardStore.commitCardTitle`, recording what it was asked to write and answering
|
||||
/// with whatever outcome the test wants.
|
||||
@MainActor
|
||||
private final class TitleSaveSpy {
|
||||
private(set) var written: [String?] = []
|
||||
var outcome: CardBodyWriteOutcome = .written
|
||||
|
||||
var count: Int { written.count }
|
||||
var last: String?? { written.last }
|
||||
|
||||
func save(_ title: String?) -> CardBodyWriteOutcome {
|
||||
written.append(title)
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeSession(_ spy: TitleSaveSpy, title: String? = "Original") -> CardTitleEditSession {
|
||||
let session = CardTitleEditSession()
|
||||
session.debounceInterval = .milliseconds(30)
|
||||
session.save = { [spy] title in spy.save(title) }
|
||||
session.adopt(diskTitle: title)
|
||||
return session
|
||||
}
|
||||
|
||||
/// Polls until `condition` holds or the deadline passes — `CardBodyEditSessionTests`' own helper.
|
||||
@MainActor
|
||||
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
|
||||
let start = ContinuousClock.now
|
||||
while !condition() {
|
||||
guard ContinuousClock.now - start < deadline else { return }
|
||||
try? await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The three gates
|
||||
|
||||
@MainActor
|
||||
@Suite("Card title ▸ the write gates")
|
||||
struct CardTitleWriteGateTests {
|
||||
|
||||
@Test("An untouched session writes nothing, ever")
|
||||
func anUntouchedSessionWritesNothing() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
|
||||
#expect(session.flush() == .unchanged)
|
||||
await waitUntil { spy.count > 0 }
|
||||
#expect(spy.count == 0)
|
||||
#expect(session.saveAttempts == 0)
|
||||
}
|
||||
|
||||
@Test("An untitled card opened and left writes nothing")
|
||||
func anUntitledSessionWritesNothing() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
#expect(session.text == "")
|
||||
#expect(session.flush() == .unchanged)
|
||||
await waitUntil { spy.count > 0 }
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
|
||||
@Test("A typed-then-reverted edit writes nothing, and leaves no timer standing")
|
||||
func aRevertedEditWritesNothing() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
|
||||
session.edited("Original and more")
|
||||
#expect(session.isDirty)
|
||||
session.edited("Original")
|
||||
#expect(!session.isDirty)
|
||||
|
||||
await waitUntil { spy.count > 0 }
|
||||
#expect(spy.count == 0)
|
||||
#expect(session.flush() == .unchanged)
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
|
||||
@Test("The echo of the app's own save is not written back")
|
||||
func anEchoIsNotWrittenBack() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
|
||||
session.edited("Typed")
|
||||
#expect(session.flush() == .written)
|
||||
#expect(spy.written == ["Typed"])
|
||||
|
||||
session.adopt(diskTitle: "Typed")
|
||||
#expect(!session.isDirty)
|
||||
#expect(session.flush() == .unchanged)
|
||||
await waitUntil { spy.count > 1 }
|
||||
#expect(spy.count == 1)
|
||||
}
|
||||
|
||||
@Test("An external edit under a clean buffer is not written back either")
|
||||
func aForeignEditUnderACleanBufferIsNotWrittenBack() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
|
||||
session.adopt(diskTitle: "Theirs")
|
||||
|
||||
#expect(session.text == "Theirs")
|
||||
#expect(!session.isDirty)
|
||||
#expect(session.flush() == .unchanged)
|
||||
await waitUntil { spy.count > 0 }
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Normalization
|
||||
|
||||
@MainActor
|
||||
@Suite("Card title ▸ normalization")
|
||||
struct CardTitleNormalizationTests {
|
||||
|
||||
@Test("Whitespace commits as empty — a title of three spaces is a slip, not a name")
|
||||
func whitespaceIsEmpty() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
session.edited(" ")
|
||||
// Untouched, in the write-gate's own terms: the untitled card's normalized text is still
|
||||
// `nil`, so there is nothing to write — `commitRename`'s own "whitespace commits as empty".
|
||||
#expect(!session.isDirty)
|
||||
#expect(session.flush() == .unchanged)
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
|
||||
@Test("Padded text writes trimmed, not with its padding")
|
||||
func paddedTextIsTrimmedAtCommit() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
session.edited(" Fix login ")
|
||||
#expect(session.isDirty)
|
||||
#expect(session.flush() == .written)
|
||||
#expect(spy.written == ["Fix login"])
|
||||
}
|
||||
|
||||
@Test("Clearing an existing title to spaces removes it")
|
||||
func clearingToSpacesRemovesTheTitle() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: "First")
|
||||
|
||||
session.edited(" ")
|
||||
#expect(session.isDirty)
|
||||
#expect(session.flush() == .written)
|
||||
#expect(spy.written == [String?.none])
|
||||
}
|
||||
|
||||
@Test("Embedded newlines are stripped as they are typed — the single-line rule")
|
||||
func embeddedNewlinesAreStripped() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
// A paste bringing in a hard return — never something a plain `TextField`'s Return key can
|
||||
// produce on its own (`CardWindowView.titleRow`'s `.onSubmit`), but a paste is not the same
|
||||
// door.
|
||||
session.edited("Foo\nBar\r\nBaz\r")
|
||||
#expect(session.text == "FooBarBaz", "no line break survives into the shown buffer")
|
||||
#expect(session.flush() == .written)
|
||||
#expect(spy.written == ["FooBarBaz"])
|
||||
}
|
||||
|
||||
@Test("A no-op edit — typing then retyping the same normalized text — writes nothing")
|
||||
func aNoOpNormalizedEditWritesNothing() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: "Fix login")
|
||||
|
||||
// Padding around the exact title already on disk normalizes to the same value.
|
||||
session.edited(" Fix login ")
|
||||
#expect(!session.isDirty, "byte-identity discipline: padding around an unchanged title is not a change")
|
||||
#expect(session.flush() == .unchanged)
|
||||
await waitUntil { spy.count > 0 }
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dirty-buffer-wins
|
||||
|
||||
@MainActor
|
||||
@Suite("Card title ▸ dirty-buffer-wins")
|
||||
struct CardTitleDirtyBufferTests {
|
||||
|
||||
@Test("A snapshot never reloads a dirty buffer under the cursor")
|
||||
func aDirtyBufferKeepsItsText() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
|
||||
session.edited("Mine, unsaved")
|
||||
session.adopt(diskTitle: "Theirs")
|
||||
|
||||
#expect(session.text == "Mine, unsaved")
|
||||
#expect(session.disk == "Theirs")
|
||||
#expect(session.isDirty)
|
||||
}
|
||||
|
||||
@Test("The buffer's own save then lands over the foreign edit — last writer wins")
|
||||
func theFlushOverwritesTheForeignEdit() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
|
||||
session.edited("Mine, unsaved")
|
||||
session.adopt(diskTitle: "Theirs")
|
||||
|
||||
#expect(session.flush() == .written)
|
||||
#expect(spy.written == ["Mine, unsaved"])
|
||||
#expect(!session.isDirty)
|
||||
}
|
||||
|
||||
@Test("A failed save keeps the buffer dirty, and the text")
|
||||
func aFailureKeepsTheText() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
spy.outcome = .failed(BoardWriteError(
|
||||
operation: .rename(title: "Original"),
|
||||
path: "/x/index.md",
|
||||
reason: .io(message: "the disk is full")
|
||||
))
|
||||
|
||||
session.edited("Precious")
|
||||
let outcome = session.flush()
|
||||
|
||||
guard case .failed = outcome else {
|
||||
Issue.record("expected the failure to be reported, got \(outcome)")
|
||||
return
|
||||
}
|
||||
#expect(session.text == "Precious")
|
||||
#expect(session.isDirty)
|
||||
}
|
||||
|
||||
@Test("A suspended save — the read-only lock — also keeps the buffer, and does not throw a close")
|
||||
func aSuspendedSaveKeepsTheBuffer() throws {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
spy.outcome = .suspended(.unwritableLocation(.permissionDenied))
|
||||
|
||||
session.edited("Held")
|
||||
#expect(session.flush() == .suspended(.unwritableLocation(.permissionDenied)))
|
||||
#expect(session.isDirty)
|
||||
try session.flushOrThrow()
|
||||
}
|
||||
|
||||
@Test("A real failure is what the close-time modal is raised on")
|
||||
func aFailureThrowsForTheGuard() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy)
|
||||
spy.outcome = .failed(BoardWriteError(operation: .rename(title: nil), path: "/x", reason: .io(message: "nope")))
|
||||
session.edited("Unsaved")
|
||||
|
||||
#expect(throws: BoardWriteError.self) { try session.flushOrThrow() }
|
||||
}
|
||||
|
||||
@Test("A session with nowhere to write keeps its text rather than reporting success")
|
||||
func aSessionWithNoDestinationHoldsOn() {
|
||||
let session = CardTitleEditSession()
|
||||
session.adopt(diskTitle: "Start")
|
||||
session.edited("Typed")
|
||||
|
||||
#expect(session.flush() == .vanished)
|
||||
#expect(session.isDirty)
|
||||
#expect(session.text == "Typed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The debounce
|
||||
|
||||
@MainActor
|
||||
@Suite("Card title ▸ the debounce")
|
||||
struct CardTitleDebounceTests {
|
||||
|
||||
@Test("Typing saves once the keystrokes stop")
|
||||
func typingSavesAfterTheInterval() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
session.edited("T")
|
||||
#expect(spy.count == 0, "not on the keystroke itself")
|
||||
|
||||
await waitUntil { spy.count == 1 }
|
||||
#expect(spy.written == ["T"])
|
||||
#expect(!session.isDirty)
|
||||
}
|
||||
|
||||
@Test("A burst of keystrokes is one save, of the last text")
|
||||
func aBurstCoalesces() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
for text in ["a", "ab", "abc", "abcd"] {
|
||||
session.edited(text)
|
||||
}
|
||||
|
||||
await waitUntil { spy.count >= 1 }
|
||||
#expect(spy.written == ["abcd"])
|
||||
}
|
||||
|
||||
@Test("A flush does not wait for the debounce, and the debounce does not fire behind it")
|
||||
func aFlushPreemptsThePendingSave() async {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
session.edited("Typed")
|
||||
#expect(session.flush() == .written)
|
||||
#expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)")
|
||||
|
||||
await waitUntil { spy.count > 1 }
|
||||
#expect(spy.count == 1)
|
||||
}
|
||||
|
||||
@Test("The production interval is ~700 ms — the body's own default")
|
||||
func theDefaultIntervalIsTheDesignsNumber() {
|
||||
#expect(CardTitleEditSession().debounceInterval == .milliseconds(700))
|
||||
}
|
||||
|
||||
@Test("Ending the session flushes — the Edit→Preview flip is the effective Save")
|
||||
func endingTheSessionFlushes() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: nil)
|
||||
|
||||
session.edited("Typed on the way out")
|
||||
#expect(session.endEditSession() == .written)
|
||||
#expect(spy.written == ["Typed on the way out"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The undo step
|
||||
|
||||
@MainActor
|
||||
@Suite("Card title ▸ the undo step")
|
||||
struct CardTitleUndoTests {
|
||||
|
||||
@Test("A session's net effect registers exactly once, at endEditSession")
|
||||
func oneStepPerSession() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: "First")
|
||||
var registered: [(String?, String?)] = []
|
||||
session.registerUndo = { prior, new in registered.append((prior, new)) }
|
||||
|
||||
// Two debounced ticks inside one session — still one step.
|
||||
session.edited("Second")
|
||||
_ = session.flush()
|
||||
session.edited("Third")
|
||||
_ = session.flush()
|
||||
_ = session.endEditSession()
|
||||
|
||||
#expect(registered.count == 1)
|
||||
#expect(registered.first?.0 == "First", "the bytes before the session's first landed save")
|
||||
#expect(registered.first?.1 == "Third", "the bytes the session left")
|
||||
}
|
||||
|
||||
@Test("A session that types its way back to where it started registers nothing")
|
||||
func aRoundTripRegistersNothing() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: "First")
|
||||
var registered = 0
|
||||
session.registerUndo = { _, _ in registered += 1 }
|
||||
|
||||
session.edited("Detour")
|
||||
_ = session.flush()
|
||||
session.edited("First")
|
||||
_ = session.endEditSession()
|
||||
|
||||
#expect(registered == 0)
|
||||
}
|
||||
|
||||
@Test("A session that only read registers nothing")
|
||||
func aReadOnlySessionRegistersNothing() {
|
||||
let spy = TitleSaveSpy()
|
||||
let session = makeSession(spy, title: "First")
|
||||
var registered = 0
|
||||
session.registerUndo = { _, _ in registered += 1 }
|
||||
|
||||
_ = session.endEditSession()
|
||||
|
||||
#expect(registered == 0)
|
||||
#expect(spy.count == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The card window title field's **write** path — `BoardStore.commitCardTitle` and
|
||||
/// `BoardStore.registerTitleEdit` — `InlineEditWriteTests.swift`'s own shape, aimed at the seam the
|
||||
/// card window's header uses instead of the board's inline rename editor.
|
||||
///
|
||||
/// Validation is deliberately the same as `InlineRenameWriteTests` pins for `commitRename`: trim,
|
||||
/// empty removes the key, an unchanged title writes nothing, a readable-but-uneditable target refuses
|
||||
/// and banners, a locked board suspends quietly. The one deliberate difference is resolution — a card
|
||||
/// window's title field stays live through the dismissal-into-trash flush the body already gets
|
||||
/// (05-card-window.md ▸ Deletion & lifecycle), so it resolves through `cardBodyTarget` (both
|
||||
/// containers) rather than `boardItem` (board only), and a trashed card is writable here where the
|
||||
/// board's own inline rename would treat it as vanished.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// One lane with two cards, plus a readable-but-uneditable lane — `InlineEditWriteTests.swift`'s own
|
||||
/// `makeBoard()`, trimmed to what this file's suites need.
|
||||
@MainActor
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try fixture.item(Ident.lane3, Item.uneditable)
|
||||
return fixture
|
||||
}
|
||||
|
||||
private let card1 = ItemID(rawValue: Ident.card1)
|
||||
private let card2 = ItemID(rawValue: Ident.card2)
|
||||
|
||||
/// The file's lines minus the ones an app-mediated write is *supposed* to change —
|
||||
/// `InlineEditWriteTests.swift`'s own `untouchedLines`.
|
||||
private func untouchedLines(_ text: String) -> [Substring] {
|
||||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||
!$0.hasPrefix("modified") && !$0.hasPrefix("title:") && !$0.hasPrefix("kind:")
|
||||
}
|
||||
}
|
||||
|
||||
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||||
try BoardLoader.load(boardRoot: fixture.root).model
|
||||
}
|
||||
|
||||
private func card(_ id: ItemID, in model: BoardModel) -> Card? {
|
||||
model.lanes.flatMap(\.cards).first { $0.id == id }
|
||||
}
|
||||
|
||||
// MARK: - commitCardTitle
|
||||
|
||||
@MainActor
|
||||
@Suite("Card title ▸ commitCardTitle")
|
||||
struct CardTitleCommitWriteTests {
|
||||
|
||||
@Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
|
||||
func writesTheTitleAndStamps() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
#expect(store.commitCardTitle(inCard: card1, title: "Fix login") == .written)
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
#expect(after.contains("title: Fix login"))
|
||||
#expect(!after.contains("modified-by"))
|
||||
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"))
|
||||
#expect(untouchedLines(after) == untouchedLines(before))
|
||||
|
||||
let renamed = try #require(card(card1, in: load(fixture)))
|
||||
#expect(renamed.title == .valid("Fix login"))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An empty commit removes the title key, byte-faithfully")
|
||||
func emptyCommitRemovesTheKey() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
#expect(store.commitCardTitle(inCard: card1, title: nil) == .written)
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
#expect(!after.contains("title:"))
|
||||
#expect(!after.contains("title: \"\""))
|
||||
#expect(untouchedLines(after) == untouchedLines(before))
|
||||
|
||||
let stripped = try #require(card(card1, in: load(fixture)))
|
||||
#expect(stripped.title.isMissing)
|
||||
#expect(stripped.order == 1024, "the card keeps its place")
|
||||
}
|
||||
|
||||
@Test("An unchanged title writes nothing at all — byte-identity discipline")
|
||||
func unchangedTitleIsANoOp() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// The card window's session hands over an already-normalized title, so an end-editing commit
|
||||
// of exactly what disk says must not stamp `modified` — the field's own no-op end-editing rule.
|
||||
#expect(store.commitCardTitle(inCard: card1, title: "First") == .unchanged)
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
||||
|
||||
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)") == ["index.md"], "no temp-file residue")
|
||||
}
|
||||
|
||||
@Test("A commit at a card the snapshot does not have writes nothing, silently")
|
||||
func vanishedTargetWritesNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.commitCardTitle(inCard: ItemID(rawValue: Ident.indexless), title: "Never lands") == .vanished)
|
||||
#expect(!fixture.exists(Ident.indexless))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A card in the trash is still writable here — the dismissal-into-trash flush's own target")
|
||||
func aTrashedCardIsStillWritable() throws {
|
||||
// "A dirty Edit buffer flushes into the card's folder at its new `.trash/` location before
|
||||
// the window dismisses" (05-card-window.md ▸ Deletion & lifecycle) — the title field rides the
|
||||
// same rule, unlike the board's own inline rename (`InlineRenameWriteTests
|
||||
// .targetInTheTrashWritesNothing`), which treats a trashed target as vanished.
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.commitCardTitle(inCard: card1, title: "Renamed while dismissing") == .written)
|
||||
|
||||
#expect(try fixture.indexText(".trash/\(Ident.card1)").contains("title: Renamed while dismissing"))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes")
|
||||
func uneditableTargetBanners() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let target = ItemID(rawValue: Ident.card3)
|
||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card3)")
|
||||
|
||||
#expect(store.commitCardTitle(inCard: target, title: "Renamed") != .written)
|
||||
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card3)") == before)
|
||||
#expect(store.banners.oneShots.count == 1)
|
||||
let posted = try #require(store.banners.oneShots.first)
|
||||
// Enriched off the document the write refused, `commitRename`'s own rule — the banner names
|
||||
// the card by the title it still has rather than the one that failed to land.
|
||||
#expect(posted.error.operation == .rename(title: "Odd"))
|
||||
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
|
||||
}
|
||||
|
||||
@Test("A read-only board suspends the write without a second banner — the lock row already stands")
|
||||
func readOnlyBoardSuspendsQuietly() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.enterVanishedRootLock()
|
||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
let outcome = store.commitCardTitle(inCard: card1, title: "Fix login")
|
||||
guard case .suspended = outcome else {
|
||||
Issue.record("expected .suspended, got \(outcome)")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
||||
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
|
||||
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - registerTitleEdit
|
||||
|
||||
@MainActor
|
||||
@Suite("Card title ▸ registerTitleEdit")
|
||||
struct CardTitleRegisterEditTests {
|
||||
|
||||
@Test("A landed edit registers one step on the given stack, and undo restores the prior title")
|
||||
func registersAndUndoes() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let board = NativeHistoryProvider()
|
||||
store.history = board
|
||||
|
||||
#expect(store.commitCardTitle(inCard: card1, title: "Fix login") == .written)
|
||||
store.registerTitleEdit(inCard: card1, priorTitle: "First", newTitle: "Fix login")
|
||||
|
||||
#expect(board.canUndo)
|
||||
#expect(board.undoActionName == "Rename Card")
|
||||
|
||||
board.undo()
|
||||
#expect(try #require(card(card1, in: load(fixture))).title == .valid("First"))
|
||||
|
||||
board.redo()
|
||||
#expect(try #require(card(card1, in: load(fixture))).title == .valid("Fix login"))
|
||||
}
|
||||
|
||||
@Test("No net change registers nothing")
|
||||
func noChangeRegistersNothing() {
|
||||
let fixture = try? WriterFixture()
|
||||
guard let fixture else { Issue.record("fixture"); return }
|
||||
defer { fixture.tearDown() }
|
||||
guard let store = try? BoardStore(rootURL: fixture.root) else {
|
||||
Issue.record("store")
|
||||
return
|
||||
}
|
||||
let board = NativeHistoryProvider()
|
||||
store.history = board
|
||||
|
||||
store.registerTitleEdit(inCard: card1, priorTitle: "Same", newTitle: "Same")
|
||||
#expect(!board.canUndo)
|
||||
}
|
||||
|
||||
@Test("A card window session step lands on the window's own stack, not the board's")
|
||||
func landsOnTheWindowStack() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let board = NativeHistoryProvider()
|
||||
store.history = board
|
||||
let window = CardWindowUndo()
|
||||
|
||||
#expect(store.commitCardTitle(inCard: card1, title: "Fix login") == .written)
|
||||
store.registerTitleEdit(inCard: card1, priorTitle: "First", newTitle: "Fix login", on: window)
|
||||
|
||||
#expect(window.stack.canUndo)
|
||||
#expect(!board.canUndo, "a window gesture never lands on the board stack while the window is open")
|
||||
}
|
||||
}
|
||||
@@ -526,10 +526,20 @@ struct RawSourceSessionTests {
|
||||
return store.writeCardBody(inCard: cardID, body: text)
|
||||
}
|
||||
body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
|
||||
presentation.flushEdits = { body.endEditSession() }
|
||||
// The title field's own session — not this suite's subject, but `configureRawSource` now
|
||||
// flushes it alongside the body, so the wiring under test needs one to flush.
|
||||
let title = CardTitleEditSession()
|
||||
title.save = { [weak store] title in
|
||||
guard let store else { return .vanished }
|
||||
return store.commitCardTitle(inCard: cardID, title: title)
|
||||
}
|
||||
presentation.flushEdits = {
|
||||
body.endEditSession()
|
||||
title.endEditSession()
|
||||
}
|
||||
|
||||
let raw = CardRawSourceSession()
|
||||
CardWindowHost.configureRawSource(raw, body: body, presentation: presentation, store: store, cardID: cardID)
|
||||
CardWindowHost.configureRawSource(raw, body: body, title: title, presentation: presentation, store: store, cardID: cardID)
|
||||
|
||||
return Rig(fixture: fixture, store: store, presentation: presentation, body: body, raw: raw)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user