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:
2026-07-30 20:19:52 -04:00
parent f68ac3668e
commit fe3ffac48e
27 changed files with 4496 additions and 24 deletions
+133 -3
View File
@@ -55,6 +55,16 @@ final class CardWindowSession: CardSessionFlushing {
/// rather than pretending to have written it.
let body: CardBodyEditSession
/// The window's comments pane the thread, the composer's draft buffer, and the one open inline
/// edit session (05-card-window.md The comments column).
///
/// It lives **here** rather than as another `@State` beside the body's handles, and the close
/// flush is why: the pane owes the close three things in a fixed order the inline session's
/// flush, the draft's save, then the `comments/.trash/` purge and this object is the one the
/// coordinator already drives (`CardSessionFlushing`). A pane held only by the view would have its
/// close work run wherever SwiftUI happened to tear the view down.
let comments = CardComments()
/// The close-time save-or-lose moment, over this window's buffer (02-architecture.md §
/// Write-failure surfacing: "the one modal moment on the write-failure path").
let bufferGuard: DirtyBufferGuard
@@ -67,9 +77,16 @@ final class CardWindowSession: CardSessionFlushing {
/// window that has not joined its board holds nothing, which is true.
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
/// The Edit buffer's dirty text or a typed-in raw-source outlet see `CardSessionFlushing`.
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
/// holding keystrokes its file has not got see `CardSessionFlushing`.
///
/// **The composer's draft is deliberately not counted.** 05-card-window.md The comments column
/// gives it the opposite posture from every other buffer in this window "Close and quit just
/// proceed no DirtyBufferGuard, nothing to lose" because it is a durable file being edited in
/// place rather than unsaved work. An inline comment edit *is* the ordinary kind, so it counts
/// exactly as the body's does (`CardComments.holdsUnsavedContent`).
var holdsUnsavedContent: Bool {
body.isDirty || rawSourceHoldsUnsavedText?() == true
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
}
private var hasEnded = false
@@ -103,6 +120,12 @@ final class CardWindowSession: CardSessionFlushing {
// the committer's own debounce outlives them and this call is where the session is known to
// be over.
body.endEditSession()
// **Saves first, purge last** the inline comment session's flush and the draft's save land
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing
// a folder a save was about to write into (`CardComments.endSession`). It runs after the
// body's for the same reason it runs at all: this is the one place the window's whole close
// work has a fixed order.
comments.endSession()
}
}
@@ -165,6 +188,12 @@ struct CardWindowHost: View {
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
/// flush; cleared by the resolution that lets the close resume.
@State private var isClosePending = false
/// The two app-wide comment bits, read here for one reason only: the window's **minimum size**
/// depends on them (`minimumSize`). The panes read them again themselves (`CardWindowView`)
/// two readers of one `UserDefaults` key, which is what `@AppStorage` is for and is cheaper than
/// threading the pair through a view that would then have to publish them back up.
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
private enum Phase {
case opening
@@ -236,6 +265,11 @@ struct CardWindowHost: View {
// File Add Attachment (A) and File Reveal in Finder's card-window scope reach the
// frontmost card window the same way (11-command-nexus.md).
.focusedSceneValue(\.cardAttachments, attachments)
// File Add Comment and the two View-menu comment toggles reach the frontmost card
// window the same way the toggles read their own persisted bits and use this only to
// know a card window is in front at all (11-command-nexus.md scopes all three to the card
// window).
.focusedSceneValue(\.cardComments, session.comments)
// The raw-source outlet's detailed alert, presented over this window a validation
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
// than inside the editor because the second of those fires while source mode is still
@@ -277,8 +311,14 @@ struct CardWindowHost: View {
.onDisappear { finish() }
}
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md
/// Composition) which is the whole reason the stacked mount exists, so a narrow display keeps
/// the minimum it always had.
private var minimumSize: CGSize {
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize)
CardWindowMetrics.minimumSize(
bodyPointSize: CardWindowMetrics.bodyPointSize,
commentsColumn: showComments && commentsBesideBody
)
}
@ViewBuilder
@@ -301,6 +341,7 @@ struct CardWindowHost: View {
// predicate too ("the attachment row's /Remove shares the posture").
isEditable: !store.isReadOnly,
attachments: attachments,
comments: session.comments,
thumbnails: thumbnails,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
@@ -319,9 +360,27 @@ struct CardWindowHost: View {
// folder rename moves the board, and rows resolving against where it used to be
// would open nothing.
attachments.cardFolder = folder
session.comments.cardFolder = folder
}
.onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked
session.comments.isEditable = !locked
}
// **The thread re-reads on every applied snapshot** (05 The comments column: "the pane
// reloads its thread from the same FSEvents stream").
//
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` it does
// not vend the changed paths, and comments are outside the snapshot entirely
// (01-storage-format.md § Enhanced schema), so there is nothing here to run
// `CommentPath.classify` against. Re-reading one card's thread is a handful of small
// files and happens only while a card window is open; filtering would mean either
// widening the store's surface to carry paths, or the pane keeping its own watcher a
// second stream over the same tree, which the one-way flow rules out. `initial:` is
// deliberately absent: `start()` already did the opening read, after the residue sweep
// that has to precede it.
.onChange(of: store.snapshotGeneration) { _, _ in
session.comments.reload()
}
} else {
// Nothing to render and nothing worth animating: this window is on its way out.
@@ -409,6 +468,27 @@ struct CardWindowHost: View {
phase = .open(store)
configureSession(store: store)
configureWindow()
openCommentThread(store: store)
}
/// **The card window's open, comment-side** the crash-residue sweep, then the thread read
/// (01-storage-format.md § Enhanced schema: "crash residue sweeps at the next card-window open,
/// armed-then-cleared like every heal memo").
///
/// It runs from `start()` rather than from a `.task` on the pane, and the reason is the pane's
/// own visibility: Show Comments off means no pane at all, and the residue of a session that died
/// mid-delete must still be swept it is the app's leftovers, not a feature of the pane. The
/// same goes for the close purge, which rides the session's end for the same reason.
///
/// The pane's two window-scoped facts are set *before* the read, because both of them are things
/// the read's results are resolved against: the folder every comment's attachments hang off, and
/// whether the lock is on.
private func openCommentThread(store: BoardStore) {
if case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) {
session.comments.cardFolder = Self.cardFolder(root: store.rootURL, placement: placement)
}
session.comments.isEditable = !store.isReadOnly
session.comments.open()
}
/// Points this window's Edit buffer at its card, and the mode flip at the buffer.
@@ -447,6 +527,56 @@ struct CardWindowHost: View {
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
Self.configureAttachments(attachments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID)
}
/// Points the comments pane at its card **the one place every comment gesture learns which card
/// it acts on** (05-card-window.md The comments column).
///
/// Every seam is one of the store's own bracketed methods, unchanged, which is the same rule the
/// attachments section keeps: there is deliberately no comment write of this window's own to keep
/// in step with the store's, so a post made here and a post made by anything else take one path
/// one bracket, one undo step, one commit shape.
///
/// The store is captured **weakly**, `configureSession`'s rule: a save still landing after the
/// board window has gone should write nothing rather than resurrect a released store. A `nil`
/// store answers what a vanished card answers an empty thread, a save that did not land, a
/// delete that did not happen which is exactly what the pane's own guards expect.
///
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
/// target resolution is invisible in a running window until it is wrong, and this shape is what
/// lets a test drive the real wiring rather than a re-typed copy of it.
static func configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) {
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
comments.purgeTrash = { [weak store] in store?.purgeCommentTrash(inCard: cardID) }
// Detection is the thread read's, the repair is the store's batch, and the notice is the
// banner surface's "the relocation-style warning-tone notice names the repair". This
// closure is only the join, which is why it is three lines and lives here rather than on
// either side of it.
comments.displaceSquatters = { [weak store] squatters in
guard let store else { return }
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
}
comments.deleteComment = { [weak store] id in
store?.deleteComment(id, inCard: cardID) ?? false
}
comments.editComment = { [weak store] id, body in
store?.editComment(id, inCard: cardID, body: body) ?? false
}
comments.importAttachments = { [weak store] urls, target in
store?.importCommentAttachments(urls, inCard: cardID, target: target)
}
comments.removeAttachment = { [weak store] name, target in
store?.removeCommentAttachment(named: name, inCard: cardID, target: target)
}
comments.composer.save = { [weak store] text in
store?.saveCommentDraft(inCard: cardID, body: text)
}
comments.composer.post = { [weak store] in
store?.postComment(inCard: cardID)
}
}
/// Points the attachments section at its card **the one place Add Attachment and Remove