Files
lanework/Kanban/UI/Card/CommentEditSession.swift
T
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
The card window recomposes into three componentized panes (body,
comments, attributes) with two mounts — beside or body-over-comments
at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is
one persisted app-wide bit, no content-derived auto-show; File ▸ Add
Comment flips it on and focuses the composer. The thread renders
author lines, edited markers, card-subset Markdown bodies, and
read-only Quick Look chips under a count header with the sort-
direction control. The composer edits comments/.draft/ on the slow
cadence (blur, close, quit, ~30s interval), Escape only moves focus,
⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms
debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start
bytes, close flushes. File drops within either authoring surface
carve out of the window-wide card default into that surface's
attachments/; paperclips cover the no-drag path. Close flush runs
inline flush, then draft save, then the comments/.trash purge;
open sweeps crash residue.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 20:19:52 -04:00

213 lines
9.0 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)?
/// 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
return flush()
}
/// **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
return flush()
}
// 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 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
}
}