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
213 lines
9.0 KiB
Swift
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
|
|
}
|
|
}
|