From c27cc93ec1dacf510711d8f713a2a2cc17f799af Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 10:22:12 -0400 Subject: [PATCH] =?UTF-8?q?The=20card=20window's=20title=20learns=20Edit?= =?UTF-8?q?=20mode=20=E2=80=94=20a=20sibling=20session=20rides=20the=20bod?= =?UTF-8?q?y's=20own=20doors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:on:) 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 --- Kanban/App/CardWindowHost.swift | 47 ++- Kanban/LiveStore/BoardStore.swift | 83 +++++ Kanban/UI/Card/CardTitleEditSession.swift | 257 +++++++++++++ Kanban/UI/Card/CardWindowView.swift | 79 +++- KanbanTests/CardSessionUndoTests.swift | 77 ++++ KanbanTests/CardTitleEditSessionTests.swift | 394 ++++++++++++++++++++ KanbanTests/CardTitleWriteTests.swift | 237 ++++++++++++ KanbanTests/RawSourceTests.swift | 14 +- 8 files changed, 1164 insertions(+), 24 deletions(-) create mode 100644 Kanban/UI/Card/CardTitleEditSession.swift create mode 100644 KanbanTests/CardTitleEditSessionTests.swift create mode 100644 KanbanTests/CardTitleWriteTests.swift diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 3288948..2d6c650 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -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 } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 659d3f8..3db3450 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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. /// diff --git a/Kanban/UI/Card/CardTitleEditSession.swift b/Kanban/UI/Card/CardTitleEditSession.swift new file mode 100644 index 0000000..7d5942d --- /dev/null +++ b/Kanban/UI/Card/CardTitleEditSession.swift @@ -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? + + /// 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 + } +} diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index 7e7f24c..ec7daba 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -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. /// diff --git a/KanbanTests/CardSessionUndoTests.swift b/KanbanTests/CardSessionUndoTests.swift index 58b6aaf..18b2d75 100644 --- a/KanbanTests/CardSessionUndoTests.swift +++ b/KanbanTests/CardSessionUndoTests.swift @@ -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") + } +} diff --git a/KanbanTests/CardTitleEditSessionTests.swift b/KanbanTests/CardTitleEditSessionTests.swift new file mode 100644 index 0000000..268672a --- /dev/null +++ b/KanbanTests/CardTitleEditSessionTests.swift @@ -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) + } +} diff --git a/KanbanTests/CardTitleWriteTests.swift b/KanbanTests/CardTitleWriteTests.swift new file mode 100644 index 0000000..e8a79ca --- /dev/null +++ b/KanbanTests/CardTitleWriteTests.swift @@ -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") + } +} diff --git a/KanbanTests/RawSourceTests.swift b/KanbanTests/RawSourceTests.swift index 99e9acf..5d77f77 100644 --- a/KanbanTests/RawSourceTests.swift +++ b/KanbanTests/RawSourceTests.swift @@ -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) }