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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user