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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user