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:
@@ -1041,6 +1041,68 @@ public enum BoardWriter: Sendable {
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
// MARK: - Card body
|
||||
|
||||
/// Replaces an item's **body span** — everything after the frontmatter's closing delimiter — and
|
||||
/// leaves every frontmatter byte exactly as it was. The card window's Edit buffer landing on
|
||||
/// disk (05-card-window.md ▸ Edit: "Saved on a ~700 ms debounce; flushed on leaving Edit,
|
||||
/// entering source mode, and window close").
|
||||
///
|
||||
/// ### Byte-honest by the same construction as everything else here
|
||||
///
|
||||
/// `FrontmatterDocument` keeps the file's raw text and re-emits it as
|
||||
/// `openingDelimiter + spans + closingDelimiter + body`, so assigning `body` is *only* a
|
||||
/// replacement of the body span: unknown keys, their order, comments, blank lines and line
|
||||
/// endings above the delimiter are the same bytes they were, because nothing re-serialized them.
|
||||
/// That is `toggleTaskMarker`'s idiom exactly — this call is that one widened from a single
|
||||
/// character to the whole span, and it shares its four steps: read fresh from disk, refuse an
|
||||
/// uneditable frontmatter shape, edit, stamp `modified` and clear `modified-by`, replace
|
||||
/// atomically.
|
||||
///
|
||||
/// **The stamp is not optional and not a policy choice here**: a body rewrite *is* an `index.md`
|
||||
/// rewrite, and every app-mediated `index.md` rewrite stamps (01-storage-format.md §
|
||||
/// Frontmatter). The raw-source Apply is the one path that keeps a `modified-by`, and it does
|
||||
/// not come through here.
|
||||
///
|
||||
/// ### The gate, and why it lives in the Writer as well as in the session
|
||||
///
|
||||
/// **An untouched body is never re-serialized** (05 ▸ Write rules): if the text on disk already
|
||||
/// equals `body`, this returns `false` having opened the file and touched nothing — no stamp, no
|
||||
/// temp file, no rename, and therefore an untouched `mtime`. The card window's Edit session
|
||||
/// gates on the same comparison before it ever calls (its three gates: untouched, reverted, and
|
||||
/// the echo of an external edit), so in practice this one never fires; it is here because the
|
||||
/// guarantee is about *bytes on disk*, and the layer that owns the bytes is the layer that can
|
||||
/// promise it against every caller, including a future one.
|
||||
///
|
||||
/// **It is not a staleness check.** A body that changed under the buffer is written over
|
||||
/// deliberately — "dirty buffer wins ... deliberate last-writer-wins" (05 ▸ Write rules) — which
|
||||
/// is why nothing here compares against what the caller last saw. Only *equality* refuses, and
|
||||
/// equality refuses because the write would be a no-op that stamped `modified` anyway.
|
||||
///
|
||||
/// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
|
||||
@discardableResult
|
||||
public static func writeBody(inItemFolder folder: URL, body: String) throws(BoardWriteError) -> Bool {
|
||||
var operation = WriteOperation.editBody(title: nil)
|
||||
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
|
||||
// The same shape guard `toggleTaskMarker` leans on, for its reason: a board root's body is
|
||||
// its description and no editor in this app opens it, so only lanes and cards are reachable.
|
||||
try checkIsUUIDShaped(folder, operation: operation)
|
||||
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
var document = try readDocument(at: indexURL, operation: operation)
|
||||
operation = operation.withTitle(document.title.value)
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
guard document.body != body else { return false }
|
||||
|
||||
document.body = body
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
/// The one folder this app ever creates under a card — every other subfolder under
|
||||
@@ -1566,6 +1628,16 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// also the app's only *body* write, which is worth being able to see in a log at a glance.
|
||||
case toggleTask(title: String?)
|
||||
|
||||
/// The card window's Edit buffer being saved — the debounced tick, the flush that leaves Edit,
|
||||
/// and the flush that closes the window (05-card-window.md ▸ Edit).
|
||||
///
|
||||
/// Its own case beside `.toggleTask` rather than folded into it, on the vocabulary's standing
|
||||
/// reasoning: both write a body, but one is a checkbox the user ticked and the other is prose
|
||||
/// they typed, and a banner telling someone the app "couldn't tick the checkbox" after they
|
||||
/// wrote three paragraphs would name a gesture that never happened. `title` is the card's title
|
||||
/// as the read that preceded the write found it — the name on the window they are typing in.
|
||||
case editBody(title: String?)
|
||||
|
||||
/// Fills in the title once the Writer has read it off the document the operation is acting
|
||||
/// on — identity for the six cases with no title slot at all: `createBoard`/`createLane`/
|
||||
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
|
||||
@@ -1591,6 +1663,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .rename: .rename(title: title)
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .toggleTask: .toggleTask(title: title)
|
||||
case .editBody: .editBody(title: title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,6 +1693,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user