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
258 lines
12 KiB
Swift
258 lines
12 KiB
Swift
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
|
|
}
|
|
}
|