Build Edit mode with debounced, byte-honest saves

The editing surface: the same hosted TextKit-1 text view gains an
editable branch with a per-keystroke line-scanner highlighter — chosen
over a parser re-parse because a mid-typing buffer is usually invalid
Markdown and 05 wants the delimiters themselves dimmed; apply only sets
attributes, so presentation-never-transforms is structural. Saves ride
a ~700ms injectable debounce through BoardWriter.writeBody —
toggleTaskMarker's idiom widened to the body span, frontmatter bytes
untouched, refusing to write when disk already holds that body, which
enforces all three gates (untouched, reverted, echo) at the layer that
owns the bytes with one isDirty predicate above it. Mode grammar lands
whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape
returns, and every flip flushes first; window close flushes through
the existing retry/save-copy/discard modal, and the dismissal flush
deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always
follows the snapshot, the buffer only when clean, both surfaces render
the buffer. Undo is the editor's own session-scoped NSUndoManager;
endEditSession names the pro-m1 one-commit-per-session boundary.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 10:46:02 -04:00
parent 6dc84176fb
commit e989c1f26e
16 changed files with 2195 additions and 78 deletions
+20 -27
View File
@@ -8,9 +8,9 @@ import SwiftUI
/// ### 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
/// renderings of what the loader already knows the card's title, its created/modified line, and
/// its body as plain text. Everything that reads or writes beyond that is later work and is marked
/// where it lands:
/// 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`). Everything that
/// reads or writes beyond that is later work and is marked where it lands:
///
/// - the title as an editable field (commit on Return / focus loss, Escape abandons),
/// - the raw-source outlet,
@@ -43,6 +43,10 @@ struct CardWindowView: View {
let cardFolder: URL?
/// This window's body-column state: which mode it is in, and the find-bar hook.
let bodyPresentation: CardBodyPresentation
/// This window's Edit buffer. It holds the text **both** surfaces show: the editor writes into
/// 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
/// Whether a checkbox may write `false` under the board's read-only lock.
let isEditable: Bool
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
@@ -95,45 +99,34 @@ struct CardWindowView: View {
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
if bodyPresentation.mode == .edit {
editPlaceholderNotice
}
CardBodySurface(
body: card.body,
// The session's text, never `card.body` directly: a dirty buffer outranks the
// snapshot (05 Write rules) and a flushed one is ahead of it by a reload, so the
// buffer is the truer of the two in both modes which is also how Preview shows the
// text that produced it the instant Edit is left.
body: bodySession.text,
mode: bodyPresentation.mode,
cardFolder: cardFolder,
presentation: bodyPresentation,
session: bodySession,
isTaskToggleEnabled: isEditable,
onToggleTask: onToggleTask
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// **Dirty-buffer-wins, applied on every snapshot** (05 Write rules): the session takes
// disk's word for what the file says, and takes it into the editor only when the buffer has
// nothing unsaved. `initial: true` is also how the buffer is filled at all a window opens
// by adopting its card's body.
.onChange(of: card.body, initial: true) { _, body in
bodySession.adopt(diskBody: body)
}
// **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 Edit mode's honest placeholder.
///
/// **The mode is real; the editor is not.** 05's opening rule is not a rendering detail that can
/// wait it decides which surface a brand-new card lands on so this milestone implements the
/// *state* (`CardBodyMode`, the opening rule, the toggle) and leaves the editor itself to the
/// Edit card. What shows meanwhile is the raw Markdown, monospaced and read-only, over a line
/// that says so: a text view that looked editable and silently discarded keystrokes would be a
/// worse lie than an empty pane, and one that saved would be this milestone building the thing
/// it deliberately is not building.
private var editPlaceholderNotice: some View {
Text("Body editing arrives with the Edit surface — this is the raw Markdown, read-only.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
.padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: bodyPointSize))
.background(.background.secondary)
}
/// "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.
///