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
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user