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) } } }