Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
224 lines
10 KiB
Swift
224 lines
10 KiB
Swift
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 session {
|
|
authorText
|
|
editor(session)
|
|
} else {
|
|
reading
|
|
|
|
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,
|
|
// and the editing branch above draws its *own* removable chips inside the
|
|
// authoring surface, which is why these are the reading branch's alone.
|
|
//
|
|
// They sit **outside** the flattened element deliberately: 10 flattens "author,
|
|
// date, edited state, body", and the chips are the one part of a comment that is
|
|
// not text but a row of controls with a Quick Look behind each — reachable exactly
|
|
// as the sidebar's are (`CommentAttachmentChips`). Flattening them in would have
|
|
// made a comment's files announceable but not openable.
|
|
CommentAttachmentChips(
|
|
names: comment.attachments,
|
|
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
|
|
thumbnails: thumbnails
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.contextMenu { menu }
|
|
.accessibilityElement(children: .contain)
|
|
}
|
|
|
|
// MARK: - Reading
|
|
|
|
/// The author line and the rendered body, as **one flattened element** with the three custom
|
|
/// actions (10-accessibility.md ▸ Comments — see `CommentRowAccessibility`).
|
|
private var reading: some View {
|
|
VStack(alignment: .leading, spacing: padding) {
|
|
authorText
|
|
|
|
CommentBodyView(
|
|
body: comment.body,
|
|
cardFolder: cardFolder,
|
|
// The find's hits in this comment, and whether the current one is here — the row draws
|
|
// them, the session found them (`CommentThreadFind`).
|
|
highlights: comments.find.matches(in: comment.id),
|
|
currentHighlight: comments.find.currentMatch.flatMap {
|
|
$0.comment == comment.id ? $0.range : nil
|
|
},
|
|
focus: comments
|
|
)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.modifier(CommentRowAccessibility(comment: comment, comments: comments, authorLine: authorLine))
|
|
}
|
|
|
|
/// The author line, or nothing — "a comment with no `author` renders **without a name**"
|
|
/// (`CommentAuthorLine`).
|
|
@ViewBuilder
|
|
private var authorText: some View {
|
|
if let line = authorLine {
|
|
Text(line)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// MARK: - The row as one element
|
|
|
|
/// **One comment is one flattened element with three custom actions** (10-accessibility.md ▸
|
|
/// Comments):
|
|
///
|
|
/// > each comment is **one flattened element** — author, date, edited state, body — with its
|
|
/// > context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut.
|
|
///
|
|
/// ### It covers the reading surface only, and that is the whole of where it is applied
|
|
///
|
|
/// While an inline edit session is open the row is a text editor with two buttons in it, and
|
|
/// collapsing *that* into one opaque element would put a VoiceOver user in front of a comment they
|
|
/// can neither read into nor type into. So this hangs on the reading branch alone
|
|
/// (`CommentRowView.reading`), and the editing branch is an ordinary container — the same asymmetry
|
|
/// the body column keeps between Preview and Edit.
|
|
///
|
|
/// The actions duplicate the context menu deliberately: 10 asks for the pointer inventory to be
|
|
/// reachable without the pointer, and the strings are shared with the menu (`AccessibilityPhrases`)
|
|
/// so the two inventories cannot drift. They are also **not** disabled under the read-only lock —
|
|
/// they call the same handles the menu rows do, and those already refuse (`CardComments.beginEdit`,
|
|
/// `.delete`), which keeps one refusal rather than two.
|
|
private struct CommentRowAccessibility: ViewModifier {
|
|
|
|
let comment: Comment
|
|
let comments: CardComments
|
|
let authorLine: String?
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(AccessibilityPhrases.commentLabel(authorLine: authorLine))
|
|
.accessibilityValue(AccessibilityPhrases.commentValue(
|
|
body: comment.body,
|
|
attachments: comment.attachments.count
|
|
))
|
|
.accessibilityAction(named: AccessibilityPhrases.commentEditAction) {
|
|
comments.beginEdit(comment.id)
|
|
}
|
|
.accessibilityAction(named: AccessibilityPhrases.commentDeleteAction) {
|
|
comments.delete(comment.id)
|
|
}
|
|
.accessibilityAction(named: AccessibilityPhrases.commentRevealAction) {
|
|
comments.reveal(comment.id)
|
|
}
|
|
}
|
|
}
|