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
+140
View File
@@ -0,0 +1,140 @@
import AppKit
import SwiftUI
// MARK: - A rendered comment body
/// One comment's Markdown, rendered **the card-body subset, through the card body's own renderer**
/// (05-card-window.md The comments column: "the rendered Markdown body (the card-body subset)").
///
/// ### Why the same renderer and not a `Text(AttributedString(markdown:))`
///
/// Because "the card-body subset" is a promise about *this* app's subset: fenced code, nested quotes,
/// GFM tables with per-column alignment, task markers, images resolved against the card's own folder,
/// HTML shown verbatim as literal code-styled text. `BodyMarkupRenderer` is where every one of those
/// is decided, and a second renderer here would be a second answer to each a comment quoting a code
/// block would look like a different app from the card that carries it.
///
/// ### Why it is not `CardBodySurface`
///
/// That surface is a hosted **scroll** view, because F's find bar lives in one and because a card
/// body is a document. A thread is a *list* of bodies inside one scroller, and a scroll view per row
/// would be a scroll view that fights its parent the same reasoning that put the card's title above
/// the body's scroller rather than inside it. So this is the same TextKit 1 stack with the scroller
/// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports
/// exactly the height its text needs.
///
/// The pane's find-in-text over the whole rendered thread is phase 3's (05 Preview scopes F to
/// "the comments pane, where it searches the whole rendered thread"); nothing here forecloses it.
struct CommentBodyView: NSViewRepresentable {
let body: String
/// The **card**'s folder, not the comment's relative images and links in a comment resolve the
/// same way a card body's do, which is what makes `![](attachments/shot.png)` mean one thing in
/// this window (05 Preview).
let cardFolder: URL?
/// The height a measurement pass lays out into tall enough that no comment reaches it, finite
/// so the arithmetic stays well-defined.
private static let layoutCeiling: CGFloat = 100_000
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSTextView {
// TextKit 1, explicitly, for `CardBodySurface`'s reason: `NSTextTable` the browser sizing
// rule GFM tables are laid out by does not lay out in TextKit 2.
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
storage.addLayoutManager(layoutManager)
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
container.widthTracksTextView = true
container.lineFragmentPadding = 0
layoutManager.addTextContainer(container)
let textView = NSTextView(frame: .zero, textContainer: container)
textView.delegate = context.coordinator
textView.isEditable = false
// Selectable and copyable, the whole thread Preview's own posture, and the reason a comment
// can be quoted without a mode flip.
textView.isSelectable = true
textView.isRichText = true
textView.drawsBackground = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.textContainerInset = .zero
textView.linkTextAttributes = [.cursor: NSCursor.pointingHand]
textView.displaysLinkToolTips = true
return textView
}
func updateNSView(_ textView: NSTextView, context: Context) {
let key = Coordinator.RenderKey(
body: body,
cardFolder: cardFolder,
pointSize: CardWindowMetrics.bodyPointSize
)
guard context.coordinator.rendered != key else { return }
context.coordinator.rendered = key
textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString(
for: BodyMarkup.parse(body),
context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder)
))
}
/// **The intrinsic height** the whole reason this is not a scroll view.
///
/// The container is laid out at the proposed width and asked what it used. `ensureLayout` is not
/// optional: `usedRect` is only meaningful once the glyphs have been laid, and an unlaid container
/// answers a zero-height rect, which would collapse every comment in the thread to nothing.
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? {
guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else {
return nil
}
guard let width = proposal.width, width > 0, width.isFinite else { return nil }
// A large finite height rather than `.greatestFiniteMagnitude`: the container tracks the
// view's width, so the frame is how the width is proposed at all, and an infinite frame
// height propagates into the layout arithmetic as a value nothing can subtract from.
nsView.frame = NSRect(x: 0, y: 0, width: width, height: Self.layoutCeiling)
layoutManager.ensureLayout(for: container)
return CGSize(width: width, height: layoutManager.usedRect(for: container).height.rounded(.up))
}
// MARK: - Coordinator
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
struct RenderKey: Equatable {
let body: String
let cardFolder: URL?
let pointSize: CGFloat
}
var rendered: RenderKey?
/// Links behave exactly as they do in a card body: external URLs go to the browser, relative
/// ones already resolved to file URLs by the renderer go to their default app.
///
/// **A task marker in a comment is inert.** 05 makes live checkboxes a rule about *Preview*,
/// the card body's one interactive exception, and gives a comment no toggle write path at all
/// the way to change a comment is Edit it. Swallowing the click (rather than letting it fall
/// through to `NSWorkspace.open`, which would try to open a `kanban-task:` URL) is what keeps
/// the checkbox drawn-but-dead rather than drawn-and-broken.
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
guard let url = Self.url(from: link) else { return false }
guard CardBodyLink.parseTask(url) == nil else { return true }
NSWorkspace.shared.open(url)
return true
}
private static func url(from link: Any) -> URL? {
switch link {
case let url as URL: url
case let string as String: URL(string: string)
default: nil
}
}
}
}