Files
lanework/Kanban/UI/Card/CardBodyEditSession.swift
T
rzen 142c6e75fe Implement undo and redo as forward commits
GitHistoryProvider is the second HistoryProviding implementation:
its stack IS HEAD's first-parent ancestry, reseeded on load (redo
empty), re-synced to HEAD before every crossing so agents'
self-commits become the top and ⌘Z steps back exactly one commit;
any arriving commit clears redo (a heal-only window deliberately
does not). Restores are forward commits through the ordinary
signature path — GitRestoreOperation materializes only the
current-vs-target diff as working-tree writes and resolves no
reset/checkout symbol at all; heal commits are transparent
in-session (pointer passes over, restores exclude heal-owned paths,
identity carried on landed windows via PlannedCommit.kind →
GitLandedCommit). Subjects "Undo:/Redo: <crossed subject>"; menu
labels never nest in-session; the root commit is not a step
(crossing it would restore the empty tree).

Provider binding flips: makeHistoryProvider(store, tier, git) —
free binds native everywhere, Pro binds the git provider on git
boards and NOTHING on mode-none/repo-nested (the pair disables
through existing validation); add-git mid-session live-binds via
HistoryStore.didAddGit → bindHistoryProvider (the flip only ever
adds).

SessionSettleGate is the reusable Save All / Discard / Cancel step:
restores whose diff touches an open Edit session or raw-source
buffer gate on it (Save All applies with validation — a refused
buffer cancels the whole restore focused on the offender; Discard
reverts via CardBodyEditSession.discardBuffer and reconciles against
the working tree, deliberately skipping the second flush); untouched
sessions ride through undisturbed. Built for the branch-switch card
to reuse. BoardStore gains the async performWholesale sibling.

CardHistorySection fills the m6 EmptyView slot: read-only, newest
first, follows the card across lane moves by folder-component match
(the UUID is the identity — no rename detection), absent off git
mode and off Pro.

2332 tests / 403 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 15:54:22 -04:00

323 lines
17 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
}
/// **Throws the buffer away and takes disk's word for it** — the Discard branch of the
/// save-or-discard step (06-history-undo.md ▸ Branch switching: "Discard reverts buffers and
/// uncommitted saves to HEAD").
///
/// It reverts the *buffer* and ends the session; the uncommitted on-disk saves are the operation
/// behind the step's to undo, because only that operation knows which state it is restoring to
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)` reconciles the card's folder
/// against the working tree for exactly this reason). Splitting it that way is what keeps the two
/// halves from being two answers able to disagree: one pass writes the card's files, once.
///
/// The pending debounce is cancelled first, which is the load-bearing half — a surviving timer
/// would write the discarded text back over the restored card a moment later.
public func discardBuffer() {
cancelPending()
text = disk
sessionOriginBody = nil
if isEditing {
isEditing = false
editSessionDidChange?(false)
}
}
/// `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
}
}