Files
lanework/Kanban/UI/Card/CardBodyEditSession.swift
T
rzen 092300c7d2 Collapse the edition split — one target, twins merged, verify-editions retired
Phase 1 of the 2026-07-30 one-app pivot (DESIGN 0bec9a6, card c3a3ddd5):
the KanbanPro target, LaneworkPro scheme, KanbanProTests module-alias
bundle, KanbanPro/ source root and scripts/verify-editions.sh retire
wholesale. project.yml reads as a single-target file again (anchors
inlined, header rewritten in tier vocabulary).

The edition twins merge: EditionTypes -> PasteboardTypes (one
UTType(exportedAs:) home — the one app owns the family types),
EditionAbout -> AboutBox (the quiet Pro signpost survives as the About
box's one line; "…in Settings" deferred until the StoreKit phase gives
it somewhere to point). InertGitTests drops its Base prefix — the
inert-.git posture is unconditional app behavior, unsubscribed and
lapsed being one state.

Entitlements gain com.apple.security.network.client, declared now and
dormant until Pro's remotes use it; no keychain access group. The App
Group key deliberately stays — it goes with AppGroup.swift in phase 2,
since pulling it first would silently drop the app into the fallback
container. README/RELEASE.md build-and-pipeline prose updated to the
one-record world; the subscription story lands with phase 3.

1893 tests in 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:25:50 -04:00

251 lines
12 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 }
// 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)?
/// 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 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
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
}
}