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
214 lines
9.7 KiB
Swift
214 lines
9.7 KiB
Swift
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,
|
|
// Both authoring surfaces report themselves to the pane's focus register, which is
|
|
// what routes ⌘F here to the editor's own find bar rather than to the thread's
|
|
// (05 ▸ Preview; `CardWindowFind.route`).
|
|
focus: comments
|
|
)
|
|
.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(AccessibilityPhrases.commentAttachFilesLabel)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
// "The composer is a labeled text field (⌘↩ posts)" — 10-accessibility.md ▸ Comments. The
|
|
// label is the placeholder's own sentence, so what a sighted user reads in the empty editor
|
|
// and what VoiceOver announces are the same invitation.
|
|
.accessibilityLabel(AccessibilityPhrases.commentComposerLabel)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|