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