Files
lanework/Kanban/UI/Card/CommentComposerView.swift
T
rzen 9588f7b1f0 Comments, phase 3 — search, the thread find, announcements, and a11y
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
2026-07-30 21:30:22 -04:00

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