Files
lanework/Kanban/UI/Card/CommentComposerView.swift
T
rzen fe3ffac48e 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
2026-07-30 20:19:52 -04:00

207 lines
9.1 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
)
.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("Attach Files")
}
}
// 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)
}
.accessibilityLabel("Add a comment")
}
/// 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)
}
}