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:
@@ -680,8 +680,13 @@ public final class AppModel {
|
||||
storeFlush: { [weak self] in
|
||||
await self?.sessions[ref]?.store.awaitQuiescence()
|
||||
},
|
||||
// editorFlush / committerFlush stay nil until m6 and m7 have something to flush; the
|
||||
// slots exist so their order is already decided when they do.
|
||||
// `editorFlush` stays nil, and now deliberately rather than for want of an editor: the
|
||||
// card windows' debounced body saves flush in **step 1**, inside each window's
|
||||
// `endSession()` (`CardWindowSession`), which is both earlier than this slot and where
|
||||
// 02-architecture.md puts them ("each open Edit session ends with its normal session
|
||||
// commit", then pending work). The slot stays for a board-level editor with no card
|
||||
// window of its own — the raw-source buffer is the candidate — so that the order
|
||||
// relative to `committerFlush` (m7) is already decided when one arrives.
|
||||
recordClose: { [weak self] in
|
||||
guard let self, let session = sessions[ref] else { return }
|
||||
let counts = Self.liveCounts(of: session.store.snapshot)
|
||||
|
||||
+134
-12
@@ -29,27 +29,67 @@ public enum CardWindowFate: Equatable {
|
||||
|
||||
// MARK: - The session seam
|
||||
|
||||
/// A card window's editor session — still a no-op stand-in for the thing 05-card-window.md will
|
||||
/// build, and deliberately so: **the shell has no Edit session to flush yet.**
|
||||
/// A card window's editor session: the thing the close flush ends, and now the thing it ends *with
|
||||
/// something in it* — the window's Edit buffer (05-card-window.md ▸ Edit).
|
||||
///
|
||||
/// It exists so the close flush has something real to call and something real to be *ordered against*
|
||||
/// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending
|
||||
/// twice does nothing the second time, which matters because two paths legitimately end a session —
|
||||
/// the board's close flush drives it for every card window, and a card window closed on its own runs
|
||||
/// it from its disappear.
|
||||
/// The ordering it exists for is unchanged (see `CardSessionFlushing`): the board's close flush
|
||||
/// drives `endSession()` for every card window before any of the board's own pending work is
|
||||
/// flushed, and a card window closed on its own runs it from its disappear. Ending twice does
|
||||
/// nothing the second time, which is what makes those two paths safe to both exist.
|
||||
///
|
||||
/// **What ending means now**: the Edit buffer's debounce is cancelled and its text written — the
|
||||
/// "window close" third of 05's flush rule, and on a dismissal caused by a tombstone the surgical
|
||||
/// body write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the tombstoned
|
||||
/// card's folder before the window dismisses ... so the keystrokes survive Put Back").
|
||||
/// `BoardStore.writeCardBody` resolves tombstoned cards on purpose for exactly this.
|
||||
///
|
||||
/// A *failing* close flush is not this object's problem to solve: it is `DirtyBufferGuard`'s modal
|
||||
/// moment, which the host runs earlier, on `windowShouldClose`, while there is still a window to
|
||||
/// present over. By the time this runs on a window that is genuinely going away, the honest thing
|
||||
/// left to do is try.
|
||||
@MainActor
|
||||
final class CardWindowSession: CardSessionFlushing {
|
||||
|
||||
/// The window's Edit buffer. Created with the window and handed its save target once the window
|
||||
/// has joined its board (`CardWindowHost.start()`); a session with no target keeps its text
|
||||
/// rather than pretending to have written it.
|
||||
let body: CardBodyEditSession
|
||||
|
||||
/// The close-time save-or-lose moment, over this window's buffer (02-architecture.md §
|
||||
/// Write-failure surfacing: "the one modal moment on the write-failure path").
|
||||
let bufferGuard: DirtyBufferGuard
|
||||
|
||||
private var hasEnded = false
|
||||
|
||||
/// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
|
||||
/// and a stored property cannot be referenced from another's initializer.
|
||||
///
|
||||
/// (They are also `let` rather than `lazy var` for a SwiftUI reason worth recording: `@State`
|
||||
/// projects a `Binding` through dynamic member lookup for every *settable* property of its
|
||||
/// value, so a `lazy var` here would make `session.bufferGuard` at the call site resolve to a
|
||||
/// binding rather than to the guard.)
|
||||
init() {
|
||||
let body = CardBodyEditSession()
|
||||
self.body = body
|
||||
bufferGuard = DirtyBufferGuard(
|
||||
attemptSave: { () throws(BoardWriteError) -> Void in try body.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.
|
||||
writeCopy: { url in try Data(body.text.utf8).write(to: url) }
|
||||
)
|
||||
}
|
||||
|
||||
func endSession() async {
|
||||
guard !hasEnded else { return }
|
||||
hasEnded = true
|
||||
// m6-card-body: commit the open Edit session here (06-history-undo.md's session
|
||||
// granularity), flushing the debounced body save first — and, on a dismissal caused by a
|
||||
// tombstone, the surgical body write 05 ▸ Deletion & lifecycle promises ("dismissal never
|
||||
// eats typed work silently where a save can land"). Nothing exists to flush until the
|
||||
// editor does; the hook's *position* in the sequence is what this milestone pins.
|
||||
// pro-m1: this is the boundary the auto-committer coalesces on — one commit per Edit
|
||||
// session, "never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). The debounced
|
||||
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
|
||||
// the committer's own debounce outlives them and this call is where the session is known to
|
||||
// be over.
|
||||
body.endEditSession()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +132,9 @@ struct CardWindowHost: View {
|
||||
/// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches
|
||||
/// through the focus system (`CardBodyPresentation`).
|
||||
@State private var bodyPresentation = CardBodyPresentation()
|
||||
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
|
||||
/// flush; cleared by the resolution that lets the close resume.
|
||||
@State private var isClosePending = false
|
||||
|
||||
private enum Phase {
|
||||
case opening
|
||||
@@ -156,11 +199,23 @@ struct CardWindowHost: View {
|
||||
// item reaches the frontmost one's body surface through this, exactly as board-window
|
||||
// items reach their window's store (`FocusedBoardStoreKey`).
|
||||
.focusedSceneValue(\.cardBody, bodyPresentation)
|
||||
// The one modal moment (02-architecture.md § Write-failure surfacing), presented over
|
||||
// the window whose close it is holding up — which is why it hangs here and not on the
|
||||
// board: the text being saved is this window's.
|
||||
.dirtyBufferAlert(session.bufferGuard) { Self.copyDestination(named: windowTitle) }
|
||||
.task { start() }
|
||||
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
|
||||
guard dismisses else { return }
|
||||
dismissWindow(id: WindowID.card, value: ref)
|
||||
}
|
||||
// The modal resolved — by a retry that landed, a copy saved elsewhere, or a knowing
|
||||
// discard — so the close it was holding may finish. `DirtyBufferGuard` has no fourth
|
||||
// "leave it open" branch by design, so reaching `.idle` always means the close resumes.
|
||||
.onChange(of: session.bufferGuard.phase) { _, phase in
|
||||
guard isClosePending, phase == .idle else { return }
|
||||
isClosePending = false
|
||||
windowController.closeAfterFlush()
|
||||
}
|
||||
.onDisappear { finish() }
|
||||
}
|
||||
|
||||
@@ -175,6 +230,7 @@ struct CardWindowHost: View {
|
||||
card: placement.card,
|
||||
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
||||
bodyPresentation: bodyPresentation,
|
||||
bodySession: session.body,
|
||||
// "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
|
||||
// the store's own lock is the whole predicate.
|
||||
@@ -267,9 +323,31 @@ struct CardWindowHost: View {
|
||||
|
||||
appModel.registerCardWindow(ref, session: session)
|
||||
phase = .open(store)
|
||||
configureSession(store: store)
|
||||
configureWindow()
|
||||
}
|
||||
|
||||
/// Points this window's Edit buffer at its card, and the mode flip at the buffer.
|
||||
///
|
||||
/// Both are seams the two types deliberately leave open (`CardBodyEditSession.save`,
|
||||
/// `CardBodyPresentation.flushEdits`) so that neither the buffer nor the mode has to know what a
|
||||
/// board is — this is the one place that knows both, which is also the only place that could
|
||||
/// wire them wrongly, and it is four lines long.
|
||||
///
|
||||
/// The store is captured **weakly**: it outlives this window by refcount, not by ownership, and a
|
||||
/// debounced save that fired after the board had gone should write nothing rather than resurrect
|
||||
/// a store the registry has released.
|
||||
private func configureSession(store: BoardStore) {
|
||||
let cardID = ref.cardIdentity
|
||||
session.body.save = { [weak store] text in
|
||||
guard let store else { return .vanished }
|
||||
return store.writeCardBody(inCard: cardID, body: text)
|
||||
}
|
||||
bodyPresentation.flushEdits = { [session] in
|
||||
session.body.endEditSession()
|
||||
}
|
||||
}
|
||||
|
||||
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
|
||||
/// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
|
||||
/// card across relaunch where state restoration allows").
|
||||
@@ -314,6 +392,14 @@ struct CardWindowHost: View {
|
||||
windowController.onAttach?(window)
|
||||
}
|
||||
|
||||
// **The close flushes first** (05-card-window.md ▸ Edit: "flushed on leaving Edit, entering
|
||||
// source mode, and window close"). Intercepting `windowShouldClose` rather than saving from
|
||||
// `onDisappear` is what makes the failure case possible at all: by the time a window has
|
||||
// disappeared there is nothing left to present a modal over, and the design's one modal
|
||||
// moment is precisely a close that could not save (02-architecture.md § Write-failure
|
||||
// surfacing).
|
||||
windowController.onCloseRequested = { closeAfterFlushing() }
|
||||
|
||||
windowController.onFrameChanged = { frame in
|
||||
if let recordID {
|
||||
appModel.boardRegistry.updateCardWindowFrame(
|
||||
@@ -331,6 +417,42 @@ struct CardWindowHost: View {
|
||||
|
||||
// MARK: - Closing
|
||||
|
||||
/// The close, held open exactly as long as the buffer needs.
|
||||
///
|
||||
/// A clean buffer closes immediately — which is every window that was only read, and every
|
||||
/// window whose last keystroke was more than the debounce ago. A dirty one is flushed through
|
||||
/// `DirtyBufferGuard`, and only a genuine write *failure* stops the close: a suspended save
|
||||
/// (read-only lock) and a vanished card do not, because neither has anywhere for the text to
|
||||
/// 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 {
|
||||
windowController.closeAfterFlush()
|
||||
return
|
||||
}
|
||||
if session.bufferGuard.beginClose() {
|
||||
windowController.closeAfterFlush()
|
||||
} else {
|
||||
// The alert is presenting; the close resumes from the phase change, above.
|
||||
isClosePending = true
|
||||
}
|
||||
}
|
||||
|
||||
/// The save panel behind the modal's "Save a Copy…". Pre-filled with the card's name and a `.md`
|
||||
/// extension, because what it writes is the Markdown body the user was typing — not the card,
|
||||
/// which cannot exist outside a board.
|
||||
private static func copyDestination(named title: String) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "\(title.isEmpty ? "Untitled" : title).md"
|
||||
panel.canCreateDirectories = true
|
||||
panel.isExtensionHidden = false
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.prompt = "Save"
|
||||
panel.message = "Choose where to keep these changes."
|
||||
guard panel.runModal() == .OK else { return nil }
|
||||
return panel.url
|
||||
}
|
||||
|
||||
/// Leaves the session and lets the store go.
|
||||
///
|
||||
/// The release rides **behind** the session's end rather than beside it: a session that has
|
||||
|
||||
@@ -108,14 +108,20 @@ struct FindSteppingCommands: View {
|
||||
/// View ▸ Edit Body (⌘E) / Raw Source (⌥⌘E) / History — the card window's three view-state rows
|
||||
/// (11-command-nexus.md).
|
||||
///
|
||||
// m6-card-window: Edit Body and Raw Source are checkmark toggles reading the window's edit-mode
|
||||
// state ("Edit Body disables while Raw Source is active" — 05-card-window.md); History is a plain
|
||||
// command that focuses the sidebar's History section and disables outright on mode `none` /
|
||||
// repo-nested boards once that section exists (05-card-window.md, 07-sync-collab.md). All three are
|
||||
// unconditionally disabled here — there is no card-window mode state anywhere yet.
|
||||
/// **Edit Body is live** (`EditBodyCommand`, beside the focused value it reads): the body column's
|
||||
/// Preview/Edit toggle, checkmark state and all. Its diff was the one `FutureCommand` promises —
|
||||
/// the title and the chord did not move, the validation and the action filled in.
|
||||
///
|
||||
// m6-raw-source: Raw Source is the same shape one card later — a checkmark toggle over a
|
||||
// window-level mode, which also adds the "Edit Body disables while Raw Source is active" clause to
|
||||
// the row above (05-card-window.md).
|
||||
//
|
||||
// m6-card-sidebar: History is a plain command that focuses the sidebar's History section, and
|
||||
// disables outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
|
||||
// 07-sync-collab.md). Both remain unconditionally disabled here — neither surface exists yet.
|
||||
struct CardViewCommands: View {
|
||||
var body: some View {
|
||||
FutureToggleCommand(title: "Edit Body", key: "e", modifiers: .command)
|
||||
EditBodyCommand()
|
||||
FutureToggleCommand(title: "Raw Source", key: "e", modifiers: [.option, .command])
|
||||
FutureCommand(title: "History")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user