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:
@@ -60,6 +60,53 @@ public enum AppPreferences {
|
||||
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
|
||||
}
|
||||
|
||||
// MARK: The comments pane's three bits
|
||||
|
||||
/// **View ▸ Show Comments** — "a checkmark toggle à la Show Trash, and its choice is **app-wide
|
||||
/// and persisted across restarts**" (05-card-window.md ▸ The comments column, re-ruled
|
||||
/// 2026-07-29; 11-command-nexus.md).
|
||||
///
|
||||
/// **One bit, and no content-derived auto-show**: checked, every card window carries the pane —
|
||||
/// a comment-less card shows the empty thread and the composer, because the invitation is the
|
||||
/// point; unchecked, threads and drafts are out of sight until the user says otherwise. The
|
||||
/// checkmark reads exactly this value, so the menu never lies, and deleting the last comment
|
||||
/// never closes the pane because nothing but this bit does.
|
||||
///
|
||||
/// **Default on.** 05 does not spell a default, and the two candidate readings pull in opposite
|
||||
/// directions — the Show Trash bargain (a secondary surface, default off) against "the invitation
|
||||
/// is the point" (a pane whose empty state is its whole argument). The invitation wins: a
|
||||
/// comments feature nobody sees until they find a View-menu row is a feature that is not there,
|
||||
/// and the user who does not want it turns it off once, forever, which is what the persistence is
|
||||
/// for.
|
||||
public static let showCommentsKey = "showComments"
|
||||
|
||||
public static var showComments: Bool {
|
||||
UserDefaults.standard.object(forKey: showCommentsKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// **View ▸ Comments Beside Body** — "checked = side-by-side (default), unchecked = body over
|
||||
/// comments; app-wide, persisted" (11-command-nexus.md; 05 ▸ Composition).
|
||||
///
|
||||
/// Default **on**, which 05 does state: "side-by-side is the default".
|
||||
public static let commentsBesideBodyKey = "commentsBesideBody"
|
||||
|
||||
public static var commentsBesideBody: Bool {
|
||||
UserDefaults.standard.object(forKey: commentsBesideBodyKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// The comments header's **sort-direction control** — "chronological ascending by default,
|
||||
/// flippable to newest-first (app-wide, persisted)" (05 ▸ The comments column; 11 files it under
|
||||
/// Configuration controls).
|
||||
///
|
||||
/// Stored as "newest first" rather than as a direction so the default is `false` and the plain
|
||||
/// `bool(forKey:)` reading is the right one — the one preference here that does not need to tell
|
||||
/// "off" from "never set".
|
||||
public static let commentsNewestFirstKey = "commentsNewestFirst"
|
||||
|
||||
public static var commentsNewestFirst: Bool {
|
||||
UserDefaults.standard.bool(forKey: commentsNewestFirstKey)
|
||||
}
|
||||
|
||||
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
||||
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
||||
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,9 +80,14 @@ struct FindSteppingCommands: View {
|
||||
// outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
|
||||
// 07-sync-collab.md). It remains unconditionally disabled here — the sidebar reserves the section's
|
||||
// place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus.
|
||||
/// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments
|
||||
/// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits,
|
||||
/// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`).
|
||||
struct CardViewCommands: View {
|
||||
var body: some View {
|
||||
EditBodyCommand()
|
||||
ShowCommentsCommand()
|
||||
CommentsBesideBodyCommand()
|
||||
RawSourceCommand()
|
||||
FutureCommand(title: "History")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user