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
+6
View File
@@ -646,6 +646,12 @@ public final class BannerCenter {
// the read that preceded the flip learned its title, so a body write that refused says
// *which* card refused it a card window is not always the frontmost thing on screen.
if let title { "Couldn't tick the checkbox in '\(title)'" } else { "Couldn't tick the checkbox" }
case let .editBody(title):
// **Save**, because that is the word for what just failed: the EditPreview flip is the
// effective Save button (05-card-window.md Edit), and the debounced tick is the same
// act happening on its own. The keystrokes are still in the buffer the banner says the
// app could not put them on disk, not that they are gone.
if let title { "Couldn't save '\(title)'" } else { "Couldn't save the card" }
}
}
+105
View File
@@ -72,6 +72,41 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv
}
}
/// What became of a card-body save the card window's Edit buffer meeting disk
/// (05-card-window.md Edit; `BoardStore.writeCardBody(inCard:body:)`).
///
/// A returned value rather than a thrown error, because **four of the five cases are not failures**
/// and the caller's response to each differs: only `.written` and `.unchanged` mean the buffer may
/// stop being held dirty. Making them one enum is what keeps that decision in one `switch` rather
/// than spread across a `try?` and two guards.
public enum CardBodyWriteOutcome: Sendable, Equatable {
/// The bytes landed. The buffer matches disk; the echoing reload is now on its way.
case written
/// **Nothing to write** the body on disk already reads exactly like the buffer. The three-gate
/// write rule's outcome (05 Write rules: untouched, reverted, or the echo of an external
/// edit), and as good as `.written` from the buffer's point of view: disk says what the user
/// means it to say, and nothing was re-serialized to make that true.
case unchanged
/// The board is locked read-only, so the save is **suspended, not failed** (02-architecture.md §
/// the lock's scope: "editor buffers kept but their debounced saves suspended"). The buffer stays
/// dirty, the standing lock row already explains why, and nothing is posted a banner per
/// suppressed tick would bury the row that matters under echoes of itself.
case suspended(ReadOnlyLockReason)
/// The card is not in this board's tree at all any more hard-deleted in Finder, or moved to
/// another board. **Not a failure either**: there is nowhere for the text to land, which is 05
/// Deletion & lifecycle's own answer ("A card hard-deleted externally (folder gone) discards
/// both nowhere left to write"). A *tombstoned* card is not this case; it is still on disk and
/// is written to.
case vanished
/// The write was attempted and failed. The banner has already been posted by `performWrite`; the
/// buffer must stay dirty, and a close standing on it is `DirtyBufferGuard`'s modal moment.
case failed(BoardWriteError)
}
/// What a cross-board drop is doing to the items it carries the **effective** operation the
/// locality model resolved (04-interactions.md Drag and drop, settled).
///
@@ -1157,6 +1192,76 @@ public final class BoardStore {
}
}
// MARK: - Card body
/// Saves a card window's Edit buffer the debounced tick, the flush that leaves Edit, and the
/// flush that closes the window (05-card-window.md Edit).
///
/// An ordinary store write in every mechanical respect: one `performWrite` bracket, so the churn
/// rounds back as a single app-mediated reload (and, on git boards, sits inside the session's
/// one commit see `CardBodyEditSession` for that seam); the banner posts itself on failure;
/// the snapshot is never touched here, because the watcher's reload is what brings the text
/// back.
///
/// **It reports rather than swallows**, which is the one way it differs from every other write
/// in this file. `toggleTaskMarker` and its neighbours are one-shot gestures whose failure the
/// banner fully describes, so they `try?` and move on. This one has a *buffer* behind it: the
/// caller has to know whether the text landed, because on success it may stop holding it dirty
/// and on failure it must keep holding it the whole of "nothing is lost while the window stays
/// open" (02-architecture.md § Write-failure surfacing). Hence an outcome, not a `Void`.
///
/// **Tombstones are writable here, deliberately.** The folder is resolved by
/// `cardBodyTarget(_:in:)` a walk that does *not* skip tombstoned cards or lanes because 05
/// Deletion & lifecycle requires exactly that: "a dirty Edit buffer flushes into the
/// tombstoned card's folder before the window dismisses ... so the keystrokes survive Put Back".
/// The write is surgical (`BoardWriter.writeBody` replaces the body span and nothing else), so
/// the `deleted:` key it lands beside is left standing and the card is not resurrected.
public func writeCardBody(inCard cardID: ItemID, body: String) -> CardBodyWriteOutcome {
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return .vanished }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(target.cardID.rawValue, isDirectory: true)
do {
// The closure's signature is spelled out because it returns a value the inference wart
// `performWrite`'s doc comment records.
let wrote = try performWrite { () throws(BoardWriteError) -> Bool in
try BoardWriter.writeBody(inItemFolder: folder, body: body)
}
return wrote ? .written : .unchanged
} 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 body: \(String(describing: error), privacy: .public)")
return .unchanged
}
}
/// Which folder a card's body write lands in **the one card walk that ignores liveness**.
///
/// Every other resolution in this file goes through `liveItem`, whose ancestor-walked liveness is
/// what keeps gestures off vanished targets. This one deliberately does not: the card window's
/// dismissal flush has to reach a card that was tombstoned *out from under the buffer* (05
/// Deletion & lifecycle), and to `liveItem` that card is already gone. A card whose folder is
/// genuinely no longer in the tree hard-deleted, or moved to another board still resolves to
/// `nil`, which is the case 05 answers with "nowhere left to write".
nonisolated static func cardBodyTarget(
_ id: ItemID,
in snapshot: BoardModel
) -> (laneID: ItemID, cardID: ItemID)? {
for lane in snapshot.lanes {
if let card = lane.cards.first(where: { $0.id == id }) {
return (laneID: lane.id, cardID: card.id)
}
}
return nil
}
// MARK: - Board rename
/// Writes the board's own `title` the board popover's rename field (03-board-ui.md § Board