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:
@@ -59,6 +59,16 @@ public final class CardBodyPresentation {
|
||||
/// exactly when ⌘F has nothing to find in.
|
||||
public var findInText: (() -> Void)?
|
||||
|
||||
/// Flushes the Edit buffer — **"leaving Edit flushes the debounce"** (05 ▸ Mode grammar), and
|
||||
/// the reason the flip goes through `setMode(_:)` rather than being three separate assignments.
|
||||
///
|
||||
/// Filled in by the window with its edit session's `endEditSession()`. It hangs here rather than
|
||||
/// on the session because *this* is the type every path that leaves Edit already holds: the menu
|
||||
/// item's toggle, Escape in the editor, and Return in Preview all flip the mode through one
|
||||
/// object, so attaching the flush to the flip is what makes "always" true by construction rather
|
||||
/// than by three call sites remembering.
|
||||
public var flushEdits: (() -> Void)?
|
||||
|
||||
/// Whether the opening rule has already run for this window.
|
||||
///
|
||||
/// **Once, not per snapshot.** The rule is about *opening* a card, and the body it judges
|
||||
@@ -80,7 +90,59 @@ public final class CardBodyPresentation {
|
||||
|
||||
/// ⌘E, Return in Preview, Escape in Edit — see `CardBodyMode.toggled`.
|
||||
public func toggleMode() {
|
||||
mode = mode.toggled
|
||||
setMode(mode.toggled)
|
||||
}
|
||||
|
||||
/// The one place the mode changes, and therefore the one place **leaving Edit flushes** (05 ▸
|
||||
/// Mode grammar: "Leaving Edit flushes the debounce (mode flip, raw-source entry, window close)
|
||||
/// — the preview never lags the text that produced it, and neither does disk").
|
||||
///
|
||||
/// The flush runs *before* the flip, not after: Preview reads the same buffer the editor was
|
||||
/// writing, so a flip that rendered first and saved second would be indistinguishable on screen
|
||||
/// — but a failure in that order would leave the user reading text the app had just failed to
|
||||
/// save, with the mode already changed under them. Saving first means the banner (and, on a
|
||||
/// close, the modal) arrives while the editor is still the thing on screen.
|
||||
///
|
||||
/// Setting the mode it already has does nothing at all, which is what keeps a redundant
|
||||
/// menu-item validation pass or a re-published focus value from flushing an untouched buffer.
|
||||
public func setMode(_ newMode: CardBodyMode) {
|
||||
guard newMode != mode else { return }
|
||||
if mode == .edit { flushEdits?() }
|
||||
mode = newMode
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View ▸ Edit Body
|
||||
|
||||
/// View ▸ Edit Body (⌘E) — the body column's mode toggle, with checkmark state (11-command-nexus.md;
|
||||
/// 05-card-window.md ▸ Mode grammar).
|
||||
///
|
||||
/// **A `Toggle`, because the row is a checkmark row**: 11 files it as "(checkmark toggle)", and
|
||||
/// 04-interactions.md ▸ Configurable bindings requires that such a row keep "one stable title,
|
||||
/// checkmark state only" — so the title is the same string it was while the row was disabled, and
|
||||
/// what changed with this milestone is the validation and the action, exactly as `FutureCommands`
|
||||
/// predicts.
|
||||
///
|
||||
/// Validation is scope: with no card window in front there is no `cardBody` focused value, and the
|
||||
/// row disables. The read-only lock is deliberately **not** part of it — entering Edit is not a
|
||||
/// mutation, and 02-architecture.md § the lock's scope keeps editor buffers alive under the lock
|
||||
/// (only their saves suspend), so a locked board can still be read in the editor and its text
|
||||
/// copied out.
|
||||
///
|
||||
// m6-raw-source: "View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar
|
||||
// item" (05 ▸ Raw source outlet). That is one more clause on `isDisabled` once a window-level raw
|
||||
// mode exists to read; the row, its title and its chord do not move.
|
||||
struct EditBodyCommand: View {
|
||||
|
||||
@FocusedValue(\.cardBody) private var cardBody
|
||||
|
||||
var body: some View {
|
||||
Toggle("Edit Body", isOn: Binding(
|
||||
get: { cardBody?.mode == .edit },
|
||||
set: { isOn in cardBody?.setMode(isOn ? .edit : .preview) }
|
||||
))
|
||||
.keyboardShortcut("e", modifiers: .command)
|
||||
.disabled(cardBody == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user