Files
lanework/Kanban/UI/Card/CommentEditSession.swift
rzen 71664dab02 Give card windows their own undo stacks and coarsen the close
Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 19:39:50 -04:00

239 lines
10 KiB
Swift

import Foundation
import Observation
// MARK: - CommentEditSession
/// One inline comment edit — **a body-edit session in miniature** (05-card-window.md ▸ The comments
/// column: "no second draft mechanism: debounced saves to the comment's own file keep it crash-safe,
/// Save (or ⌘↩) ends the session as its commit point, Cancel — or Escape, its keyboard twin —
/// reverts to session-start bytes, window close flushes the session exactly as the body's does").
///
/// ### What it borrows from `CardBodyEditSession`, and what it adds
///
/// Borrowed, deliberately verbatim: the buffer/disk pair, the single write predicate (*write if and
/// only if the buffer differs from disk*), dirty-buffer-wins on `adopt(diskBody:)`, the ~700 ms
/// trailing debounce, and the flush that a mode exit or a window close performs. **The 700 ms is
/// right here**, and its being right here is what the slow cadence next door is a contrast to: the
/// comment already exists as a file, so a save is an ordinary edit to it — it is the *draft* that
/// must not become a commit stream (`CommentDraftSession`).
///
/// Added, and the only genuinely new thing in this type: **session-start bytes**. The body has no
/// Cancel — leaving Edit is a commit, and ⌘Z in the editor is the text view's own undo — while an
/// inline comment edit has a Cancel button and an Escape that means it. 13-native-undo.md forbids
/// byte capture *on the undo stack* in every tier, and this is not that: the capture is a live
/// buffer's, held for the length of one session, discarded when the session ends, and never
/// registered anywhere. `BoardStoreComments`' own note says so — "an inline edit's revert is its
/// *session*'s … which is a live buffer, not a stack entry".
///
/// ### The revert is a write, not an unwrite
///
/// Cancel puts the captured bytes back **through the ordinary save** (`BoardStore.editComment`), so
/// the file returns to what it said with one more `modified` stamp and one more bracketed write. That
/// is the honest shape for a files-first app: the debounced saves genuinely happened, other windows
/// and other machines have already seen them, and pretending otherwise would mean holding the file
/// open for the length of a session.
///
/// A cancel that has nothing to put back writes nothing — a session that only ever read leaves the
/// file byte-identical, `mtime` included, which is the body's untouched gate applied to the exit.
@MainActor
@Observable
public final class CommentEditSession {
// MARK: Identity
/// Which comment is open. The row renders an editor instead of its body while this session names
/// it, and the drop carve-out aims at its `attachments/`.
public let commentID: ItemID
// MARK: State
/// What the editor is showing.
public private(set) var text: String
/// What the last read said the comment's `index.md` holds. The write gate's other half.
public private(set) var disk: String
/// **The bytes this session opened on** — Cancel's destination, captured once at `init` and never
/// updated. See the type's note for why this capture is not the one 13 forbids.
@ObservationIgnored
public let sessionStart: String
public var isDirty: Bool { text != disk }
// MARK: Seams
/// The debounce interval — **~700 ms**, the body's own (05 ▸ Edit), and settable so a test does
/// not have to spend it.
@ObservationIgnored
public var debounceInterval: Duration = .milliseconds(700)
/// Where a save goes — `BoardStore.editComment(_:inCard:body:)`, filled in by the window.
///
/// `true` means the bytes landed. `false` covers everything that means they did not — a failed
/// write (already bannered by `performWrite`), a suspended one under the read-only lock, and a
/// comment or card that has gone — and they are one case here for the reason 05 gives the window:
/// each of them leaves the buffer dirty, which keeps the text, and none of them has a different
/// thing for this type to do.
@ObservationIgnored
public var save: ((String) -> Bool)?
/// Where this session's **one undo step** goes, called at its commit point with the bytes the
/// session opened on and the bytes it leaves — `CardBodyEditSession.registerUndo`'s seam one level
/// down, and for its reason exactly: a session is one step, never one per debounced tick
/// (13-native-undo.md ▸ Rules ▸ coalescing).
///
/// `CardComments.beginEdit` points it at `BoardStore.registerCommentEdit`, on the *window's*
/// stack. `nil` — a session with no window behind it — registers nothing.
@ObservationIgnored
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
/// How many saves have actually been attempted through `save`.
@ObservationIgnored
public private(set) var saveAttempts = 0
@ObservationIgnored
private var pending: Task<Void, Never>?
/// Whether this session has ended. A session ends once — Save, Cancel, or the window close that
/// beat both of them to it — and ending twice must not write twice.
@ObservationIgnored
public private(set) var hasEnded = false
/// Opens a session over `body`, which is both the buffer's starting text and Cancel's
/// destination.
public init(commentID: ItemID, body: String) {
self.commentID = commentID
text = body
disk = body
sessionStart = body
}
// MARK: - Disk → buffer
/// A thread read arrived. **Dirty-buffer-wins**, the body's single `if`: `disk` always follows the
/// read; `text` follows it only when the buffer had nothing unsaved.
public func adopt(diskBody: String) {
let wasDirty = isDirty
disk = diskBody
guard !wasDirty else { return }
if text != diskBody { text = diskBody }
}
// MARK: - Buffer → disk
/// A keystroke. Restarts the debounce, or cancels it when the change brought the buffer back to
/// what disk already says.
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 — the window
/// close's flush, which "flushes the session exactly as the body's does".
@discardableResult
public func flush() -> Bool {
cancelPending()
return saveNow()
}
// MARK: - The two ends
/// **Save, or ⌘↩** — the session's commit point (05 ▸ The comments column). Flushes and ends.
///
/// A named call rather than a bare `flush()` for `CardBodyEditSession.endEditSession()`'s reason:
/// this is the boundary a Pro auto-commit coalesces on, one commit per session and never per save
/// tick (06-history-undo.md ▸ Rules ▸ Auto-commit).
@discardableResult
public func commit() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
let wrote = flush()
registerStep()
return wrote
}
/// **Cancel, or Escape** — reverts to session-start bytes and ends (05; 11-command-nexus.md's
/// grammar table gives Escape as the button's keyboard twin).
///
/// The revert is a write, and it is attempted only when something of this session's actually
/// landed: `disk` is what the file says as far as this session knows, so `disk == sessionStart`
/// is a session that has overwritten nothing and has nothing to put back.
///
/// A *foreign* edit landing mid-session moves `disk` too, and Cancel then writes the session's
/// start bytes over it — deliberate last-writer-wins, the same no-merge-UI philosophy the body's
/// dirty-buffer rule states (05 ▸ Write rules). The alternative would be a merge prompt in a
/// comment editor.
@discardableResult
public func cancel() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
cancelPending()
guard disk != sessionStart else { return false }
saveAttempts += 1
guard save?(sessionStart) == true else { return false }
text = sessionStart
disk = sessionStart
return true
}
/// The window close's end: flush, then mark the session over — the same one-way latch Save and
/// Cancel use, so a close that beat the buttons cannot be followed by a second write.
///
/// It is **not** Cancel: a close is not an abandon (05 ▸ Deletion & lifecycle — "Dismissal never
/// eats typed work silently where a save can land"), and reverting the user's typing because they
/// closed a window would be the opposite of that promise.
@discardableResult
public func endOnClose() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
let wrote = flush()
registerStep()
return wrote
}
// MARK: - Private
/// The session's one step, at whichever of its two ends got here first — and **only when the
/// session actually changed the file**: `disk` is what this session knows the file says, so
/// `disk == sessionStart` covers both the session that only read and the one that typed its way
/// back to where it started (`CardBodyEditSession.endEditSession`'s guard, restated).
///
/// Cancel deliberately never reaches here: it writes the start bytes back, so its net effect is
/// nothing and a step would only offer to undo an undo.
private func registerStep() {
guard disk != sessionStart else { return }
registerUndo?(sessionStart, disk)
}
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 landing moves `disk` up to the text that
/// landed; anything else leaves it, which keeps the buffer dirty and therefore keeps the text.
private func saveNow() -> Bool {
guard isDirty, let save else { return false }
saveAttempts += 1
guard save(text) else { return false }
disk = text
return true
}
}