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