Every settled change on a git-mode board commits, debounced 2s past drag/typing churn, staged whole-root with .gitignore respected. GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2 pin SwiftGitX resolves — importable, not duplicated) for signature-capable commits; add-git's config materialization is gone, identity resolves at commit time (repo-local config, else derived default) per the 2026-07-31 ruling in 06. CommitAttribution classifies per file off EchoLedger receipts: user identity on app-mediated windows, Lanework External <[email protected]> on foreign, the modified-by refinement (<slug>@agents.lanework .invalid) when every foreign file agrees, heal-marked receipts split into their own commit — window split foreign → heal → user. Edit-session granularity: ~700ms saves stay uncommitted, staging excludes open session folders (closure-resolved so mid-session moves stage around the new location), session end nudges the debounce so each session lands exactly one body commit. Flush-before-overwrite gates on known-foreign windows and commits synchronously ahead of the write; close/quit flush the pipeline via CloseFlushCoordinator's committerFlush. index.lock backs off briefly then re-debounces silently; clean tree no-ops; genuine failures ride the standing history-suspension banner and retry next debounce. Abnormal repo states (detached HEAD, merge/rebase/cherry-pick in progress) hold the engine with a 15s re-check; unborn HEAD commits "Initial board state" whole-tree; dirty tree at open catches up through the same engine. Message seam (CommitMessageComposing) ships interim — the semantic composer is the next card. Discovery diffs HEAD against an in-memory index with rename detection (git status alone never pairs a bare mv), and a failed survey reads as "could not look", never "nothing changed". 46 new tests / 8 suites, all real repositories via bundled libgit2. 2240 tests / 383 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
301 lines
16 KiB
Swift
301 lines
16 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
// MARK: - CardBodyEditSession
|
|
|
|
/// One card window's Edit buffer: the text the user is typing, what disk last said, and the
|
|
/// debounced save between them (05-card-window.md ▸ Edit, ▸ Write rules).
|
|
///
|
|
/// ### One comparison is the whole write rule
|
|
///
|
|
/// 05 states three gates — "untouched → never re-serialized; reverted → not written; echo of an
|
|
/// external edit → not written back" — and they are three faces of a single predicate: **write if
|
|
/// and only if the buffer differs from what is on disk**.
|
|
///
|
|
/// - *Untouched*: the user opened Edit, read, and left. `text == disk`, so nothing is written and
|
|
/// the file stays byte-identical, `mtime` included.
|
|
/// - *Reverted*: they typed and undid it. The debounce is cancelled the moment `text` matches `disk`
|
|
/// again, so the timer that was going to write does not survive the revert.
|
|
/// - *Echo*: our own save lands, the watcher reloads, and the snapshot arrives carrying the text we
|
|
/// just wrote. `adopt(diskBody:)` moves `disk` to it, the buffer is already equal, and nothing is
|
|
/// written back — which is what stops a save from ringing forever through the one-way flow.
|
|
///
|
|
/// `BoardWriter.writeBody` re-checks the same equality against the bytes it reads fresh, so the
|
|
/// guarantee holds even against a caller that skipped this type. Belt and braces on purpose: this is
|
|
/// the promise a file-backed app cannot afford to get subtly wrong.
|
|
///
|
|
/// ### Dirty-buffer-wins, as one branch
|
|
///
|
|
/// "A dirty Edit buffer is never reloaded under the cursor: while the user has unsaved keystrokes,
|
|
/// watcher reloads update everything else (board, Preview, other windows) but leave the buffer
|
|
/// alone; the debounced save then writes it — deliberate last-writer-wins. A clean buffer follows
|
|
/// disk" (05 ▸ Write rules). That is `adopt(diskBody:)`'s single `if`: `disk` always follows the
|
|
/// snapshot, and `text` follows it only when the two agreed before the snapshot arrived.
|
|
///
|
|
/// Keeping `disk` current *even while dirty* is the deliberate half. It means "dirty" reads as
|
|
/// "differs from the file", not "differs from what the file said when I started" — so a foreign edit
|
|
/// that happens to arrive at the text the user typed lands the buffer clean and writes nothing,
|
|
/// rather than re-stamping a file that already says the right thing.
|
|
///
|
|
/// ### The undo and commit seams
|
|
///
|
|
/// ⌘Z is the *editor's* undo and lives in the text view (`CardBodySurface` gives it an
|
|
/// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other
|
|
/// half of 05 ▸ Edit's undo sentence: the **session**, whose end is the effective Save.
|
|
/// `endEditSession()` is that moment — the Edit→Preview flip, raw-source entry, or the window
|
|
/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because
|
|
/// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its
|
|
/// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save
|
|
/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). In the free tier there is no committer, so
|
|
/// the two calls do the same work today; the seam is what keeps them from having to be pulled apart
|
|
/// later.
|
|
@MainActor
|
|
@Observable
|
|
public final class CardBodyEditSession {
|
|
|
|
// MARK: State
|
|
|
|
/// What the editor is showing — and, once the window has opened, the truest text there is: it is
|
|
/// the buffer when the buffer is dirty and disk when it is not, which is precisely the order 05
|
|
/// settles. Preview renders it too, so "the preview never lags the text that produced it" needs
|
|
/// no separate mechanism.
|
|
public private(set) var text: String = ""
|
|
|
|
/// What the last snapshot said is on disk. The write gate's other half; never shown.
|
|
public private(set) var disk: String = ""
|
|
|
|
/// Whether the buffer holds keystrokes the file does not.
|
|
public var isDirty: Bool { text != disk }
|
|
|
|
/// **Whether an Edit session is open right now** — the body column is showing the editor.
|
|
///
|
|
/// The fact pro-m1's committer stages around: "a board change committing mid-session excludes
|
|
/// the session card's folder from staging, so a lane move never sweeps half-typed body text into
|
|
/// its commit" (06-history-undo.md ▸ Rules ▸ Auto-commit).
|
|
///
|
|
/// **Open, not dirty**, deliberately: an *open* session is what 06 names, and the exclusion has
|
|
/// to cover the moment between a landed ~700 ms save and the next keystroke — precisely when the
|
|
/// buffer is clean and the file holds a half-typed paragraph no commit should carry yet. A
|
|
/// session opened and never typed in costs one card folder its commits until the flip back, and
|
|
/// nothing else. (Recorded as a judgment call: 06 says "open Edit sessions", the branch-switch
|
|
/// rule qualifies its own gate with "unsaved keystrokes, or on-disk saves the session hasn't
|
|
/// committed", and the two readings differ only for the untouched session.)
|
|
public private(set) var isEditing = false
|
|
|
|
// MARK: Seams
|
|
|
|
/// The debounce interval — **~700 ms** (05 ▸ Edit), and settable so a test does not have to
|
|
/// spend it. `DragSession.holdTimeout`'s precedent: a production default on the property, and
|
|
/// the suite dialling it down to milliseconds.
|
|
@ObservationIgnored
|
|
public var debounceInterval: Duration = .milliseconds(700)
|
|
|
|
/// Where a save goes. Filled in by the window once it has a store and a card to aim at
|
|
/// (`CardWindowHost`), which is also why it is a closure rather than a store reference: this type
|
|
/// is a buffer and a clock, and it stays testable by having no idea what a board is.
|
|
///
|
|
/// `nil` is a session with nowhere to write — before the window has joined its board, and after
|
|
/// it has left. A flush then keeps the buffer dirty rather than reporting success.
|
|
@ObservationIgnored
|
|
public var save: ((String) -> CardBodyWriteOutcome)?
|
|
|
|
/// Where this session's **one undo step** goes, called at `endEditSession()` with the body disk
|
|
/// held when the session's first save landed and the body it holds now (13-native-undo.md
|
|
/// ▸ Rules: "an Edit session is one step, registered at the Edit→Preview flip").
|
|
///
|
|
/// A closure for `save`'s reason exactly — this type is a buffer and a clock, and it stays
|
|
/// testable by having no idea what a board or an undo stack is. `CardWindowHost` points it at
|
|
/// `BoardStore.registerBodyEdit(inCard:priorBody:newBody:)`, which builds the step; `nil` is a
|
|
/// session whose window has not joined its board, and registers nothing.
|
|
@ObservationIgnored
|
|
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
|
|
|
|
/// **The session boundary, announced** — called with `true` when an Edit session opens and
|
|
/// `false` when it ends, and with nothing in between.
|
|
///
|
|
/// `CardWindowHost` points it at the board's auto-committer, which registers the card's folder to
|
|
/// stage around while the session stands and **nudges** when it ends (06-history-undo.md ▸ Rules
|
|
/// ▸ Auto-commit: the Edit→Preview flip is "the effective Save button", and raw-source entry and
|
|
/// window close end the session too). That nudge is what turns a session's several debounced
|
|
/// saves into exactly one commit: they commit nothing while the folder is excluded, and the
|
|
/// whole diff becomes committable at once when it is not.
|
|
///
|
|
/// A closure for `save`'s reason exactly — this type is a buffer and a clock, and it stays
|
|
/// testable by having no idea what a repository is. `nil` (the free tier, a storeless test) means
|
|
/// nothing is listening, which is the same shape every other seam here takes.
|
|
@ObservationIgnored
|
|
public var editSessionDidChange: ((_ isEditing: Bool) -> Void)?
|
|
|
|
/// What disk said before this session's **first** landed save — the step's before-value, held
|
|
/// from the first write until the session ends.
|
|
///
|
|
/// `nil` means "no save has landed in this session", which is also what a session that only ever
|
|
/// read looks like: nothing was written, so there is nothing to undo and no step to register. It
|
|
/// is captured at the *write*, not at Edit entry, so that a session opened and abandoned leaves
|
|
/// the stack exactly as it found it.
|
|
@ObservationIgnored
|
|
private var sessionOriginBody: String?
|
|
|
|
@ObservationIgnored
|
|
private var pending: Task<Void, Never>?
|
|
|
|
/// How many saves have actually been attempted through `save` — the debounce's own testimony,
|
|
/// which a test would otherwise have to infer from `mtime`s.
|
|
@ObservationIgnored
|
|
public private(set) var saveAttempts = 0
|
|
|
|
public init() {}
|
|
|
|
// MARK: - Disk → buffer
|
|
|
|
/// A snapshot arrived. **Dirty-buffer-wins**: `disk` always follows it; `text` follows it only
|
|
/// when the buffer had nothing unsaved.
|
|
///
|
|
/// Called on every snapshot the window renders, including the first, which is how the buffer is
|
|
/// filled at all — a card window opens by adopting its card's body.
|
|
public func adopt(diskBody: String) {
|
|
let wasDirty = isDirty
|
|
disk = diskBody
|
|
guard !wasDirty else { return }
|
|
// Assigning an equal string would still notify observers, and an observer here is a text
|
|
// view that would replace its contents under the cursor.
|
|
if text != diskBody { text = diskBody }
|
|
}
|
|
|
|
// MARK: - Buffer → disk
|
|
|
|
/// The editor changed. Restarts the debounce — or cancels it outright, when the change brought
|
|
/// the buffer back to what disk already says (05's *reverted* gate: a revert must not leave a
|
|
/// timer standing that would then write nothing but a `modified` stamp).
|
|
public func edited(_ newText: String) {
|
|
guard text != newText else { return }
|
|
text = newText
|
|
guard isDirty else {
|
|
cancelPending()
|
|
return
|
|
}
|
|
scheduleSave()
|
|
}
|
|
|
|
/// Saves now if there is anything to save, cancelling the pending debounce first — "leaving Edit
|
|
/// flushes the debounce (mode flip, raw-source entry, window close)" (05 ▸ Mode grammar).
|
|
///
|
|
/// Synchronous, because the write is: `BoardWriter` is a temp file and a rename, and a flush that
|
|
/// returned before the bytes landed would be no flush at all — the close path in particular has
|
|
/// to know the answer before it lets the window go.
|
|
@discardableResult
|
|
public func flush() -> CardBodyWriteOutcome {
|
|
cancelPending()
|
|
return saveNow()
|
|
}
|
|
|
|
/// The start of one Edit session — the flip into Edit, or a window that opened straight into it
|
|
/// because its card's body was empty (`CardBodyMode.opening(body:)`).
|
|
///
|
|
/// Idempotent, because the mode can be re-asserted by a menu validation pass or a re-published
|
|
/// focus value, and a second announcement would register a session that is already registered.
|
|
public func beginEditSession() {
|
|
guard !isEditing else { return }
|
|
isEditing = true
|
|
editSessionDidChange?(true)
|
|
}
|
|
|
|
/// The end of one Edit session — the flip back to Preview, raw-source entry, or the window
|
|
/// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's
|
|
/// doc comment).
|
|
@discardableResult
|
|
public func endEditSession() -> CardBodyWriteOutcome {
|
|
let outcome = flush()
|
|
// The session's one undo step, registered here and nowhere else — see `registerUndo`. It
|
|
// fires only when something of this session's actually landed: `sessionOriginBody` is set by
|
|
// the first successful write, and the guard against `disk` covers the session that typed its
|
|
// way back to where it started across several ticks (each of which wrote, so the origin is
|
|
// set, but whose net effect on the file is nothing to undo).
|
|
if let origin = sessionOriginBody, origin != disk {
|
|
registerUndo?(origin, disk)
|
|
}
|
|
sessionOriginBody = nil
|
|
// **Last**, after the flush and after the undo step: the committer's nudge must find the
|
|
// session's final bytes already on disk, or the commit it arms would carry the file as it
|
|
// stood one keystroke ago. Guarded on `isEditing` so a window closing from Preview — which
|
|
// calls this too, and should — announces nothing.
|
|
if isEditing {
|
|
isEditing = false
|
|
editSessionDidChange?(false)
|
|
}
|
|
return outcome
|
|
}
|
|
|
|
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
|
|
/// reported.
|
|
///
|
|
/// The three non-failures deliberately do *not* throw, because each of them is a state in which
|
|
/// blocking the close would be dishonest:
|
|
///
|
|
/// - `.written` / `.unchanged` — the text is on disk.
|
|
/// - `.vanished` — the card's folder is gone, so there is nowhere for the save to land; 05 ▸
|
|
/// Deletion & lifecycle answers exactly this case with "nowhere left to write", and a modal
|
|
/// offering Try Again against a deleted folder would be a button that can only fail.
|
|
/// - `.suspended` — the board is locked read-only, which is 05's "where a save can land"
|
|
/// qualifier failing rather than a write failing: no write was attempted, the lock row has been
|
|
/// standing the whole time the user was typing, and the lock's own clearing rule (a successful
|
|
/// reload) is not something a close can wait on.
|
|
public func flushOrThrow() throws(BoardWriteError) {
|
|
switch flush() {
|
|
case .written, .unchanged, .vanished, .suspended:
|
|
return
|
|
case let .failed(error):
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func scheduleSave() {
|
|
cancelPending()
|
|
let interval = debounceInterval
|
|
pending = Task { [weak self] in
|
|
try? await Task.sleep(for: interval)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.pending = nil
|
|
_ = self.saveNow()
|
|
}
|
|
}
|
|
|
|
private func cancelPending() {
|
|
pending?.cancel()
|
|
pending = nil
|
|
}
|
|
|
|
/// The gate, and the one place `save` is called.
|
|
///
|
|
/// A successful landing moves `disk` up to the text that landed, so the echo arriving a reload
|
|
/// later finds the buffer already clean. A failure, a suspension and a vanished card all leave
|
|
/// `disk` where it was, which keeps the buffer dirty — and therefore keeps the text, which is the
|
|
/// whole point.
|
|
private func saveNow() -> CardBodyWriteOutcome {
|
|
guard isDirty else { return .unchanged }
|
|
guard let save else { return .vanished }
|
|
|
|
saveAttempts += 1
|
|
// Read before the write, because the write is what makes it stale: this is the body the
|
|
// session is about to start overwriting, and only the *first* landed save of a session may
|
|
// claim it (13-native-undo.md ▸ Rules — one step per session, not per tick).
|
|
let priorBody = disk
|
|
let outcome = save(text)
|
|
switch outcome {
|
|
case .written:
|
|
if sessionOriginBody == nil { sessionOriginBody = priorBody }
|
|
disk = text
|
|
case .unchanged:
|
|
// Nothing was replaced — the file already read like the buffer, so this session has
|
|
// overwritten nothing yet and has nothing to hand an undo. (It is also the one outcome
|
|
// where `disk` was demonstrably wrong, so the bytes it held are not a state to restore.)
|
|
disk = text
|
|
case .suspended, .vanished, .failed:
|
|
break
|
|
}
|
|
return outcome
|
|
}
|
|
}
|