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
+338
View File
@@ -0,0 +1,338 @@
import Foundation
import Testing
@testable import Kanban
/// The Edit buffer's state machine (05-card-window.md Edit, Write rules): the three write gates,
/// the ~700 ms debounce, and dirty-buffer-wins.
///
/// The session is deliberately a buffer and a clock with a *closure* for its destination, which is
/// what lets this suite be about the rules rather than about files: the fake below records every
/// save it is asked for, so "writes nothing" is an assertion about a count rather than an inference
/// from an `mtime`. The bytes those saves put on disk are `BodyWriteTests`'.
// MARK: - The fake destination
/// A stand-in for `BoardStore.writeCardBody`, recording what it was asked to write and answering
/// with whatever outcome the test wants.
@MainActor
private final class SaveSpy {
private(set) var written: [String] = []
var outcome: CardBodyWriteOutcome = .written
var count: Int { written.count }
var last: String? { written.last }
func save(_ text: String) -> CardBodyWriteOutcome {
written.append(text)
return outcome
}
}
@MainActor
private func makeSession(_ spy: SaveSpy, body: String = "original\n") -> CardBodyEditSession {
let session = CardBodyEditSession()
// Fast enough that a test never waits on the real 700 ms, slow enough that a keystroke arriving
// right after another can still cancel it the `DragSession.holdTimeout` precedent.
session.debounceInterval = .milliseconds(30)
session.save = { [spy] text in spy.save(text) }
session.adopt(diskBody: body)
return session
}
/// Polls until `condition` holds or the deadline passes the suite's shape for anything the
/// debounce has to actually elapse for.
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while !condition() {
guard ContinuousClock.now - start < deadline else { return }
try? await Task.sleep(for: .milliseconds(5))
}
}
// MARK: - The three gates
@MainActor
@Suite("Card body ▸ the write gates")
struct CardBodyWriteGateTests {
@Test("An untouched session writes nothing, ever")
func anUntouchedSessionWritesNothing() async {
let spy = SaveSpy()
let session = makeSession(spy)
// Opened, read, and left the flush that leaving Edit performs, on a buffer nobody typed
// into. "An untouched body is never rewritten" (05 Write rules).
#expect(session.flush() == .unchanged)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
#expect(session.saveAttempts == 0)
}
@Test("A typed-then-reverted edit writes nothing, and leaves no timer standing")
func aRevertedEditWritesNothing() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("original\nand more")
#expect(session.isDirty)
// Undone before the debounce could fire.
session.edited("original\n")
#expect(!session.isDirty)
// The pending save was cancelled by the revert rather than firing on a no-op a write that
// landed here would stamp `modified` and mint a commit for nothing.
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
#expect(session.flush() == .unchanged)
#expect(spy.count == 0)
}
@Test("The echo of the app's own save is not written back")
func anEchoIsNotWrittenBack() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("typed\n")
#expect(session.flush() == .written)
#expect(spy.written == ["typed\n"])
// The watcher's reload arrives carrying what we just wrote. The buffer is clean against it,
// so nothing is written back which is what stops a save ringing forever round the one-way
// flow.
session.adopt(diskBody: "typed\n")
#expect(!session.isDirty)
#expect(session.flush() == .unchanged)
await waitUntil { spy.count > 1 }
#expect(spy.count == 1)
}
@Test("An external edit under a clean buffer is not written back either")
func aForeignEditUnderACleanBufferIsNotWrittenBack() async {
let spy = SaveSpy()
let session = makeSession(spy)
// Somebody else rewrote the file while the window sat there reading it. "A clean buffer
// follows disk" (05 Write rules) and following disk is not a reason to write to it.
session.adopt(diskBody: "theirs\n")
#expect(session.text == "theirs\n")
#expect(!session.isDirty)
#expect(session.flush() == .unchanged)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
}
// MARK: - Dirty-buffer-wins
@MainActor
@Suite("Card body ▸ dirty-buffer-wins")
struct CardBodyDirtyBufferTests {
@Test("A snapshot never reloads a dirty buffer under the cursor")
func aDirtyBufferKeepsItsText() {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("mine, unsaved\n")
// The board, Preview and every other window take the new snapshot; this buffer does not.
session.adopt(diskBody: "theirs\n")
#expect(session.text == "mine, unsaved\n")
#expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
#expect(session.isDirty)
}
@Test("The buffer's own save then lands over the foreign edit — last writer wins")
func theFlushOverwritesTheForeignEdit() {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("mine, unsaved\n")
session.adopt(diskBody: "theirs\n")
#expect(session.flush() == .written)
#expect(spy.written == ["mine, unsaved\n"], "deliberate last-writer-wins (05 ▸ Write rules)")
#expect(!session.isDirty)
}
@Test("A foreign edit that happens to match the buffer settles it clean, writing nothing")
func aConvergentForeignEditNeedsNoWrite() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("same text\n")
// An agent wrote exactly what the user was typing. The file already says what they mean, so
// re-writing it would only stamp `modified`.
session.adopt(diskBody: "same text\n")
#expect(!session.isDirty)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A failed save keeps the buffer dirty, and the text")
func aFailureKeepsTheText() {
let spy = SaveSpy()
let session = makeSession(spy)
spy.outcome = .failed(BoardWriteError(
operation: .editBody(title: "Notes"),
path: "/x/index.md",
reason: .io(message: "the disk is full")
))
session.edited("precious\n")
let outcome = session.flush()
guard case .failed = outcome else {
Issue.record("expected the failure to be reported, got \(outcome)")
return
}
// "Nothing is lost while the window stays open" (02 § Write-failure surfacing): the text is
// still here, still dirty, and the next flush will try again.
#expect(session.text == "precious\n")
#expect(session.isDirty)
}
@Test("A suspended save — the read-only lock — also keeps the buffer, and does not throw a close")
func aSuspendedSaveKeepsTheBuffer() throws {
let spy = SaveSpy()
let session = makeSession(spy)
spy.outcome = .suspended(.unwritableLocation)
session.edited("held\n")
#expect(session.flush() == .suspended(.unwritableLocation))
#expect(session.isDirty)
// The close-time guard treats it as a non-failure: no write was attempted, the lock row has
// been standing all along, and a modal offering Try Again could only fail again.
try session.flushOrThrow()
}
@Test("A real failure is what the close-time modal is raised on")
func aFailureThrowsForTheGuard() {
let spy = SaveSpy()
let session = makeSession(spy)
spy.outcome = .failed(BoardWriteError(operation: .editBody(title: nil), path: "/x", reason: .io(message: "nope")))
session.edited("unsaved\n")
#expect(throws: BoardWriteError.self) { try session.flushOrThrow() }
}
@Test("A session with nowhere to write keeps its text rather than reporting success")
func aSessionWithNoDestinationHoldsOn() {
let session = CardBodyEditSession()
session.adopt(diskBody: "start\n")
session.edited("typed\n")
#expect(session.flush() == .vanished)
#expect(session.isDirty)
#expect(session.text == "typed\n")
}
}
// MARK: - The debounce
@MainActor
@Suite("Card body ▸ the debounce")
struct CardBodyDebounceTests {
@Test("Typing saves once the keystrokes stop")
func typingSavesAfterTheInterval() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("t\n")
#expect(spy.count == 0, "not on the keystroke itself")
await waitUntil { spy.count == 1 }
#expect(spy.written == ["t\n"])
#expect(!session.isDirty)
}
@Test("A burst of keystrokes is one save, of the last text")
func aBurstCoalesces() async {
let spy = SaveSpy()
let session = makeSession(spy)
for text in ["a", "ab", "abc", "abcd"] {
session.edited(text)
}
await waitUntil { spy.count >= 1 }
// Trailing debounce: each keystroke restarts the clock, so the run costs one write rather
// than one per character.
#expect(spy.written == ["abcd"])
}
@Test("A flush does not wait for the debounce, and the debounce does not fire behind it")
func aFlushPreemptsThePendingSave() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("typed\n")
#expect(session.flush() == .written)
#expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)")
// And the timer it cancelled does not come back to write the same text a second time.
await waitUntil { spy.count > 1 }
#expect(spy.count == 1)
}
@Test("The production interval is ~700 ms")
func theDefaultIntervalIsTheDesignsNumber() {
// The only thing the seam must not do is quietly change the number the design settled
// (05 Edit: "Saved on a ~700 ms debounce").
#expect(CardBodyEditSession().debounceInterval == .milliseconds(700))
}
@Test("Ending the session flushes — the Edit→Preview flip is the effective Save")
func endingTheSessionFlushes() {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("typed on the way out\n")
#expect(session.endEditSession() == .written)
#expect(spy.written == ["typed on the way out\n"])
}
}
// MARK: - The mode flip
@MainActor
@Suite("Card body ▸ leaving Edit flushes")
struct CardBodyModeFlushTests {
@Test("Every way out of Edit flushes first")
func leavingEditFlushes() {
let presentation = CardBodyPresentation()
var flushes = 0
presentation.flushEdits = { flushes += 1 }
presentation.openIfNeeded(body: "")
#expect(presentation.mode == .edit)
// E / the menu item's toggle
presentation.toggleMode()
#expect(presentation.mode == .preview)
#expect(flushes == 1)
// Escape in the editor, which is the same flip through the same door
presentation.setMode(.edit)
#expect(flushes == 1, "entering Edit flushes nothing — there is nothing to flush yet")
presentation.setMode(.preview)
#expect(flushes == 2)
}
@Test("Setting the mode it already has flushes nothing")
func aRedundantSetIsNotAFlush() {
let presentation = CardBodyPresentation()
var flushes = 0
presentation.flushEdits = { flushes += 1 }
presentation.setMode(.edit)
presentation.setMode(.edit)
presentation.setMode(.edit)
#expect(flushes == 0, "a re-published focus value must not commit an untouched buffer")
}
}