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
+23 -10
View File
@@ -89,7 +89,7 @@ public final class CardAttachments {
/// the very same store method a whole-window drop uses.
public func add() {
guard isEditable, cardFolder != nil else { return }
let urls = Self.chooseFiles()
let urls = AttachmentPanel.chooseFiles(message: "Choose files to attach to this card.")
guard !urls.isEmpty else { return }
// The sandbox's half, `BoardDropContext.commitFileDrop`'s rule: `start` answers false for a
@@ -172,22 +172,35 @@ public final class CardAttachments {
return [cardFolder]
}
// MARK: - The panel
}
/// The multi-select open panel behind Add Attachment **every file type**, because a card's
/// `attachments/` takes anything (01-storage-format.md § Attachments) and a filter here would be
/// this app deciding what the user may keep beside their card.
///
/// Directories are not choosable, which is the panel's own spelling of the same refusal a
/// folder drop gets (`FinderDrop`): the attachment model is flat top-level files.
private static func chooseFiles() -> [URL] {
// MARK: - The panel
/// The multi-select open panel behind every "add a file" affordance in the card window File Add
/// Attachment and the attachments header's plus (card-scoped), and the composer's and inline
/// editor's paperclips (comment-scoped, 05-card-window.md The comments column).
///
/// **Every file type**, because a card's `attachments/` takes anything (01-storage-format.md §
/// Attachments) and a filter here would be this app deciding what the user may keep beside their
/// card. Directories are not choosable, which is the panel's own spelling of the same refusal a
/// folder drop gets (`FinderDrop`): the attachment model is flat top-level files.
///
/// Shared rather than copied per surface for `BoardWriter.importFiles`' reason one layer up: the
/// paperclip is "the section header's add-affordance pattern" and a second panel that happened to
/// allow folders would make that sentence false.
@MainActor
enum AttachmentPanel {
/// - Parameter message: the panel's one line of guidance the only thing that differs between
/// the card-scoped and comment-scoped calls, because it is the only thing that *is* different.
static func chooseFiles(message: String) -> [URL] {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.resolvesAliases = true
panel.prompt = "Add"
panel.message = "Choose files to attach to this card."
panel.message = message
guard panel.runModal() == .OK else { return [] }
return panel.urls
}
+329
View File
@@ -0,0 +1,329 @@
import AppKit
import Observation
import SwiftUI
// MARK: - The window's comments pane, as a handle
/// One card window's comments pane, reduced to what things *outside* it need: the thread it is
/// showing, the composer's buffer, whichever comment has an inline edit session open, and the writes
/// the pane can start (05-card-window.md The comments column).
///
/// `CardAttachments`' shape and for its reason one per window, `@State` in the host (on its
/// session, so the close flush can reach it), published through the focus system so **menu items**
/// (File Add Comment; View Show Comments' validation) can reach the frontmost card window without
/// anyone keeping a which-window-is-key register. It is deliberately not on `BoardStore`: the store
/// is the *board's*, shared by every window on it, and two card windows open on two cards have two
/// different threads, two different drafts and two different sessions.
///
/// ### What it is not
///
/// It is **not** the thread's source of truth, and it is emphatically not a cache. Comments are
/// window-scoped and outside the board snapshot (01-storage-format.md § Enhanced schema), so there is
/// no snapshot to republish from this handle re-reads the thread from disk through the store's own
/// read, at exactly two moments: when the window opens, and when a reload lands. Everything else
/// here is a buffer or a seam.
///
/// ### The three preference bits are not here either
///
/// Show Comments, Comments Beside Body and the sort direction are **app-wide and persisted**
/// (`AppPreferences`), so they are read where they are used `@AppStorage` in the views and the menu
/// rows rather than mirrored onto every window's handle, which would be one copy per window of a
/// value that has exactly one.
@MainActor
@Observable
public final class CardComments {
// MARK: What the pane shows
/// The thread as the last read found it, in the loader's order (`created` ascending). The header's
/// sort control reverses it for display and never re-sorts see `CommentSortDirection`.
public private(set) var thread: CommentThread = .empty
/// The card's own folder `<root>/<lane>/<card>`. `nil` until the window has joined its board,
/// which is exactly while there is nothing to comment on. Comment attachment URLs and Reveal in
/// Finder resolve against it.
public var cardFolder: URL?
/// Whether the pane's mutations are offered at all `!store.isReadOnly`. Under the lock the
/// composer, the paperclips, Post, Edit and Delete disable in place, which is
/// 02-architecture.md's every-entry-point predicate applied to this pane.
public var isEditable = false
// MARK: The two authoring surfaces
/// The composer's buffer always present, because the composer is always visible when the pane
/// is (05: "an always-visible text area").
public let composer = CommentDraftSession()
/// The one open inline edit session, or `nil`. **One at a time**: 05 describes Edit as *the*
/// comment's session and the window's close flushes *the* session, and two editors over one
/// thread would each hold their own session-start bytes over files the other was writing.
public private(set) var editing: CommentEditSession?
/// Bumped by File Add Comment (and by the pane's own affordances) to ask the composer for the
/// keyboard. A **counter**, not a flag, so two Add Comments in a row are two focus requests
/// a `Bool` would need clearing, and a clear that raced the view would swallow the second one.
public private(set) var focusComposerRequests = 0
// MARK: Seams filled in by the host with the store's own bracketed methods
/// Re-reads the thread `BoardStore.commentThread(inCard:)`.
@ObservationIgnored
public var readThread: (() -> CommentThread)?
/// Re-reads the draft `BoardStore.commentDraft(inCard:)`.
@ObservationIgnored
public var readDraft: (() -> CommentDraft?)?
/// The crash-residue sweep, run once when the window opens
/// `BoardStore.sweepCommentTrashResidue(inCard:)`.
@ObservationIgnored
public var sweepTrashResidue: (() -> Void)?
/// The close purge `BoardStore.purgeCommentTrash(inCard:)`.
@ObservationIgnored
public var purgeTrash: (() -> Void)?
/// Displaces the claimed names a thread read found squatted, and surfaces what moved
/// `BoardStore.displaceCommentClaimedNames(_:)` joined to `BannerCenter.postDisplacedClaimedNames`.
@ObservationIgnored
public var displaceSquatters: (([ClaimedNameSquatter]) -> Void)?
/// Deletes one comment `BoardStore.deleteComment(_:inCard:)`. Immediate, no confirm, and the
/// undo step is already registered store-side.
@ObservationIgnored
public var deleteComment: ((ItemID) -> Bool)?
/// One inline edit session's save `BoardStore.editComment(_:inCard:body:)`, handed to each
/// session as it opens.
@ObservationIgnored
public var editComment: ((ItemID, String) -> Bool)?
/// Imports files into an authoring surface's `attachments/`
/// `BoardStore.importCommentAttachments(_:inCard:target:)`.
@ObservationIgnored
public var importAttachments: (([URL], CommentTarget) -> Void)?
/// Moves one authoring chip's file to the system Trash
/// `BoardStore.removeCommentAttachment(named:inCard:target:)`.
@ObservationIgnored
public var removeAttachment: ((String, CommentTarget) -> Void)?
public init() {}
// MARK: - Reading
/// **The window-open sequence** the sweep first, then the read (01-storage-format.md § Enhanced
/// schema: "crash residue sweeps at the next card-window open"; the brief's order).
///
/// The sweep goes first because it *removes* folders, and a thread read taken before it would
/// describe a `comments/.trash/` that is about to stop existing. It costs no bracket at all on a
/// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one.
public func open() {
sweepTrashResidue?()
reload()
}
/// Re-reads the thread and the draft, and routes anything the read found to be repaired.
///
/// **Foreign arrivals snap** (05 The comments column: "foreign arrivals snap in per the motion
/// language"). `withAnimation(nil)` is the motion language's own spelling of that the bare
/// assignment `Motion.reloadAnimation` returns `nil` for, applied here rather than inherited,
/// because this runs from an `onChange` that may still be inside the store's own reload
/// transaction and a thread must not ride the board's structural spring.
public func reload() {
guard let readThread else { return }
let thread = readThread()
let draft = readDraft?()
withAnimation(nil) {
self.thread = thread
}
composer.adopt(draft: draft)
// The open session follows disk under the same dirty-buffer-wins rule the body has: a clean
// editor takes the foreign edit, a dirty one keeps the keystrokes. A session whose comment
// has gone deleted here, or by another window is simply dropped; there is no error UI to
// show for a file that is not there (05 Deletion & lifecycle's "nowhere left to write").
if let editing {
guard let comment = thread.comments.first(where: { $0.id == editing.commentID }) else {
self.editing = nil
return
}
editing.adopt(diskBody: comment.body)
}
// The thread's own claimed-name squatters `comments/.draft`, `comments/.trash`, and a
// comment's `attachments` displaced through the store's batch, with the warning-tone
// notice naming what moved. Detection is the read's, the repair is the store's, and the
// notice is the banner surface's; this line is only the join.
let squatters: [ClaimedNameSquatter] = thread.defects.compactMap {
if case let .claimedNameSquatted(work) = $0 { work } else { nil }
}
if !squatters.isEmpty {
displaceSquatters?(squatters)
}
}
// MARK: - The composer
/// **File Add Comment**, and the pane's own "add a comment" affordances: ask the composer for
/// the keyboard.
///
/// Turning Show Comments *on* is deliberately not here it is the menu row's, because the row is
/// the thing that knows the preference and because the pane has to be mounted before there is a
/// composer to focus. See `AddCommentCommand`, which does both in the one order that works.
public func focusComposer() {
focusComposerRequests += 1
}
// MARK: - The inline edit session
/// Opens a session over one comment the context menu's **Edit** (05 The comments column).
///
/// A session already open is **committed** first rather than abandoned: the user asked to edit a
/// different comment, which is not a request to throw away what they typed in this one. Editing
/// the comment that is already open is a no-op, so a double-click on Edit cannot restart a session
/// and lose its start-of-session bytes.
public func beginEdit(_ commentID: ItemID) {
guard isEditable else { return }
guard editing?.commentID != commentID else { return }
endEdit()
guard let comment = thread.comments.first(where: { $0.id == commentID }) else { return }
let session = CommentEditSession(commentID: commentID, body: comment.body)
session.save = { [weak self] text in
self?.editComment?(commentID, text) ?? false
}
editing = session
}
/// **Save / ** the session's commit point.
public func commitEdit() {
editing?.commit()
editing = nil
}
/// **Cancel / Escape** reverts to session-start bytes.
public func cancelEdit() {
editing?.cancel()
editing = nil
reload()
}
/// The window close's end of the session a flush, never a revert (see
/// `CommentEditSession.endOnClose`).
private func endEdit() {
editing?.commit()
editing = nil
}
// MARK: - Delete
/// The context menu's **Delete** "immediate and undoable, no confirm" (05 The comments
/// column). The step is `BoardStore.deleteComment`'s; nothing is registered here.
///
/// A session open over the comment being deleted ends first, and ends as a *commit*: the user's
/// last keystrokes belong in the file that is about to move into `comments/.trash/`, so that an
/// undo brings back what they wrote rather than what the file said a debounce ago.
public func delete(_ commentID: ItemID) {
guard isEditable else { return }
if editing?.commentID == commentID {
endEdit()
}
guard deleteComment?(commentID) == true else { return }
reload()
}
/// The context menu's **Reveal in Finder** the comment's own folder.
public func reveal(_ commentID: ItemID) {
guard let cardFolder else { return }
let folder = CommentThread.commentFolder(commentID, inCard: cardFolder)
NSWorkspace.shared.activateFileViewerSelecting([folder])
}
// MARK: - Attachments on the two authoring surfaces
/// Where a target's files live on disk what a chip's thumbnail, its Quick Look and its Reveal
/// resolve against. `nil` before the window has joined its board.
public func attachmentURL(_ name: String, in target: CommentTarget) -> URL? {
guard let cardFolder else { return nil }
return target.folder(inCard: cardFolder)
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
.appendingPathComponent(name)
}
/// The paperclip affordance and the drop carve-out **one act with two pointers at it**, the
/// attachments section's add-affordance rule one level down.
///
/// The security-scope dance is `CardAttachments.add()`'s, verbatim and for its reason: `start`
/// answers false for a URL that carries no scope of its own, so only the ones that opened are
/// closed again.
public func addAttachments(to target: CommentTarget) {
guard isEditable, cardFolder != nil else { return }
let urls = AttachmentPanel.chooseFiles(message: "Choose files to attach to this comment.")
guard !urls.isEmpty else { return }
importFiles(urls, to: target)
}
/// The drop's write. Named separately from `addAttachments(to:)` because a drop already has its
/// URLs and must not open a panel.
public func importFiles(_ urls: [URL], to target: CommentTarget) {
guard isEditable, !urls.isEmpty else { return }
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
importAttachments?(urls, target)
reload()
}
/// An authoring chip's Remove the **system** Trash (05 The comments column).
public func removeFile(named name: String, from target: CommentTarget) {
guard isEditable else { return }
removeAttachment?(name, target)
reload()
}
// MARK: - The close flush
/// **The window's close, in the order the brief fixes: saves first, purge last.**
///
/// The inline session flushes as the body's does (a flush, never a revert a close is not an
/// abandon), then the composer's draft lands, and only then is `comments/.trash/` emptied. The
/// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and
/// running it before a session's save could remove a folder that save was about to write into.
///
/// Ending twice does nothing the second time the sessions latch, and a purge over an empty
/// trash is a no-op which is what makes the two paths that call this (a window closed on its
/// own, and the board's close flush driving it) safe to both exist.
public func endSession() {
editing?.endOnClose()
editing = nil
composer.flush()
purgeTrash?()
}
/// Whether this pane holds content its files do not the inline session's buffer, and only it.
///
/// **The draft is deliberately not counted.** 05 is explicit that the composer needs no
/// save-or-lose ceremony ("Close and quit just proceed no DirtyBufferGuard, nothing to lose"),
/// and this property's one consumer is File Save as Template's carve-out, which is about
/// keystrokes a suspended save cannot flush. An inline comment edit *is* such a case it is a
/// body-edit session in miniature so it counts exactly as the body's does.
public var holdsUnsavedContent: Bool {
editing?.isDirty == true
}
}
// MARK: - The focused value
/// The focused card window's comments pane, beside `FocusedValues.cardAttachments` see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
struct FocusedCardCommentsKey: FocusedValueKey {
typealias Value = CardComments
}
extension FocusedValues {
var cardComments: CardComments? {
get { self[FocusedCardCommentsKey.self] }
set { self[FocusedCardCommentsKey.self] = newValue }
}
}
+127
View File
@@ -0,0 +1,127 @@
import SwiftUI
// MARK: - View Show Comments
/// View Show Comments (checkmark toggle, **no default chord**) 11-command-nexus.md;
/// 05-card-window.md The comments column.
///
/// ### One bit, app-wide, persisted and the checkmark *is* the bit
///
/// > **View Show Comments** is a checkmark toggle à la Show Trash, and its choice is **app-wide and
/// > persisted across restarts**. One bit, no content-derived auto-show: checked, every card window
/// > carries the pane (a comment-less card shows the empty thread and the composer the invitation
/// > is the point); unchecked, threads and drafts are out of sight until the user says otherwise, the
/// > Show Trash bargain. The checkmark reads the bit the menu never lies.
///
/// `@AppStorage` is that sentence with no machinery under it: the row's `isOn` reads the very default
/// every card window's pane reads, so there is no per-window mirror to keep in step and no auto-show
/// rule that could disagree with the tick. "Deleting the last comment never closes the pane" needs no
/// code at all for the same reason nothing but this row writes the key.
///
/// **No App Group.** The suite is `UserDefaults.standard`, which the sandbox already scopes to this
/// one app (`AppPreferences`); the group suite the 2026-07-29 ruling named went with the App Group
/// itself when the edition split collapsed.
///
/// Validation is **scope and nothing else**: with no card window in front there is no `cardComments`
/// focused value and the row disables. The read-only lock is deliberately not part of it showing a
/// pane is not a mutation, exactly as entering Edit is not (`EditBodyCommand`).
struct ShowCommentsCommand: View {
@FocusedValue(\.cardComments) private var comments
@AppStorage(AppPreferences.showCommentsKey) private var isShown = true
/// The row's validation, as a value a test can hold `EditBodyCommand.isEnabled`'s shape, for
/// its reason: a menu item's `.disabled` is otherwise only observable by driving the menu bar.
static func isEnabled(_ comments: CardComments?) -> Bool {
comments != nil
}
var body: some View {
Toggle("Show Comments", isOn: $isShown)
.disabled(!Self.isEnabled(comments))
}
}
// MARK: - View Comments Beside Body
/// View Comments Beside Body (checkmark toggle, **no default chord**) "checked = side-by-side
/// (default), unchecked = body over comments; app-wide, persisted" (11-command-nexus.md;
/// 05-card-window.md Composition).
///
/// `ShowCommentsCommand`'s shape exactly, and for its reasons one persisted bit, the checkmark
/// reading it, scope-only validation. The two rows sit together because they are the pane's two
/// user-facing facts and 11 lists them adjacent.
///
/// It stays enabled while Show Comments is off. The row is a *layout* preference, not a second
/// visibility switch, and a user arranging their window before turning the pane on is doing something
/// perfectly ordinary the alternative (disabling it) would also make the checkmark lie about a bit
/// that is still stored and still applies the moment the pane appears.
struct CommentsBesideBodyCommand: View {
@FocusedValue(\.cardComments) private var comments
@AppStorage(AppPreferences.commentsBesideBodyKey) private var isBeside = true
static func isEnabled(_ comments: CardComments?) -> Bool {
comments != nil
}
var body: some View {
Toggle("Comments Beside Body", isOn: $isBeside)
.disabled(!Self.isEnabled(comments))
}
}
// MARK: - File Add Comment
/// File Add Comment (**no default chord**) card window, all tiers (11-command-nexus.md;
/// 05-card-window.md The comments column).
///
/// ### One gesture, two effects, in the one order that works
///
/// > **File Add Comment** flips the bit on when it's off (the gesture *is* the user choosing to see
/// > comments same persistence) and focuses the composer in one gesture.
///
/// The write comes first and the focus request second, because the composer does not exist to be
/// focused until the pane is mounted. The request is a counter on the window's handle rather than a
/// call into a text view (`CardComments.focusComposerRequests`), so the pane picks it up on the
/// update after the one that mounted it which is the only ordering that survives the pane arriving
/// in the same frame.
///
/// **Flipping the bit is persisted like any other flip of it.** That is the ruling, stated in the
/// design as "the same user choice": a user who reaches for Add Comment has said they want to see
/// comments, and a visibility that reverted at the next window would make the row a one-shot.
///
/// Validation is scope. The read-only lock is not part of it, `EditBodyCommand`'s rule: this focuses
/// a text surface, and 02-architecture.md keeps editor buffers alive under the lock (only their saves
/// suspend), so a locked board can still be typed into and its text copied out. What the lock does
/// disable is the composer's Post button and its paperclip, in place, where they are.
struct AddCommentCommand: View {
@FocusedValue(\.cardComments) private var comments
@AppStorage(AppPreferences.showCommentsKey) private var isShown = true
static func isEnabled(_ comments: CardComments?) -> Bool {
comments != nil
}
/// What the row does to the two pieces of state it touches, as a pure pair a test can drive: the
/// bit's new value, and whether a focus request is owed.
///
/// It is extracted for the reason every menu-row rule in this codebase is: "turns it on when it
/// is off, and focuses either way" is one sentence with two clauses, and the clause that
/// regresses silently is the second one a row that only focused when it had just turned the
/// pane on would look correct in the demo and be wrong every time after.
static func effect(isShown: Bool) -> (isShown: Bool, focusesComposer: Bool) {
(isShown: true, focusesComposer: true)
}
var body: some View {
Button("Add Comment") {
let effect = Self.effect(isShown: isShown)
isShown = effect.isShown
guard effect.focusesComposer else { return }
comments?.focusComposer()
}
.disabled(!Self.isEnabled(comments))
}
}
+219
View File
@@ -0,0 +1,219 @@
import CoreGraphics
import Foundation
// MARK: - Where the comments pane mounts
/// **Body and comments, side by side or stacked** the layout option, as a value
/// (05-card-window.md Composition, ruled 2026-07-29: "side-by-side is the default; **View
/// Comments Beside Body** unchecked stacks them body pane above, comments pane below at a fixed
/// 3:2 split, each keeping its own scroll for narrow displays. App-wide, persisted").
///
/// It is an enum rather than the `Bool` the menu row stores because the two mounts differ in three
/// arithmetic facts the axis, the split, and whether the window's minimum width grows and a
/// `Bool` at each of those three sites is three chances to read it backwards.
///
/// **The panes are identical in both mounts.** Nothing below describes a pane; it describes the frame
/// one is given. That is the componentization the design asks for stated as code: `CardCommentsPane`
/// has no idea which of these it is inside, and neither has the body column.
public enum CommentsMount: Sendable, Equatable {
/// Comments beside the body, sharing the window's width. The default.
case beside
/// Comments under the body, sharing the window's height at 3:2.
case stacked
/// The menu row's bit, read the one way checked means beside.
public init(besideBody: Bool) {
self = besideBody ? .beside : .stacked
}
/// **The stacked split: three parts body to two parts comments** 05's "3:2", written once.
///
/// A fraction rather than a point height so the split survives every window size and every system
/// text size, and a *pure* one so "the body keeps three fifths" is a fact a suite asserts rather
/// than something checked by eye at one window height.
public static let stackedBodyFraction: CGFloat = 3.0 / 5.0
/// How tall the body pane is in a window `height` tall `height` itself when the panes are side
/// by side, since then the body owns the full column.
///
/// Clamped at zero: a window mid-animation can propose a negative height, and a frame with one
/// would be a layout error rather than a small pane.
public func bodyHeight(in height: CGFloat) -> CGFloat {
switch self {
case .beside: max(0, height)
case .stacked: max(0, height * Self.stackedBodyFraction)
}
}
/// The comments pane's height, the remainder so the two always add up to the window and the
/// divider between them never has a gap or an overlap to account for.
public func commentsHeight(in height: CGFloat) -> CGFloat {
max(0, height) - bodyHeight(in: height)
}
/// **Whether the window's minimum width grows** "the window's minimum width grows only while
/// the column is shown side-by-side" (05 Composition).
///
/// Stacked, the comments pane takes the width the body already had, so a narrow display keeps the
/// minimum it has always had. That is the whole reason the option exists.
public var widensWindow: Bool { self == .beside }
}
// MARK: - Which way the thread runs
/// **The thread's sort direction** "chronological ascending by default, flippable to newest-first
/// (app-wide, persisted)" (05-card-window.md The comments column).
///
/// The *order* itself is storage's (`CommentThread.sorted`, `created` ascending with the undated
/// after the dated and a canonical folder-name tie-break) and is not re-derived here: descending is
/// that order reversed, so a tie between two undated comments breaks the same way in both directions
/// rather than two sort predicates agreeing by luck.
public enum CommentSortDirection: Sendable, Equatable, CaseIterable {
/// Oldest first chronology as it happened, and the default.
case ascending
/// Newest first.
case descending
/// The menu/header control's bit, read the one way.
public init(newestFirst: Bool) {
self = newestFirst ? .descending : .ascending
}
public var isNewestFirst: Bool { self == .descending }
/// Applies the direction to a thread the loader already sorted.
///
/// **A reverse, never a re-sort**: see the type's note the loader's predicate is the only one
/// in the app that decides what "before" means for two comments.
public func apply(to comments: [Comment]) -> [Comment] {
self == .ascending ? comments : comments.reversed()
}
/// **Whether the composer sits above the thread** "The composer sits at the thread's newest end
/// (bottom ascending, top descending)" (05 The comments column).
///
/// One fact, derived once, because the composer's placement and the pane's opening scroll target
/// are the same sentence read twice ("the window opens scrolled to it") and a window that opened
/// at the wrong end would be wrong only for descending users.
public var placesComposerFirst: Bool { self == .descending }
/// What the header control says it will do the help text and the accessibility label, which are
/// the same string and must stay so.
public var controlLabel: String {
self == .ascending ? "Oldest First" : "Newest First"
}
}
// MARK: - The header's count
/// The comments pane's small-caps header line **"Comments · 3"** (05-card-window.md The comments
/// column: "The section header carries the count").
///
/// Pure and separate from the view for the reason every count line in this app is: the empty case is
/// the one that gets written wrong, and 05 is explicit that a comment-less card still shows the pane
/// ("the empty thread and the composer the invitation is the point"), so the zero has to render as
/// a count rather than as an absence.
public enum CommentsHeader {
public static func title(count: Int) -> String {
"Comments · \(count)"
}
}
// MARK: - One comment's author line
/// The line above a comment's body: **who says they wrote it, when, and whether it has been edited**
/// (05-card-window.md The comments column: "an author line (self-reported `author`, unattributed
/// when absent; timestamp; '· edited' when `modified` differs from `created`)").
///
/// ### Absent means absent
///
/// "Missing renders unattributed" (`Comment.author`) and *unattributed* is the absence of a name,
/// not the word "unattributed" drawn in its place. A placeholder there would be this app inventing an
/// identity for a file that deliberately carries none, which is the same reason there are no avatars.
/// So a comment with no `author` renders its timestamp alone, and one with neither renders nothing at
/// all rather than an empty row of separators.
public enum CommentAuthorLine {
/// The separator every segment of this window's quiet lines uses the card's created/modified
/// line's, shared so the two read as one family.
private static let separator = " · "
/// Composes the line, or `nil` when there is nothing to say.
///
/// - Parameters:
/// - author: the self-reported `author`, already unwrapped. An **empty** string is treated as
/// absent: the Writer never writes `author: ""` (it omits the key instead), so one on disk is
/// a hand edit, and rendering a blank name with a separator beside it would be noise.
/// - timestamp: the already-formatted `created`, or `nil` where the key is missing or
/// unreadable (the coerce tier a comment with no date still renders).
/// - isEdited: `Comment.isEdited`.
public static func text(author: String?, timestamp: String?, isEdited: Bool) -> String? {
var parts: [String] = []
if let author, !author.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
parts.append(author)
}
if let timestamp, !timestamp.isEmpty {
parts.append(timestamp)
}
// "· edited" hangs off something. On a comment with neither a name nor a date there is no
// line to hang it from, and a bare "edited" would be a row that says only that.
guard !parts.isEmpty else { return nil }
if isEdited {
parts.append("edited")
}
return parts.joined(separator: separator)
}
}
// MARK: - The drop carve-out
/// **Which folder a file dropped in this window lands in** (05-card-window.md Attachments, ruled
/// 2026-07-29: "One carve-out by hover target a file dropped **within the comment composer's
/// bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's
/// bounds** to that comment's the window-wide card default covers everywhere else").
///
/// ### Why this is a value and not three `.onDrop`s trusted to nest correctly
///
/// SwiftUI does dispatch a drop to the innermost target, and the implementation leans on exactly
/// that the composer and the inline editor each carry their own drop delegate inside the
/// window-wide one. But *what the rule is* and *whether the nesting expresses it* are two questions,
/// and only the first one is checkable without a running window. This enum is the first question's
/// answer; the modifiers are the second's.
///
/// ### The precedence, and why it is stated at all
///
/// An inline edit session opens **over a comment row**, and the composer is a separate surface at the
/// thread's newest end, so in practice the two never overlap and the order is moot. It is fixed
/// anyway the inline session wins because the case where it stops being moot is a layout change,
/// and a layout change should not be able to silently move a user's files into the wrong folder.
public enum CommentDropCarveOut {
/// What the pointer is over, as the window knows it.
///
/// Deliberately two independent facts rather than one enum: each surface answers only for itself
/// (a drop delegate knows its own bounds and nothing else), and the arbitration is this type's.
public struct Hover: Sendable, Equatable {
public var isOverComposer: Bool
public var inlineEdit: ItemID?
public init(isOverComposer: Bool = false, inlineEdit: ItemID? = nil) {
self.isOverComposer = isOverComposer
self.inlineEdit = inlineEdit
}
}
/// Where the files go.
public enum Landing: Sendable, Equatable {
/// The window-wide default: the **card**'s `attachments/` (`CardWindowDropDelegate`).
case card
/// One of the two authoring surfaces.
case comment(CommentTarget)
}
/// The rule, in one expression.
public static func landing(for hover: Hover) -> Landing {
if let editing = hover.inlineEdit { return .comment(.comment(editing)) }
if hover.isOverComposer { return .comment(.draft) }
return .card
}
}
+206
View File
@@ -0,0 +1,206 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
// MARK: - CardCommentsPane
/// The card window's **comments pane** the middle of the three componentized panes
/// (05-card-window.md Composition, The comments column).
///
/// ### It does not know where it is mounted
///
/// Beside the body or under it, the pane is identical "the panes are identical in both mounts"
/// so nothing in this file asks. It fills the frame it is given, scrolls its own content, and the
/// arrangement is `CardWindowView`'s (`CommentsMount`). That is the componentization the 2026-07-29
/// re-composition asks for, stated as an absence: there is no layout parameter here to get wrong.
///
/// ### Header, thread, composer and the composer is at the newest end
///
/// > The composer sits at the thread's newest end (bottom ascending, top descending) and the window
/// > opens scrolled to it a thread opens where the conversation is happening.
///
/// Both halves come from one value (`CommentSortDirection.placesComposerFirst`), so the scroll target
/// and the composer's position cannot disagree a window that opened at the wrong end would be wrong
/// only for the users who had flipped the sort, which is exactly the bug that ships.
struct CardCommentsPane: View {
let comments: CardComments
/// The **card**'s folder what relative images and links in every comment resolve against.
let cardFolder: URL?
/// The window's thumbnail memory, shared with the sidebar's attachment rows so a file shown in
/// both places is rendered once.
let thumbnails: AttachmentThumbnailCache
/// **App-wide and persisted** (05 The comments column; 11-command-nexus.md files the header
/// control under Configuration controls). Read here rather than mirrored onto the window's handle
/// because there is exactly one of it and every open pane obeys it.
@AppStorage(AppPreferences.commentsNewestFirstKey) private var newestFirst = false
/// The composer's scroll anchor. A constant rather than a generated id because there is one
/// composer and two possible places for it.
private static let composerAnchor = "comments.composer"
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private var direction: CommentSortDirection { CommentSortDirection(newestFirst: newestFirst) }
private var ordered: [Comment] { direction.apply(to: comments.thread.comments) }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize))
thread
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// 10-accessibility.md's container label for the pane ("Comments, N"). The elements inside it
// and their custom actions are phase 3's; the container is here because the pane would
// otherwise be an unnamed region the moment it exists.
.accessibilityElement(children: .contain)
.accessibilityLabel("Comments, \(comments.thread.comments.count)")
}
// MARK: - Header
/// "Comments · 3" with the sort control beside it the sidebar's own small-caps section header,
/// shared rather than restated so the pane and the sidebar read as one window (05: "the section
/// header carries the count and the sort-direction control").
private var header: some View {
CardSidebarSectionHeader(title: CommentsHeader.title(count: comments.thread.comments.count)) {
sortControl
}
}
/// The sort-direction control **Tab-reachable beside the count** (11-command-nexus.md
/// Configuration controls).
///
/// A button rather than a segmented picker: there are two states and the second one is the
/// reverse of the first, so a toggle whose glyph says which way the thread currently runs is the
/// smaller thing that says the same. Its help text and its accessibility label are the same
/// string (`CommentSortDirection.controlLabel`) one label, two readers.
private var sortControl: some View {
Button {
newestFirst.toggle()
} label: {
Image(systemName: direction == .ascending ? "arrow.down" : "arrow.up")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(direction.controlLabel)
.accessibilityLabel("Sort")
.accessibilityValue(direction.controlLabel)
}
// MARK: - The thread
private var thread: some View {
ScrollViewReader { proxy in
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) {
if direction.placesComposerFirst {
composer.id(Self.composerAnchor)
}
ForEach(ordered) { comment in
CommentRowView(
comment: comment,
comments: comments,
cardFolder: cardFolder,
thumbnails: thumbnails
)
.id(comment.id)
}
if !direction.placesComposerFirst {
composer.id(Self.composerAnchor)
}
}
.padding(CardWindowMetrics.gutter(bodyPointSize: pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
}
// **The window opens scrolled to the composer** (05). Deferred one turn rather than run
// inline: `scrollTo` needs the content laid out to have somewhere to scroll to, and a
// thread's rows measure their own rendered height (`CommentBodyView`).
.task {
await Task.yield()
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
}
// File Add Comment focuses the composer which is no use if the composer is off
// screen, so the same request scrolls to it. One request, both effects.
.onChange(of: comments.focusComposerRequests) { _, _ in
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
}
}
}
// MARK: - The composer
/// **The composer edits `comments/.draft/`** always visible, at the thread's newest end
/// (05 The comments column).
private var composer: some View {
CommentComposerView(comments: comments, thumbnails: thumbnails)
}
}
// MARK: - The authoring surfaces' drop carve-out
/// **A file dropped within an authoring surface's bounds lands in *that* surface's `attachments/`**
/// (05-card-window.md Attachments, ruled 2026-07-29 the hover-target carve-out on the
/// window-wide card default).
///
/// The *arbitration* is SwiftUI's own innermost-target dispatch: this delegate is attached **inside**
/// `CardWindowDropDelegate`'s region, so a drag released over the composer or over an open inline
/// editor is offered here first and never reaches the window's card default.
///
/// The *destination* is `CommentDropCarveOut`, which is why this takes a hover rather than a folder:
/// the surface says what the pointer is over and the pure rule says where the files go, so the ruling
/// including which authoring surface wins where they would ever overlap is checkable without a
/// window and cannot drift from what the delegate actually does. A hover the rule resolves to the
/// **card** never reaches here at all (no authoring surface is under the pointer, so no authoring
/// surface has a drop target on screen), and this refuses it rather than guessing a folder.
///
/// Everything else is `CardWindowDropDelegate`'s, deliberately: the same payload predicate (files,
/// not folders, not text), the same read-only refusal, the same `.copy` badge, and the same
/// asynchronous URL load with the sandbox's security scope around it. Only the destination differs,
/// which is the entire point of the carve-out.
struct CommentAttachmentDropDelegate: DropDelegate {
let comments: CardComments
/// What the pointer is over, as this surface knows it see `CommentDropCarveOut.Hover`.
let hover: CommentDropCarveOut.Hover
/// Where the rule says the files go, or `nil` for the window-wide card default.
private var target: CommentTarget? {
guard case let .comment(target) = CommentDropCarveOut.landing(for: hover) else { return nil }
return target
}
private var acceptsFileDrops: Bool { comments.isEditable && target != nil }
func validateDrop(info: DropInfo) -> Bool {
guard acceptsFileDrops else { return false }
return CardWindowDrop.accepts(
payloads: info.itemProviders(for: [.fileURL]).map(\.registeredTypeIdentifiers)
)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: validateDrop(info: info) ? .copy : .cancel)
}
func performDrop(info: DropInfo) -> Bool {
guard acceptsFileDrops, let target else { return false }
let providers = info.itemProviders(for: [.fileURL])
guard !providers.isEmpty else { return false }
let comments = comments
Task { @MainActor in
var urls: [URL] = []
for provider in providers {
if let url = await FileDropLoading.url(from: provider) { urls.append(url) }
}
guard !urls.isEmpty else { return }
comments.importFiles(urls, to: target)
}
return true
}
}
+71 -3
View File
@@ -119,6 +119,61 @@ enum CardWindowMetrics {
previewPadding(bodyPointSize: bodyPointSize)
}
// MARK: - The comments pane
/// How wide the comments pane is when it is mounted **beside** the body, in characters
/// (05-card-window.md Composition; The comments column).
///
/// Wider than the sidebar and narrower than the body's default measure, which is what it holds:
/// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer. It is a
/// *fixed* width for the sidebar's reason "resize flex always goes to the body, never the fixed
/// panes" (05 Composition) so this is not a fraction of anything either.
///
/// Stacked, the pane takes the body's width instead and this number is not consulted at all,
/// which is why the window's minimum grows only in the beside mount (`CommentsMount.widensWindow`).
static let commentsColumnCharacters: CGFloat = 40
static func commentsColumnWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: commentsColumnCharacters, bodyPointSize: bodyPointSize)
}
/// The narrowest the comments pane is allowed to get the floor its share of the window's
/// minimum is measured at, a shorter measure than the body's because a comment is a remark rather
/// than a document.
static let commentsMinimumCharacters: CGFloat = 28
static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
}
/// The smallest an attachment chip may be before the row wraps a thumbnail, a few characters of
/// filename, and the padding around them. Middle truncation does the rest, so a long name shrinks
/// rather than widening the pane.
static func commentChipMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
(attachmentThumbnailSide(bodyPointSize: bodyPointSize) + bodyPointSize * 6).rounded()
}
/// The composer's resting height **four lines and a bit**, which is the shape of the thing it
/// invites: enough that a two-sentence remark is visible whole, short enough that it never
/// dominates a thread. It scrolls internally past that rather than growing the pane, so a long
/// draft cannot push the thread off screen.
static func composerHeight(bodyPointSize: CGFloat) -> CGFloat {
(lineHeight(bodyPointSize: bodyPointSize) * 4.5).rounded()
}
/// An inline edit session's editor, one line taller than the composer: it opens over text that
/// already exists, so the common case is reading it before changing it.
static func inlineEditorHeight(bodyPointSize: CGFloat) -> CGFloat {
(lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
}
/// The gap between two comments in the thread a full gutter, one step larger than the rhythm
/// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
/// rather than with its neighbour.
static func commentSpacing(bodyPointSize: CGFloat) -> CGFloat {
gutter(bodyPointSize: bodyPointSize)
}
// MARK: - The rendered body
/// One step of structural indent in Preview a list level, a quote level. One and a half ems,
@@ -149,9 +204,22 @@ enum CardWindowMetrics {
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
/// for a title, its date line and a few lines of body.
static func minimumSize(bodyPointSize: CGFloat) -> CGSize {
CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize) + bodyMinimumWidth(bodyPointSize: bodyPointSize),
///
/// - Parameter commentsColumn: whether the comments pane is currently mounted **beside** the body
/// "the window's minimum width grows only while the column is shown side-by-side"
/// (05-card-window.md Composition). Stacked, or hidden, the pane costs the window no width at
/// all, which is the narrow-display case the layout option exists for. The height is unchanged
/// either way: a stacked pane divides the height it is given rather than demanding more, and a
/// window at its minimum height simply gets a short thread.
///
/// Defaulted to `false` so every caller that predates the comments pane still asks the same
/// question it always did.
static func minimumSize(bodyPointSize: CGFloat, commentsColumn: Bool = false) -> CGSize {
let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0
return CGSize(
width: sidebarWidth(bodyPointSize: bodyPointSize)
+ bodyMinimumWidth(bodyPointSize: bodyPointSize)
+ comments,
height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
)
}
+91 -3
View File
@@ -3,8 +3,22 @@ import UniformTypeIdentifiers
// MARK: - CardWindowView
/// The card window's content: **two full-height, independently scrolling columns** a wide body
/// column leading, a narrow attributes sidebar trailing (05-card-window.md Composition).
/// The card window's content: **three componentized panes** a wide body pane leading, the comments
/// pane in the middle when it is shown, and the narrow attributes sidebar trailing
/// (05-card-window.md Composition, re-composed 2026-07-29).
///
/// ### The composition arranges; the panes do not know about each other
///
/// > each an independent component with its own scroll, arranged by the window's layout rather than
/// > wired to each other; componentization is the rule, so the comments pane mounts beside the body
/// > or below it (the layout option) without either pane knowing which.
///
/// That is enforced here by there being nothing to enforce: `CardCommentsPane` takes no layout
/// parameter and the body column takes none either. This view puts one of them in a frame; the
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
///
/// The sidebar is unchanged by any of it it is a third pane, it has always been fixed-width, and
/// the resize flex still goes to the body and never to the two fixed panes.
///
/// ### What this milestone builds, and what it deliberately does not
///
@@ -71,6 +85,10 @@ struct CardWindowView: View {
/// This window's attachments section: the listing, the selection, and the two writes it starts
/// (05 Attachments).
let attachments: CardAttachments
/// This window's comments pane: the thread, the composer's draft buffer, and the one open inline
/// edit session (05 The comments column). It lives on the window's *session* so the close flush
/// can reach it, which is why it arrives here rather than being made here.
let comments: CardComments
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache
/// The whole-window file drop (05 Attachments: "the drop surface remains the **whole
@@ -79,11 +97,24 @@ struct CardWindowView: View {
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
let onToggleTask: (Int, Bool) -> Void
/// **View Show Comments** and **View Comments Beside Body** app-wide, persisted, read here
/// rather than passed in (05 The comments column; Composition).
///
/// `@AppStorage` because the two bits genuinely are app-wide: every open card window obeys the
/// same pair, so a window that took them as parameters would need something above it keeping
/// every window in step with a value that has exactly one instance. It is also what makes the
/// menu rows' checkmarks and these panes provably the same bit (`ShowCommentsCommand`).
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
/// The body font's point size, read once per body evaluation: every measurement in this view
/// the sidebar's width, both gutters, the vertical rhythm is derived from it, so they scale
/// together when the system text size changes.
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// Where the comments pane sits, when it is shown at all.
private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }
/// The two columns **or the raw-source editor in place of both of them**.
///
/// A swap rather than an overlay, which is 05 Raw source outlet's own word for it ("swaps the
@@ -111,10 +142,14 @@ struct CardWindowView: View {
@ViewBuilder
private var content: some View {
if rawSource.isActive {
// **All three panes**, comments included: "Raw Source still swaps the entire content area
// all panes, comments included; the raw outlet's rule is unchanged" (05 The comments
// column). The swap encloses the whole composition below rather than any one pane, which
// is what keeps that true as the composition grows.
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
} else {
HStack(spacing: 0) {
bodyColumn
contentPanes
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Divider()
@@ -128,6 +163,59 @@ struct CardWindowView: View {
}
}
/// The body pane and the comments pane, in whichever of the two mounts is current or the body
/// pane alone, when Show Comments is off.
///
/// **The thread stays visible through body Edit in either mount**, and needs no rule of its own:
/// Edit swaps the content of the body pane (`CardBodySurface`), which is *inside* the body column
/// here, so nothing about the composition changes when the mode flips. That is the sidebar's own
/// precedent, which 05 names when it states the rule.
@ViewBuilder
private var contentPanes: some View {
if showComments {
switch mount {
case .beside:
HStack(spacing: 0) {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Divider()
commentsPane
// Fixed, like the sidebar: "resize flex always goes to the body, never the
// fixed panes" (05 Composition).
.frame(width: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize))
.frame(maxHeight: .infinity, alignment: .top)
}
case .stacked:
// The 3:2 split needs a height to divide, and a `GeometryReader` is the only way to
// have one `layoutPriority` and flexible frames express *preferences*, and this is
// a ratio the design fixes. The body takes its share; the comments pane takes the
// remainder, so the divider between them can never leave a gap or overlap.
GeometryReader { proxy in
VStack(spacing: 0) {
bodyColumn
.frame(height: mount.bodyHeight(in: proxy.size.height))
.frame(maxWidth: .infinity, alignment: .topLeading)
Divider()
commentsPane
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
} else {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private var commentsPane: some View {
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
}
// MARK: - Body column
/// Title, the quiet created/modified line, then the body 05's top-to-bottom order.
+168
View File
@@ -0,0 +1,168 @@
import AppKit
import SwiftUI
// MARK: - The chip row
/// A comment's or the draft's `attachments/`, as **chips** (05-card-window.md The comments column:
/// "attachment chips when its `attachments/` is non-empty (Quick Look, the sidebar section's
/// pattern)").
///
/// ### Chips, not rows and the same parts
///
/// The sidebar's inventory is a vertical list because it is a *complete* listing of a card's files in
/// a narrow column. A comment's files are a handful of things said in passing, so they wrap
/// horizontally under the text that mentions them. What does not change is the anatomy the small
/// QuickLook thumbnail with its Finder-icon fallback, the middle-truncated filename, Space/click to
/// Quick Look because that is what "the sidebar section's pattern" names, and a user who has
/// learned the sidebar has learned this.
///
/// ### The one difference that is a rule
///
/// > Chips on an authoring surface carry remove (to the **system** Trash the sidebar row's rule); a
/// > posted comment's chips are read-only, Quick Look only Edit the comment to change its files.
///
/// `onRemove` is that sentence: `nil` is a posted comment's chip and there is no remove affordance at
/// all not a disabled one, because the file is not un-removable, it is simply not removable *here*.
struct CommentAttachmentChips: View {
let names: [String]
/// Where each name lives the pane resolves it, since only it knows which authoring surface (or
/// which posted comment) these belong to.
let url: (String) -> URL?
let thumbnails: AttachmentThumbnailCache
/// `nil` on a posted comment's read-only chips; the remove write on an authoring surface's.
var onRemove: ((String) -> Void)?
@Environment(\.displayScale) private var displayScale
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
// Wrapping, because a chip row is horizontal and a comment can carry more files than fit:
// `Layout`-free wrapping through a flexible `WrappingHStack` would be a new layout to own, so
// this leans on SwiftUI's own a `LazyVGrid` with adaptive columns wraps and needs nothing.
LazyVGrid(
columns: [GridItem(
.adaptive(minimum: CardWindowMetrics.commentChipMinimumWidth(bodyPointSize: pointSize)),
spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize),
alignment: .leading
)],
alignment: .leading,
spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize)
) {
ForEach(names, id: \.self) { name in
chip(name)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func chip(_ name: String) -> some View {
let fileURL = url(name)
let side = CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize)
let padding = CardWindowMetrics.previewPadding(bodyPointSize: pointSize)
HStack(spacing: padding) {
CommentChipThumbnail(
url: fileURL,
side: side,
thumbnails: thumbnails,
displayScale: displayScale
)
.frame(width: side, height: side)
Text(name)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
if let onRemove {
Button {
onRemove(name)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Remove")
.accessibilityLabel("Remove \(name)")
}
}
.padding(.horizontal, padding)
.padding(.vertical, padding / 2)
.background(.quaternary, in: Capsule(style: .continuous))
.contentShape(Capsule(style: .continuous))
// Quick Look on click, the chip being small enough that a select-then-Space dance would be
// ceremony over a thing you can already point at. The panel's / then walk this surface's
// files, exactly as Space over the sidebar walks the card's.
.onTapGesture {
quickLook(name)
}
.contextMenu {
Button("Open") {
guard let fileURL else { return }
NSWorkspace.shared.open(fileURL)
}
Button("Reveal in Finder") {
guard let fileURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
}
if let onRemove {
Divider()
Button("Remove") { onRemove(name) }
}
}
.help(name)
.accessibilityElement(children: .combine)
.accessibilityLabel(name)
}
private func quickLook(_ name: String) {
guard let index = names.firstIndex(of: name) else { return }
AttachmentQuickLook.shared.toggle(urls: names.compactMap(url), at: index)
}
}
// MARK: - One chip's thumbnail
/// The generated QuickLook thumbnail once there is one, the file's Finder icon until then and
/// forever, for anything QuickLook declines. `AttachmentRow`'s own fallback ladder, shared by being
/// written the same way rather than by being the same view: the sidebar's row is a row, this is a
/// chip, and only the picture is common.
private struct CommentChipThumbnail: View {
let url: URL?
let side: CGFloat
let thumbnails: AttachmentThumbnailCache
let displayScale: CGFloat
private var slot: AttachmentThumbnailKey.Slot? {
url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) }
}
var body: some View {
content
.task(id: url?.path) {
guard let slot, let url else { return }
await thumbnails.load(slot, url: url, scale: displayScale)
}
}
@ViewBuilder
private var content: some View {
if let slot, let image = thumbnails.thumbnail(for: slot) {
Image(decorative: image, scale: displayScale)
.resizable()
.aspectRatio(contentMode: .fit)
} else if let url {
Image(nsImage: thumbnails.icon(forFileAt: url))
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "doc")
.foregroundStyle(.secondary)
}
}
}
+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
}
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
// MARK: - The shared authoring chrome
/// What the composer and an inline comment edit session **both** are: a Markdown editor, the
/// surface's own attachment chips with remove, a quiet paperclip, and a drop target aimed at this
/// surface's folder (05-card-window.md The comments column, ruled 2026-07-29 "the same pair
/// applies within an inline comment edit session, targeting that comment's").
///
/// It is one view rather than two near-copies because the design states the composer's rules and then
/// says "and the same for an inline edit". Two implementations of that sentence would be two places
/// for the paperclip to open a different panel or for a drop to land in the wrong folder and the
/// wrong folder is not a bug a user can see until they go looking in Finder.
///
/// What differs between the two surfaces arrives as parameters and nothing more: which buffer, what
/// means, what Escape means, the placeholder, the height, and the buttons underneath.
struct CommentAuthoringSurface<Actions: View>: View {
let comments: CardComments
/// Which folder this surface's files land in the draft's, or the comment being edited.
let target: CommentTarget
let text: String
/// Shown over an empty editor. `nil` on the inline editor, which opens over text that exists.
var placeholder: String?
let height: CGFloat
var focusRequest: Int = 0
let attachments: [String]
let thumbnails: AttachmentThumbnailCache
let onEdit: (String) -> Void
let onCommandReturn: () -> Void
let onEscape: () -> Void
var onBlur: () -> Void = {}
@ViewBuilder var actions: Actions
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private var padding: CGFloat { CardWindowMetrics.previewPadding(bodyPointSize: pointSize) }
/// What the pointer is over while this surface is under it the carve-out's input, derived from
/// the surface's own identity rather than passed in beside it, so a surface can only ever report
/// being itself (`CommentDropCarveOut`).
private var hover: CommentDropCarveOut.Hover {
switch target {
case .draft: CommentDropCarveOut.Hover(isOverComposer: true)
case let .comment(id): CommentDropCarveOut.Hover(inlineEdit: id)
}
}
var body: some View {
VStack(alignment: .leading, spacing: padding) {
editor
if !attachments.isEmpty {
CommentAttachmentChips(
names: attachments,
url: { comments.attachmentURL($0, in: target) },
thumbnails: thumbnails,
// **Authoring chips carry remove** to the system Trash, never a hard delete.
onRemove: comments.isEditable ? { comments.removeFile(named: $0, from: target) } : nil
)
}
HStack(spacing: padding) {
paperclip
Spacer(minLength: 0)
actions
}
}
// The carve-out's whole mechanism: a drop target *inside* the window-wide one, so SwiftUI
// offers this surface the drag first (`CommentAttachmentDropDelegate`).
.onDrop(
of: [.fileURL],
delegate: CommentAttachmentDropDelegate(comments: comments, hover: hover)
)
}
// MARK: Editor
private var editor: some View {
ZStack(alignment: .topLeading) {
CommentTextEditor(
text: text,
// Under the read-only lock the buffer stays alive and only its saves suspend
// (02-architecture.md § the lock's scope) but a *composer* under the lock has
// nothing to suspend into, so the editor disables in place like every other
// mutation entry point in this window.
isEditable: comments.isEditable,
onEdit: onEdit,
onCommandReturn: onCommandReturn,
onEscape: onEscape,
onBlur: onBlur,
focusRequest: focusRequest
)
.frame(height: height)
if let placeholder, text.isEmpty {
Text(placeholder)
.font(.body)
.foregroundStyle(.tertiary)
.padding(.horizontal, padding + 5)
.padding(.vertical, padding)
// A label, not a control: clicks belong to the editor underneath it.
.allowsHitTesting(false)
}
}
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(.quaternary)
)
}
/// The **quiet paperclip** "a pointer twin" of nothing at all in the menu bar, deliberately:
/// "File Add Attachment stays card-scoped" (05 The comments column), so this surface's
/// no-drag path is the affordance and only the affordance. It opens the same panel the sidebar's
/// plus does (`AttachmentPanel`), differing in one line of guidance.
private var paperclip: some View {
Button {
comments.addAttachments(to: target)
} label: {
Image(systemName: "paperclip")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!comments.isEditable)
.help("Attach Files…")
.accessibilityLabel("Attach Files")
}
}
// MARK: - The composer
/// **The composer** an always-visible text area whose backing file is `comments/.draft/`
/// (05-card-window.md The comments column).
///
/// ### Restore-on-reopen is not implemented here, and that is the design
///
/// > The composer edits `comments/.draft/` Restore-on-reopen falls out for free (the composer just
/// > reads its file).
///
/// The window's open reads the draft (`CardComments.reload`) and the session adopts it; there is no
/// restore path, no per-window memory, and nothing to clear. A draft written on another machine and
/// synced in arrives the same way, because it is the same read.
///
/// ### Escape never discards
///
/// "Escape moves focus out of the composer, draft untouched" (ruled 2026-07-29). Resigning first
/// responder is *also* a blur, which is one of the four cadence moments so Escape saves the draft
/// rather than losing it, which is the exact opposite of what Escape means in a transient bubble and
/// is why the design had to say so out loud.
struct CommentComposerView: View {
let comments: CardComments
let thumbnails: AttachmentThumbnailCache
private var session: CommentDraftSession { comments.composer }
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
CommentAuthoringSurface(
comments: comments,
target: .draft,
text: session.text,
placeholder: "Add a comment…",
height: CardWindowMetrics.composerHeight(bodyPointSize: pointSize),
focusRequest: comments.focusComposerRequests,
attachments: session.attachments,
thumbnails: thumbnails,
onEdit: { session.edited($0) },
onCommandReturn: post,
onEscape: Self.resignFocus,
onBlur: { session.blurred() }
) {
// **The Comment button twins ** (05) one act, two pointers at it, so the button calls
// exactly what the chord calls.
//
// Prominent styling rather than `.defaultAction`, deliberately: the chord this gesture
// owns is (11-command-nexus.md's grammar table), which the editor's own text view
// intercepts, and a default-action binding would additionally claim plain Return in a
// window that already has two authoring surfaces able to claim it at once.
Button("Comment", action: post)
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!comments.isEditable || !session.canPost)
}
.accessibilityLabel("Add a comment")
}
/// Post, then re-read: the rename moved a folder into the thread, and the pane shows the thread.
///
/// The re-read is explicit rather than left to the watcher's reload because a post is a gesture
/// with a visible result the comment appearing and waiting a debounce for FSEvents would make
/// the app look like it had not heard the . The reload lands afterwards and finds the same
/// thing.
private func post() {
guard comments.isEditable, session.canPost else { return }
guard session.postNow() != nil else { return }
comments.reload()
}
/// Escape's whole implementation: **move focus out**, which the blur then saves. Nothing is
/// discarded, because there is nothing here that could be the draft is a durable file.
private static func resignFocus() {
NSApp.keyWindow?.makeFirstResponder(nil)
}
}
+221
View File
@@ -0,0 +1,221 @@
import Foundation
import Observation
// MARK: - CommentDraftSession
/// The composer's buffer: the text the user is typing into `comments/.draft/`, what disk last said,
/// and the **slow** cadence between them (05-card-window.md The comments column).
///
/// ### It is not the body's session, and the difference is the whole point
///
/// `CardBodyEditSession` is a 700 ms trailing debounce: the body is the card, and a card should be on
/// disk almost as fast as it is typed. A draft is neither.
///
/// > **Draft saves are slow-cadence, never prompted** (flow breakage minimized): the draft writes on
/// > composer blur, window close, quit, and a lazy interval (~30 s) not the body editor's 700 ms,
/// > so a Pro user's typing never becomes a commit stream.
///
/// So the timer here is a **lazy interval, not a debounce**: it is armed the moment the buffer first
/// goes dirty and it is *not* restarted by the keystrokes after it. A debounce would never fire while
/// someone was typing steadily and would then fire the instant they paused which is exactly the
/// commit stream the rule exists to prevent, and exactly the wrong moment to interrupt them. An
/// interval fires on its own schedule, at most once per period, whatever the typing is doing.
///
/// ### Escape is not here, and that is a ruling
///
/// "**Escape moves focus out of the composer, draft untouched**" (ruled 2026-07-29 Escape never
/// discards: the draft is a durable file, so 'abandon' has no meaning here; emptying the draft is the
/// discard gesture). There is therefore no `cancel()` on this type at all the absence is the
/// design, not an omission, and adding one later would be adding a way to lose a file.
///
/// ### The emptied draft deletes itself, and this type does not know that
///
/// "A draft emptied of text with no attachments deletes its folder no litter." That rule lives in
/// `BoardWriter.saveCommentDraft`, which is why an emptied composer here simply *saves empty text*
/// and reports `.deleted` back. Re-deriving the condition would be a second place for "no text and no
/// attachments" to mean something slightly different.
@MainActor
@Observable
public final class CommentDraftSession {
// MARK: State
/// What the composer is showing the buffer when it is dirty, disk when it is not, exactly the
/// order `CardBodyEditSession` settles for the body.
public private(set) var text: String = ""
/// What the last read said `comments/.draft/index.md` holds. The write gate's other half; never
/// shown.
public private(set) var disk: String = ""
/// The draft's `attachments/`, republished from every thread read the chips the composer draws,
/// and half of what decides whether there is anything to post.
///
/// It lives here rather than beside the thread because the *rule* it feeds is this session's: a
/// draft with no text but a file in it is still a draft (it does not delete, and it does post).
public var attachments: [String] = []
/// Whether the buffer holds keystrokes the file does not.
public var isDirty: Bool { text != disk }
/// **Whether / the Comment button have anything to post** the emptied-draft rule read
/// forwards: a save of this buffer would delete the folder exactly when there is nothing to post,
/// so the two questions have one answer (`CommentDraft.isEmpty`).
public var canPost: Bool {
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
}
// MARK: Seams
/// The lazy interval **~30 s** (05 The comments column), and settable so a test does not have
/// to spend it. `CardBodyEditSession.debounceInterval`'s precedent, with its production default
/// on the property.
@ObservationIgnored
public var saveInterval: Duration = .seconds(30)
/// Where a save goes `BoardStore.saveCommentDraft(inCard:body:)`, filled in by the window once
/// it has a store and a card to aim at.
///
/// A closure for `CardBodyEditSession.save`'s reason exactly: this type is a buffer and a clock,
/// and it stays testable by having no idea what a board is. `nil` or a `nil` answer, which is
/// what a failed write and a vanished card both give is a save that did not land, and the
/// buffer stays dirty rather than reporting success.
@ObservationIgnored
public var save: ((String) -> CommentDraftOutcome?)?
/// The post `BoardStore.postComment(inCard:)`, which renames `.draft` to a fresh UUID and
/// restamps in one bracket. `nil` answers a post that did not happen.
@ObservationIgnored
public var post: (() -> ItemID?)?
/// How many saves have actually been attempted through `save` the cadence's own testimony,
/// which a test would otherwise have to infer from `mtime`s.
@ObservationIgnored
public private(set) var saveAttempts = 0
@ObservationIgnored
private var pending: Task<Void, Never>?
public init() {}
// MARK: - Disk buffer
/// A thread read arrived. **Dirty-buffer-wins**, the body's rule applied here for its reason: the
/// composer is a text surface with a cursor in it, and a reload landing another machine's draft
/// under that cursor would be the app eating keystrokes.
///
/// `nil` is a card with no draft at all the ordinary state before the first keystroke, and the
/// state a post leaves behind. Disk is then the empty string, which is what a clean composer
/// shows.
public func adopt(draft: CommentDraft?) {
let wasDirty = isDirty
attachments = draft?.attachments ?? []
disk = draft?.body ?? ""
guard !wasDirty else { return }
// Assigning an equal string would still notify observers, and an observer here is a text view
// that would replace its contents under the cursor.
if text != disk { text = disk }
}
// MARK: - Buffer disk
/// The composer changed. **Arms the interval; never restarts it** see the type's note.
///
/// A change that brings the buffer back to what disk already says cancels the armed save outright,
/// `CardBodyEditSession.edited(_:)`'s *reverted* gate: a tick that fired on a no-op would stamp
/// `modified` on a draft nobody touched.
public func edited(_ newText: String) {
guard text != newText else { return }
text = newText
guard isDirty else {
cancelPending()
return
}
armInterval()
}
/// **Composer blur** the first of 05's four cadence moments. Named rather than folded into
/// `flush()` because it is the one a view calls, and because the other three are the window's.
@discardableResult
public func blurred() -> CommentDraftOutcome? {
flush()
}
/// Saves now if there is anything to save, disarming the interval first window close, app quit,
/// and blur all land here.
///
/// Synchronous, because the write is: the close path has to know the answer before it lets the
/// window go (`CardBodyEditSession.flush()`'s reason, unchanged).
@discardableResult
public func flush() -> CommentDraftOutcome? {
cancelPending()
return saveNow()
}
/// ** and the Comment button** one gesture: flush the buffer into `.draft/`, then rename it
/// into the thread (05 The comments column: "posting renames `.draft` a fresh lowercase UUID
/// and **restamps** `created`/`modified` in the same write bracket one gesture, one commit").
///
/// **The flush comes first and is not optional.** The post is a rename of a *folder*, so whatever
/// the composer has not yet written would simply not be in the comment the one place the slow
/// cadence would otherwise be visible as lost text.
///
/// Nothing to post is a no-op rather than a refusal: the button is disabled and in an empty
/// composer should do nothing at all, not post an empty comment and not raise anything.
///
/// **After a post the composer empties**, because the draft is gone the folder it was editing is
/// now a comment in the thread. Clearing `disk` too is what keeps the buffer clean rather than
/// dirty-against-a-file-that-no-longer-exists.
@discardableResult
public func postNow() -> ItemID? {
guard canPost else { return nil }
flush()
guard let posted = post?() else { return nil }
cancelPending()
text = ""
disk = ""
attachments = []
return posted
}
// MARK: - Private
/// Arms the interval **once**. A second dirty keystroke inside the period rides the timer that is
/// already running, which is the difference between an interval and a debounce.
private func armInterval() {
guard pending == nil else { return }
let interval = saveInterval
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, so the thread read arriving a moment later
/// finds the buffer already clean. A `nil` a failure, a suspension under the read-only lock, a
/// vanished card leaves `disk` where it was, which keeps the buffer dirty and therefore keeps
/// the text.
///
/// `.deleted` is a landing like any other: the folder is gone *because* the buffer was empty, so
/// disk and the buffer agree perfectly.
private func saveNow() -> CommentDraftOutcome? {
guard isDirty else { return nil }
guard let save else { return nil }
saveAttempts += 1
let outcome = save(text)
if outcome != nil {
disk = text
}
return outcome
}
}
+212
View File
@@ -0,0 +1,212 @@
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
}
}
+148
View File
@@ -0,0 +1,148 @@
import SwiftUI
// MARK: - One comment
/// One comment in the thread: **an author line, the rendered Markdown body, and attachment chips when
/// its `attachments/` is non-empty** (05-card-window.md The comments column).
///
/// ### No avatars
///
/// "There is no identity system, and initials faked from self-reported strings would be decoration."
/// So the row's whole identity surface is a line of secondary text, and a comment with no `author`
/// renders **without a name** rather than with a placeholder standing in for one
/// (`CommentAuthorLine`).
///
/// ### The row is two views, not one with a mode
///
/// While an inline edit session names this comment, the body and its read-only chips are replaced by
/// the authoring surface the *same* authoring surface the composer uses
/// (`CommentAuthoringSurface`), aimed at this comment's folder. That is what makes "the same pair
/// applies within an inline comment edit session" true by construction rather than by two views
/// agreeing.
struct CommentRowView: View {
let comment: Comment
let comments: CardComments
let cardFolder: URL?
let thumbnails: AttachmentThumbnailCache
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private var padding: CGFloat { CardWindowMetrics.previewPadding(bodyPointSize: pointSize) }
/// The session open over *this* comment, or `nil` one at a time, window-wide
/// (`CardComments.editing`).
private var session: CommentEditSession? {
guard let editing = comments.editing, editing.commentID == comment.id else { return nil }
return editing
}
var body: some View {
VStack(alignment: .leading, spacing: padding) {
if let line = authorLine {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let session {
editor(session)
} else {
reading
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.contextMenu { menu }
.accessibilityElement(children: .contain)
.accessibilityLabel(authorLine ?? "Comment")
}
// MARK: - Reading
private var reading: some View {
VStack(alignment: .leading, spacing: padding) {
CommentBodyView(body: comment.body, cardFolder: cardFolder)
.frame(maxWidth: .infinity, alignment: .leading)
if !comment.attachments.isEmpty {
// **Read-only** "a posted comment's chips are read-only, Quick Look only Edit the
// comment to change its files" (05). `onRemove` left `nil` is that sentence.
CommentAttachmentChips(
names: comment.attachments,
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
thumbnails: thumbnails
)
}
}
}
// MARK: - Editing
/// The inline session's surface: the composer's chrome, aimed at this comment, with Save and
/// Cancel where the composer has Comment.
private func editor(_ session: CommentEditSession) -> some View {
CommentAuthoringSurface(
comments: comments,
target: .comment(comment.id),
text: session.text,
height: CardWindowMetrics.inlineEditorHeight(bodyPointSize: pointSize),
// A constant, and it is enough: this editor is mounted by the session opening and
// unmounted by it ending, so its coordinator sees exactly one transition from the
// never-requested 0 Edit puts the caret in the text, once, without a counter of its
// own (contrast the composer, which is always mounted and needs one).
focusRequest: 1,
attachments: comment.attachments,
thumbnails: thumbnails,
onEdit: { session.edited($0) },
onCommandReturn: { comments.commitEdit() },
// **Escape is Cancel's keyboard twin** (ruled 2026-07-29; 11-command-nexus.md's grammar
// table) unlike the composer's Escape, which never discards, because a session *has* a
// start state to go back to and a draft has not.
onEscape: { comments.cancelEdit() }
) {
// is Save's chord and Escape is Cancel's, both intercepted by the editor's own text
// view see the composer's Comment button for why neither is `.defaultAction`.
Button("Cancel") { comments.cancelEdit() }
.controlSize(.small)
Button("Save") { comments.commitEdit() }
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!comments.isEditable)
}
}
// MARK: - The context menu
/// **Edit / Delete / Reveal in Finder** the per-item action inventory 11-command-nexus.md
/// inventories for a comment, and the surface VoiceOver reads.
///
/// Delete is "immediate and undoable, no confirm" (05; 01's ruling the comment moves into
/// `comments/.trash/` and Z is the move back), so there is no confirmation sheet here and no
/// destructive-role ceremony beyond the divider that separates it.
@ViewBuilder
private var menu: some View {
Button("Edit") { comments.beginEdit(comment.id) }
.disabled(!comments.isEditable)
Button("Reveal in Finder") { comments.reveal(comment.id) }
Divider()
Button("Delete") { comments.delete(comment.id) }
.disabled(!comments.isEditable)
}
// MARK: - The author line
private var authorLine: String? {
CommentAuthorLine.text(
author: comment.author.value,
timestamp: comment.created.value.map(Self.timestamp),
isEdited: comment.isEdited
)
}
/// The card's own created/modified line's format, shared so a comment's timestamp and its card's
/// read the same way.
private static func timestamp(_ date: Date) -> String {
date.formatted(date: .abbreviated, time: .shortened)
}
}
+226
View File
@@ -0,0 +1,226 @@
import AppKit
import SwiftUI
// MARK: - CommentTextEditor
/// The text surface both **authoring** surfaces use: the composer, and an inline comment edit
/// session (05-card-window.md The comments column).
///
/// ### One editor for both, because they are one thing twice
///
/// 05 describes the composer as "an always-visible text area ('Add a comment', Edit-mode Markdown
/// highlighting)" and the inline session as "a body-edit session in miniature". Both are raw Markdown
/// with the body editor's highlighting over it, both end on , both answer Escape, and both are
/// where a dropped file lands for their own folder. What differs is entirely outside this view
/// which buffer the keystrokes go to, what means, what Escape means so all four arrive as
/// closures and none of them is decided here.
///
/// ### The highlighting is the body editor's, exactly
///
/// `MarkdownHighlighter` emits ranges and never a string, so "the text is the raw Markdown, character
/// for character no hidden transforms, no smart substitutions" (05 Edit) holds here for free, and
/// the same smart-substitution deregistrations are repeated below because a comment is as much the
/// user's file as a body is.
///
/// ### It declines file drags, like the body editor
///
/// `acceptableDragTypes` drops the file types so AppKit's hit-test walks past the text view which
/// is what lets the SwiftUI drop target *around* this view take the drop (the composer's carve-out,
/// `CommentDropCarveOut`) instead of `NSTextView` inserting a path into the user's Markdown. The
/// mechanism is `CardBodyTextView`'s, verbatim; only the target above it differs.
struct CommentTextEditor: NSViewRepresentable {
let text: String
let isEditable: Bool
/// Every keystroke straight into the session, which owns the cadence.
let onEdit: (String) -> Void
/// Post for the composer, Save for an inline session (11-command-nexus.md's grammar table).
let onCommandReturn: () -> Void
/// Escape "focus moves out, draft untouched" for the composer; Cancel for an inline session.
let onEscape: () -> Void
/// Focus left. The composer's first cadence moment ("composer blur"); nothing for an inline
/// session, whose commit points are its two buttons.
var onBlur: () -> Void = {}
/// Bumped to ask for the keyboard File Add Comment's second half, and an inline session
/// opening. A counter rather than a flag: two requests in a row are two requests.
var focusRequest: Int = 0
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSScrollView {
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
storage.addLayoutManager(layoutManager)
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
container.widthTracksTextView = true
layoutManager.addTextContainer(container)
let textView = CommentEditorTextView(frame: .zero, textContainer: container)
textView.delegate = context.coordinator
textView.isEditable = isEditable
textView.isSelectable = true
textView.isRichText = false
textView.drawsBackground = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = NSView.AutoresizingMask.width
textView.minSize = CGSize(width: 0, height: 0)
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
// The body editor's list, and normative here for its reason: a smart quote substituted into a
// fenced code block would be the app silently corrupting the user's file.
textView.isAutomaticLinkDetectionEnabled = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.isAutomaticDataDetectionEnabled = false
textView.smartInsertDeleteEnabled = false
textView.allowsUndo = true
textView.usesFindBar = true
textView.isIncrementalSearchingEnabled = true
let padding = CardWindowMetrics.previewPadding(bodyPointSize: CardWindowMetrics.bodyPointSize)
textView.textContainerInset = CGSize(width: padding, height: padding)
textView.onCommandReturn = onCommandReturn
textView.onEscape = onEscape
let scrollView = NSScrollView()
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.drawsBackground = false
scrollView.findBarPosition = .aboveContent
context.coordinator.textView = textView
context.coordinator.onEdit = onEdit
context.coordinator.onBlur = onBlur
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
let coordinator = context.coordinator
coordinator.onEdit = onEdit
coordinator.onBlur = onBlur
guard let textView = scrollView.documentView as? CommentEditorTextView else { return }
textView.isEditable = isEditable
textView.onCommandReturn = onCommandReturn
textView.onEscape = onEscape
coordinator.show(text, in: textView, pointSize: CardWindowMetrics.bodyPointSize)
guard focusRequest != coordinator.servedFocusRequest else { return }
coordinator.servedFocusRequest = focusRequest
guard focusRequest > 0 else { return }
// Deferred a turn: this runs inside a SwiftUI update, and making a view first responder
// re-enters AppKit's responder machinery (`CardBodySurface.Coordinator.enter`'s rule).
Task { @MainActor [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
}
}
// MARK: - Coordinator
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
weak var textView: NSTextView?
var onEdit: ((String) -> Void)?
var onBlur: (() -> Void)?
var servedFocusRequest = 0
/// Set while this coordinator is replacing the view's text, so the resulting change
/// notification is not mistaken for typing.
private var isSettingText = false
/// `CardBodySurface.Coordinator.show(_:in:pointSize:)`, unchanged and for its reason: the
/// equality guard is load-bearing rather than an optimization, because this runs on every
/// keystroke and replacing the storage with the string it already holds would collapse the
/// selection and throw away the undo stack on every character typed.
func show(_ text: String, in textView: NSTextView, pointSize: CGFloat) {
guard let storage = textView.textStorage else { return }
if storage.string != text {
let selected = textView.selectedRange()
isSettingText = true
storage.setAttributedString(NSAttributedString(
string: text,
attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize)
))
isSettingText = false
let length = (text as NSString).length
textView.setSelectedRange(NSRange(
location: min(selected.location, length),
length: min(selected.length, max(0, length - min(selected.location, length)))
))
}
MarkdownHighlighter.highlight(storage, pointSize: pointSize)
textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize)
}
func textDidChange(_ notification: Notification) {
guard !isSettingText, let textView = notification.object as? NSTextView else { return }
onEdit?(textView.string)
}
/// **Composer blur is a save** (05 The comments column, the first of the four cadence
/// moments). For an inline session `onBlur` is empty: its commit points are Save and Cancel,
/// and clicking away from it is neither.
func textDidEndEditing(_ notification: Notification) {
onBlur?()
}
}
}
// MARK: - The editor's text view
/// The authoring editor's text view, subclassed for the two keys 11-command-nexus.md's grammar table
/// gives it and for the drag types it must not take.
final class CommentEditorTextView: NSTextView {
var onCommandReturn: (() -> Void)?
var onEscape: (() -> Void)?
/// **** "Post the draft / end the edit session at its commit point"
/// (11-command-nexus.md Fixed grammar keys). Intercepted before `super`, which would otherwise
/// insert a newline: the chord is the gesture, not a decorated Return.
override func keyDown(with event: NSEvent) {
let isReturn = event.keyCode == 36 || event.keyCode == 76
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting([.function, .numericPad, .capsLock])
if isReturn, modifiers == .command, let onCommandReturn {
onCommandReturn()
return
}
super.keyDown(with: event)
}
/// **Escape.** What it *means* is the caller's focus out for the composer (never a discard),
/// Cancel for an inline session which is why this only forwards. Intercepted before
/// `NSTextView`'s own meaning for it (text completion); with the find bar up the bar is first
/// responder and never reaches this.
override func cancelOperation(_ sender: Any?) {
guard let onEscape else {
super.cancelOperation(sender)
return
}
onEscape()
}
/// **A file drop is never the editor's** `CardBodyTextView`'s deregistration, here so the drop
/// falls through to the authoring surface's own target and lands in *this* surface's
/// `attachments/` (05 Attachments, the hover-target carve-out).
override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
let fileTypes: Set<NSPasteboard.PasteboardType> = [
.fileURL,
NSPasteboard.PasteboardType("NSFilenamesPboardType")
]
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
}
}