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:
@@ -60,6 +60,53 @@ public enum AppPreferences {
|
||||
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
|
||||
}
|
||||
|
||||
// MARK: The comments pane's three bits
|
||||
|
||||
/// **View ▸ Show Comments** — "a checkmark toggle à la Show Trash, and its choice is **app-wide
|
||||
/// and persisted across restarts**" (05-card-window.md ▸ The comments column, re-ruled
|
||||
/// 2026-07-29; 11-command-nexus.md).
|
||||
///
|
||||
/// **One bit, and no content-derived auto-show**: checked, every card window carries the pane —
|
||||
/// a comment-less card shows the empty thread and the composer, because the invitation is the
|
||||
/// point; unchecked, threads and drafts are out of sight until the user says otherwise. The
|
||||
/// checkmark reads exactly this value, so the menu never lies, and deleting the last comment
|
||||
/// never closes the pane because nothing but this bit does.
|
||||
///
|
||||
/// **Default on.** 05 does not spell a default, and the two candidate readings pull in opposite
|
||||
/// directions — the Show Trash bargain (a secondary surface, default off) against "the invitation
|
||||
/// is the point" (a pane whose empty state is its whole argument). The invitation wins: a
|
||||
/// comments feature nobody sees until they find a View-menu row is a feature that is not there,
|
||||
/// and the user who does not want it turns it off once, forever, which is what the persistence is
|
||||
/// for.
|
||||
public static let showCommentsKey = "showComments"
|
||||
|
||||
public static var showComments: Bool {
|
||||
UserDefaults.standard.object(forKey: showCommentsKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// **View ▸ Comments Beside Body** — "checked = side-by-side (default), unchecked = body over
|
||||
/// comments; app-wide, persisted" (11-command-nexus.md; 05 ▸ Composition).
|
||||
///
|
||||
/// Default **on**, which 05 does state: "side-by-side is the default".
|
||||
public static let commentsBesideBodyKey = "commentsBesideBody"
|
||||
|
||||
public static var commentsBesideBody: Bool {
|
||||
UserDefaults.standard.object(forKey: commentsBesideBodyKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// The comments header's **sort-direction control** — "chronological ascending by default,
|
||||
/// flippable to newest-first (app-wide, persisted)" (05 ▸ The comments column; 11 files it under
|
||||
/// Configuration controls).
|
||||
///
|
||||
/// Stored as "newest first" rather than as a direction so the default is `false` and the plain
|
||||
/// `bool(forKey:)` reading is the right one — the one preference here that does not need to tell
|
||||
/// "off" from "never set".
|
||||
public static let commentsNewestFirstKey = "commentsNewestFirst"
|
||||
|
||||
public static var commentsNewestFirst: Bool {
|
||||
UserDefaults.standard.bool(forKey: commentsNewestFirstKey)
|
||||
}
|
||||
|
||||
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
||||
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
||||
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
||||
|
||||
@@ -55,6 +55,16 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
/// rather than pretending to have written it.
|
||||
let body: CardBodyEditSession
|
||||
|
||||
/// The window's comments pane — the thread, the composer's draft buffer, and the one open inline
|
||||
/// edit session (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// It lives **here** rather than as another `@State` beside the body's handles, and the close
|
||||
/// flush is why: the pane owes the close three things in a fixed order — the inline session's
|
||||
/// flush, the draft's save, then the `comments/.trash/` purge — and this object is the one the
|
||||
/// coordinator already drives (`CardSessionFlushing`). A pane held only by the view would have its
|
||||
/// close work run wherever SwiftUI happened to tear the view down.
|
||||
let comments = CardComments()
|
||||
|
||||
/// The close-time save-or-lose moment, over this window's buffer (02-architecture.md §
|
||||
/// Write-failure surfacing: "the one modal moment on the write-failure path").
|
||||
let bufferGuard: DirtyBufferGuard
|
||||
@@ -67,9 +77,16 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
/// window that has not joined its board — holds nothing, which is true.
|
||||
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
|
||||
|
||||
/// The Edit buffer's dirty text or a typed-in raw-source outlet — see `CardSessionFlushing`.
|
||||
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
|
||||
/// holding keystrokes its file has not got — see `CardSessionFlushing`.
|
||||
///
|
||||
/// **The composer's draft is deliberately not counted.** 05-card-window.md ▸ The comments column
|
||||
/// gives it the opposite posture from every other buffer in this window — "Close and quit just
|
||||
/// proceed — no DirtyBufferGuard, nothing to lose" — because it is a durable file being edited in
|
||||
/// place rather than unsaved work. An inline comment edit *is* the ordinary kind, so it counts
|
||||
/// exactly as the body's does (`CardComments.holdsUnsavedContent`).
|
||||
var holdsUnsavedContent: Bool {
|
||||
body.isDirty || rawSourceHoldsUnsavedText?() == true
|
||||
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
|
||||
}
|
||||
|
||||
private var hasEnded = false
|
||||
@@ -103,6 +120,12 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
// the committer's own debounce outlives them and this call is where the session is known to
|
||||
// be over.
|
||||
body.endEditSession()
|
||||
// **Saves first, purge last** — the inline comment session's flush and the draft's save land
|
||||
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing
|
||||
// a folder a save was about to write into (`CardComments.endSession`). It runs after the
|
||||
// body's for the same reason it runs at all: this is the one place the window's whole close
|
||||
// work has a fixed order.
|
||||
comments.endSession()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +188,12 @@ struct CardWindowHost: View {
|
||||
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
|
||||
/// flush; cleared by the resolution that lets the close resume.
|
||||
@State private var isClosePending = false
|
||||
/// The two app-wide comment bits, read here for one reason only: the window's **minimum size**
|
||||
/// depends on them (`minimumSize`). The panes read them again themselves (`CardWindowView`) —
|
||||
/// two readers of one `UserDefaults` key, which is what `@AppStorage` is for and is cheaper than
|
||||
/// threading the pair through a view that would then have to publish them back up.
|
||||
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
||||
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
||||
|
||||
private enum Phase {
|
||||
case opening
|
||||
@@ -236,6 +265,11 @@ struct CardWindowHost: View {
|
||||
// File ▸ Add Attachment… (⇧⌘A) and File ▸ Reveal in Finder's card-window scope reach the
|
||||
// frontmost card window the same way (11-command-nexus.md).
|
||||
.focusedSceneValue(\.cardAttachments, attachments)
|
||||
// File ▸ Add Comment and the two View-menu comment toggles reach the frontmost card
|
||||
// window the same way — the toggles read their own persisted bits and use this only to
|
||||
// know a card window is in front at all (11-command-nexus.md scopes all three to the card
|
||||
// window).
|
||||
.focusedSceneValue(\.cardComments, session.comments)
|
||||
// The raw-source outlet's detailed alert, presented over this window — a validation
|
||||
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
|
||||
// than inside the editor because the second of those fires while source mode is still
|
||||
@@ -277,8 +311,14 @@ struct CardWindowHost: View {
|
||||
.onDisappear { finish() }
|
||||
}
|
||||
|
||||
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md ▸
|
||||
/// Composition) — which is the whole reason the stacked mount exists, so a narrow display keeps
|
||||
/// the minimum it always had.
|
||||
private var minimumSize: CGSize {
|
||||
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
CardWindowMetrics.minimumSize(
|
||||
bodyPointSize: CardWindowMetrics.bodyPointSize,
|
||||
commentsColumn: showComments && commentsBesideBody
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -301,6 +341,7 @@ struct CardWindowHost: View {
|
||||
// predicate too ("the attachment row's ⌫/Remove shares the posture").
|
||||
isEditable: !store.isReadOnly,
|
||||
attachments: attachments,
|
||||
comments: session.comments,
|
||||
thumbnails: thumbnails,
|
||||
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
|
||||
onToggleTask: { offset, checked in
|
||||
@@ -319,9 +360,27 @@ struct CardWindowHost: View {
|
||||
// folder rename moves the board, and rows resolving against where it used to be
|
||||
// would open nothing.
|
||||
attachments.cardFolder = folder
|
||||
session.comments.cardFolder = folder
|
||||
}
|
||||
.onChange(of: store.isReadOnly, initial: true) { _, locked in
|
||||
attachments.isEditable = !locked
|
||||
session.comments.isEditable = !locked
|
||||
}
|
||||
// **The thread re-reads on every applied snapshot** (05 ▸ The comments column: "the pane
|
||||
// reloads its thread from the same FSEvents stream").
|
||||
//
|
||||
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
|
||||
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` — it does
|
||||
// not vend the changed paths, and comments are outside the snapshot entirely
|
||||
// (01-storage-format.md § Enhanced schema), so there is nothing here to run
|
||||
// `CommentPath.classify` against. Re-reading one card's thread is a handful of small
|
||||
// files and happens only while a card window is open; filtering would mean either
|
||||
// widening the store's surface to carry paths, or the pane keeping its own watcher — a
|
||||
// second stream over the same tree, which the one-way flow rules out. `initial:` is
|
||||
// deliberately absent: `start()` already did the opening read, after the residue sweep
|
||||
// that has to precede it.
|
||||
.onChange(of: store.snapshotGeneration) { _, _ in
|
||||
session.comments.reload()
|
||||
}
|
||||
} else {
|
||||
// Nothing to render and nothing worth animating: this window is on its way out.
|
||||
@@ -409,6 +468,27 @@ struct CardWindowHost: View {
|
||||
phase = .open(store)
|
||||
configureSession(store: store)
|
||||
configureWindow()
|
||||
openCommentThread(store: store)
|
||||
}
|
||||
|
||||
/// **The card window's open, comment-side** — the crash-residue sweep, then the thread read
|
||||
/// (01-storage-format.md § Enhanced schema: "crash residue sweeps at the next card-window open,
|
||||
/// armed-then-cleared like every heal memo").
|
||||
///
|
||||
/// It runs from `start()` rather than from a `.task` on the pane, and the reason is the pane's
|
||||
/// own visibility: Show Comments off means no pane at all, and the residue of a session that died
|
||||
/// mid-delete must still be swept — it is the app's leftovers, not a feature of the pane. The
|
||||
/// same goes for the close purge, which rides the session's end for the same reason.
|
||||
///
|
||||
/// The pane's two window-scoped facts are set *before* the read, because both of them are things
|
||||
/// the read's results are resolved against: the folder every comment's attachments hang off, and
|
||||
/// whether the lock is on.
|
||||
private func openCommentThread(store: BoardStore) {
|
||||
if case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) {
|
||||
session.comments.cardFolder = Self.cardFolder(root: store.rootURL, placement: placement)
|
||||
}
|
||||
session.comments.isEditable = !store.isReadOnly
|
||||
session.comments.open()
|
||||
}
|
||||
|
||||
/// Points this window's Edit buffer at its card, and the mode flip at the buffer.
|
||||
@@ -447,6 +527,56 @@ struct CardWindowHost: View {
|
||||
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
|
||||
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
|
||||
Self.configureAttachments(attachments, store: store, cardID: cardID)
|
||||
Self.configureComments(session.comments, store: store, cardID: cardID)
|
||||
}
|
||||
|
||||
/// Points the comments pane at its card — **the one place every comment gesture learns which card
|
||||
/// it acts on** (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// Every seam is one of the store's own bracketed methods, unchanged, which is the same rule the
|
||||
/// attachments section keeps: there is deliberately no comment write of this window's own to keep
|
||||
/// in step with the store's, so a post made here and a post made by anything else take one path —
|
||||
/// one bracket, one undo step, one commit shape.
|
||||
///
|
||||
/// The store is captured **weakly**, `configureSession`'s rule: a save still landing after the
|
||||
/// board window has gone should write nothing rather than resurrect a released store. A `nil`
|
||||
/// store answers what a vanished card answers — an empty thread, a save that did not land, a
|
||||
/// delete that did not happen — which is exactly what the pane's own guards expect.
|
||||
///
|
||||
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
|
||||
/// target resolution is invisible in a running window until it is wrong, and this shape is what
|
||||
/// lets a test drive the real wiring rather than a re-typed copy of it.
|
||||
static func configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) {
|
||||
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
|
||||
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
|
||||
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
|
||||
comments.purgeTrash = { [weak store] in store?.purgeCommentTrash(inCard: cardID) }
|
||||
// Detection is the thread read's, the repair is the store's batch, and the notice is the
|
||||
// banner surface's — "the relocation-style warning-tone notice names the repair". This
|
||||
// closure is only the join, which is why it is three lines and lives here rather than on
|
||||
// either side of it.
|
||||
comments.displaceSquatters = { [weak store] squatters in
|
||||
guard let store else { return }
|
||||
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
|
||||
}
|
||||
comments.deleteComment = { [weak store] id in
|
||||
store?.deleteComment(id, inCard: cardID) ?? false
|
||||
}
|
||||
comments.editComment = { [weak store] id, body in
|
||||
store?.editComment(id, inCard: cardID, body: body) ?? false
|
||||
}
|
||||
comments.importAttachments = { [weak store] urls, target in
|
||||
store?.importCommentAttachments(urls, inCard: cardID, target: target)
|
||||
}
|
||||
comments.removeAttachment = { [weak store] name, target in
|
||||
store?.removeCommentAttachment(named: name, inCard: cardID, target: target)
|
||||
}
|
||||
comments.composer.save = { [weak store] text in
|
||||
store?.saveCommentDraft(inCard: cardID, body: text)
|
||||
}
|
||||
comments.composer.post = { [weak store] in
|
||||
store?.postComment(inCard: cardID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Points the attachments section at its card — **the one place Add Attachment… and Remove
|
||||
|
||||
@@ -80,9 +80,14 @@ struct FindSteppingCommands: View {
|
||||
// outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
|
||||
// 07-sync-collab.md). It remains unconditionally disabled here — the sidebar reserves the section's
|
||||
// place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus.
|
||||
/// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments
|
||||
/// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits,
|
||||
/// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`).
|
||||
struct CardViewCommands: View {
|
||||
var body: some View {
|
||||
EditBodyCommand()
|
||||
ShowCommentsCommand()
|
||||
CommentsBesideBodyCommand()
|
||||
RawSourceCommand()
|
||||
FutureCommand(title: "History")
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@ struct KanbanApp: App {
|
||||
SaveAsTemplateCommand(appModel: appModel)
|
||||
RevealInFinderCommand()
|
||||
AddAttachmentCommand()
|
||||
AddCommentCommand()
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommentTarget
|
||||
|
||||
/// Which of a card's two **authoring surfaces** a write is aimed at: the composer's draft, or one
|
||||
/// posted comment being edited inline (05-card-window.md ▸ The comments column — the hover-target
|
||||
/// carve-out's two destinations).
|
||||
///
|
||||
/// It exists because the pair is a *choice the view makes* and the store must not re-derive: which
|
||||
/// surface the pointer was over when a file was dropped is knowledge only the window has, and the
|
||||
/// alternative — two near-identical store methods — would put the choice in the call site's name
|
||||
/// instead of in a value a test can hold.
|
||||
///
|
||||
/// `comments/.trash/` is deliberately not a case: a deleted comment is undo's backing store and
|
||||
/// "never a UI surface" (01-storage-format.md § Enhanced schema), so there is no gesture that could
|
||||
/// aim at one.
|
||||
public enum CommentTarget: Sendable, Equatable {
|
||||
/// `comments/.draft/` — the composer's backing file.
|
||||
case draft
|
||||
/// `comments/<uuid>/` — a posted comment with an inline edit session open over it.
|
||||
case comment(ItemID)
|
||||
|
||||
/// Where the target lives, given the card's folder. One resolution, so a view and a write can
|
||||
/// never disagree about which folder "the composer" means.
|
||||
public func folder(inCard cardFolder: URL) -> URL {
|
||||
switch self {
|
||||
case .draft: CommentThread.draftFolder(inCard: cardFolder)
|
||||
case let .comment(id): CommentThread.commentFolder(id, inCard: cardFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The comment gestures
|
||||
|
||||
/// **The comment thread at the store boundary** — every comment write the app makes, bracketed, with
|
||||
@@ -43,6 +73,16 @@ extension BoardStore {
|
||||
return CommentThread.load(inCard: card.folder, path: card.path)
|
||||
}
|
||||
|
||||
/// **The composer's own file, read** — `comments/.draft/`, excluded from the thread listing and
|
||||
/// therefore asked for by name (`CommentThread.loadDraft`).
|
||||
///
|
||||
/// `nil` for a card with no draft, an unreadable one, or an id that names no card — the same
|
||||
/// vanished-target answer `commentThread(inCard:)` gives, and the same "nothing to restore".
|
||||
public func commentDraft(inCard id: ItemID) -> CommentDraft? {
|
||||
guard let card = commentSubject(id) else { return nil }
|
||||
return CommentThread.loadDraft(inCard: card.folder)
|
||||
}
|
||||
|
||||
// MARK: The draft
|
||||
|
||||
/// Saves the composer's draft — one bracket, no step.
|
||||
@@ -192,6 +232,38 @@ extension BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The authoring surfaces' attachments
|
||||
|
||||
/// **Imports files into the draft's or one comment's `attachments/`** — the composer's and the
|
||||
/// inline editor's drop carve-out and paperclip (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// One bracket, **no step**: an attachment import registers nothing at card level either
|
||||
/// (`importAttachments`), and 13-native-undo.md's inventory does not grow because a file landed
|
||||
/// one folder deeper.
|
||||
///
|
||||
/// A vanished card, or a target folder that is not an authoring surface, writes nothing — the
|
||||
/// Writer's own guard, reached through the ordinary bracket so a failure banners like any other.
|
||||
public func importCommentAttachments(_ urls: [URL], inCard id: ItemID, target: CommentTarget) {
|
||||
guard !urls.isEmpty, let card = commentSubject(id) else { return }
|
||||
let folder = target.folder(inCard: card.folder)
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.importCommentAttachments(urls, intoComment: folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// **Moves one authoring chip's file to the system Trash** — never a hard delete, the sidebar
|
||||
/// row's rule one level down (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// A name that is no longer there is a silent no-op rather than a failure: the reload is the
|
||||
/// authority on what a folder holds (`BoardWriter.trashAttachment`).
|
||||
public func removeCommentAttachment(named name: String, inCard id: ItemID, target: CommentTarget) {
|
||||
guard !name.isEmpty, let card = commentSubject(id) else { return }
|
||||
let folder = target.folder(inCard: card.folder)
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.removeCommentAttachment(named: name, fromComment: folder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The thread's claimed names
|
||||
|
||||
/// Displaces the claimed names one thread read found squatted — `comments/.draft`,
|
||||
|
||||
@@ -1852,13 +1852,35 @@ public enum BoardWriter: Sendable {
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
|
||||
try checkIsUUIDShaped(cardFolder, operation: batchOperation)
|
||||
|
||||
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||
return try importFiles(sourceURLs, intoAttachmentsOf: cardFolder, batchOperation: batchOperation)
|
||||
}
|
||||
|
||||
/// **Steps 2–4 of `importAttachments`, with the shape guard left to the caller** — the copy
|
||||
/// itself, which is identical wherever an `attachments/` folder hangs.
|
||||
///
|
||||
/// It exists because a *comment* has an `attachments/` too (01-storage-format.md § Enhanced
|
||||
/// schema — "a card's anatomy one level down"), and comment attachments author in-window
|
||||
/// (05-card-window.md ▸ The comments column, ruled 2026-07-29). The alternative was a second
|
||||
/// importer beside this one, which is exactly what `CardAttachments`' own note forbids for the
|
||||
/// card level: "one import path, one set of banners, one Finder-style collision rename". The
|
||||
/// generalization is therefore the *smallest* one that keeps that true — the folder shape is what
|
||||
/// differs between a card and a comment, and it is the only thing the callers still decide.
|
||||
///
|
||||
/// Everything the batch promises is here rather than at either entry point: `attachments/` minted
|
||||
/// on first import, each source validated before it is touched, the Finder-style collision-free
|
||||
/// name, the non-atomic copy with best-effort cleanup, and the import receipt.
|
||||
static func importFiles(
|
||||
_ sourceURLs: [URL],
|
||||
intoAttachmentsOf itemFolder: URL,
|
||||
batchOperation: WriteOperation
|
||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
let attachmentsFolder = itemFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: batchOperation,
|
||||
path: cardFolder.path,
|
||||
path: itemFolder.path,
|
||||
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
@@ -2325,9 +2347,24 @@ public enum BoardWriter: Sendable {
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
try checkIsUUIDShaped(cardFolder, operation: operation)
|
||||
|
||||
guard BoardLoader.attachmentNames(in: cardFolder).contains(name) else { return nil }
|
||||
return try trashAttachment(named: name, fromItemFolder: cardFolder, operation: operation)
|
||||
}
|
||||
|
||||
let fileURL = cardFolder
|
||||
/// `removeAttachment`'s body with the shape guard left to the caller — `importFiles`' split, for
|
||||
/// its reason: a comment's authoring chips carry Remove too, "to the **system** Trash — the
|
||||
/// sidebar row's rule" (05-card-window.md ▸ The comments column), and one Trash-not-delete
|
||||
/// promise is worth more than two implementations of it.
|
||||
///
|
||||
/// The listing check, the never-hard-delete guarantee and the a-name-that-is-gone-is-not-a-failure
|
||||
/// rule all live here, so they hold at both levels without either caller restating them.
|
||||
static func trashAttachment(
|
||||
named name: String,
|
||||
fromItemFolder itemFolder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> URL? {
|
||||
guard BoardLoader.attachmentNames(in: itemFolder).contains(name) else { return nil }
|
||||
|
||||
let fileURL = itemFolder
|
||||
.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||
.appendingPathComponent(name)
|
||||
var trashedURL: NSURL?
|
||||
|
||||
@@ -52,6 +52,37 @@ public struct Comment: Identifiable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentDraft
|
||||
|
||||
/// The card's single draft, as the composer needs it: the text to restore, and the chips to draw.
|
||||
///
|
||||
/// **Two fields and no identity**, which is the difference between a draft and a comment stated as a
|
||||
/// type: `comments/.draft/` is a claimed name the composer edits in place, and it becomes a `Comment`
|
||||
/// only at the post, where the rename mints the id (`BoardWriter.postComment`).
|
||||
public struct CommentDraft: Sendable, Equatable {
|
||||
|
||||
/// `index.md`'s body — what the composer shows when the window opens.
|
||||
public let body: String
|
||||
|
||||
/// The draft's `attachments/`, through the same enumeration a comment's listing uses, so the
|
||||
/// composer's chips and a posted comment's chips can never disagree about what a folder holds.
|
||||
public let attachments: [String]
|
||||
|
||||
public init(body: String, attachments: [String]) {
|
||||
self.body = body
|
||||
self.attachments = attachments
|
||||
}
|
||||
|
||||
/// Whether a save of `body` would delete the folder — **the emptied-draft rule, asked before the
|
||||
/// write** (01-storage-format.md § Enhanced schema; `BoardWriter.saveCommentDraft`'s own gate).
|
||||
///
|
||||
/// It is the composer's Post validation read backwards: there is nothing to post exactly when
|
||||
/// there would be nothing to keep.
|
||||
public var isEmpty: Bool {
|
||||
body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && attachments.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentThread
|
||||
|
||||
/// A card's comment thread, read from disk — **window-scoped, outside the board snapshot**
|
||||
@@ -265,6 +296,37 @@ public struct CommentThread: Sendable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// **The composer's own file, read** — `comments/.draft/`, which the thread listing deliberately
|
||||
/// excludes (see the type's note) and which the composer therefore has to ask for by name.
|
||||
///
|
||||
/// It answers a pair rather than a `Comment` because a draft has **no identity**: it is a claimed
|
||||
/// *name*, not a UUID (`CommentPath.Kind.draft` — "the one member of the thread that is a name
|
||||
/// rather than an id"), and minting an `ItemID` for it here would put a lie in the one type whose
|
||||
/// whole job is that a comment's folder name *is* its id.
|
||||
///
|
||||
/// Total, like `load(inCard:path:)`: a card with no draft, an unreadable one, or a `.draft` held
|
||||
/// by a file answers `nil` — which is what "nothing to restore" looks like and is not a defect
|
||||
/// this read invents (the squatted-name case is reported by the thread read beside it).
|
||||
///
|
||||
/// **This is the whole of restore-on-reopen**: the composer's backing file is the draft, so
|
||||
/// reading it at open is all the mechanism there is (05-card-window.md ▸ The comments column).
|
||||
public static func loadDraft(inCard cardFolder: URL) -> CommentDraft? {
|
||||
let folder = draftFolder(inCard: cardFolder)
|
||||
guard IntegrityRules.node(at: folder) == .directory else { return nil }
|
||||
let indexURL = folder.appendingPathComponent(IntegrityRules.indexFileName)
|
||||
let attachments = BoardLoader.attachmentNames(in: folder)
|
||||
|
||||
// A folder with no readable `index.md` is a draft that exists — the two-step-create shape, and
|
||||
// the shape a drop-before-a-keystroke leaves. Its body is empty, its chips are real, and
|
||||
// reporting `nil` would hide files the user can see in Finder.
|
||||
guard let data = try? Data(contentsOf: indexURL),
|
||||
let document = try? BoardLoader.parseDocument(data, path: IntegrityRules.commentDraftFolderName)
|
||||
else {
|
||||
return CommentDraft(body: "", attachments: attachments)
|
||||
}
|
||||
return CommentDraft(body: document.body, attachments: attachments)
|
||||
}
|
||||
|
||||
/// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema,
|
||||
/// ruled 2026-07-29): "the thread sorts by `created` ascending … ties and missing/malformed
|
||||
/// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order".
|
||||
|
||||
@@ -364,6 +364,79 @@ extension BoardWriter {
|
||||
try restampComment(at: posted, to: instant, operation: operation)
|
||||
}
|
||||
|
||||
// MARK: - The authoring surfaces' attachments
|
||||
|
||||
/// **Imports files into one comment's (or the draft's) `attachments/`** — the write behind the
|
||||
/// composer's and the inline editor's drop carve-out and their paperclip affordance
|
||||
/// (05-card-window.md ▸ The comments column, ruled 2026-07-29: "a file dropped within the
|
||||
/// composer's bounds imports to the draft's `attachments/` … and the same pair applies within an
|
||||
/// inline comment edit session, targeting that comment's").
|
||||
///
|
||||
/// **It is `importAttachments` with a different shape guard and nothing else** — the collision
|
||||
/// rename, the per-file validation, the partial cleanup and the import receipt are all
|
||||
/// `BoardWriter.importFiles`', shared rather than restated, so a file added to a comment behaves
|
||||
/// exactly like one added to a card.
|
||||
///
|
||||
/// **No stamp, deliberately.** `index.md` is never opened: importing a file says nothing about the
|
||||
/// comment's text, and `relocateLooseFiles` already settles that reading one level up ("relocating
|
||||
/// a stray says nothing about the card's content, so no `modified` stamp is written"). It also
|
||||
/// keeps the draft's own rule honest — a draft that has only ever been dropped on still has the
|
||||
/// `created`/`modified` pair the post is about to restamp.
|
||||
@discardableResult
|
||||
public static func importCommentAttachments(
|
||||
_ sourceURLs: [URL],
|
||||
intoComment folder: URL
|
||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
|
||||
try checkIsAuthoringFolder(folder, operation: batchOperation)
|
||||
return try importFiles(sourceURLs, intoAttachmentsOf: folder, batchOperation: batchOperation)
|
||||
}
|
||||
|
||||
/// **Moves one of a comment's (or the draft's) attachments to the system Trash** — the authoring
|
||||
/// chip's Remove (05-card-window.md ▸ The comments column: "Chips on an authoring surface carry
|
||||
/// remove (to the **system** Trash — the sidebar row's rule); a posted comment's chips are
|
||||
/// read-only").
|
||||
///
|
||||
/// The *posted*-chip half of that sentence is enforced in the view, not here: the Writer's job is
|
||||
/// that the file goes to the Trash rather than being destroyed, and an inline edit session over a
|
||||
/// posted comment is an authoring surface too. `BoardWriter.trashAttachment` is the whole body.
|
||||
@discardableResult
|
||||
public static func removeCommentAttachment(
|
||||
named name: String,
|
||||
fromComment folder: URL
|
||||
) throws(BoardWriteError) -> URL? {
|
||||
let operation = WriteOperation.removeAttachment(filename: name)
|
||||
try checkIsAuthoringFolder(folder, operation: operation)
|
||||
return try trashAttachment(named: name, fromItemFolder: folder, operation: operation)
|
||||
}
|
||||
|
||||
/// Refuses any folder that is not a place a user can author attachments into: a **posted
|
||||
/// comment**, or the card's single **draft**.
|
||||
///
|
||||
/// `checkIsCommentFolder` widened by exactly one name, and widened here rather than there on
|
||||
/// purpose: `editComment` and `deleteComment` must keep refusing `.draft` (the composer owns it,
|
||||
/// and posting is its only way into the thread), while an attachment import has no such
|
||||
/// asymmetry — the draft is precisely where the composer's chips land. `comments/.trash/` stays
|
||||
/// out of both, because a deleted comment is undo's and never a surface.
|
||||
private static func checkIsAuthoringFolder(
|
||||
_ folder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
try checkIsDirectory(folder, describedAs: "comment folder", operation: operation)
|
||||
let name = folder.lastPathComponent
|
||||
guard folder.deletingLastPathComponent().lastPathComponent.lowercased()
|
||||
== IntegrityRules.commentsFolderName,
|
||||
IntegrityRules.isIdentityShaped(name)
|
||||
|| name.lowercased() == IntegrityRules.commentDraftFolderName
|
||||
else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .unreadable(message: "folder is not a comment")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared mechanics
|
||||
|
||||
/// The frontmatter text for a comment the app is minting outright — `newDocumentText`'s twin, and
|
||||
|
||||
@@ -89,7 +89,7 @@ public final class CardAttachments {
|
||||
/// the very same store method a whole-window drop uses.
|
||||
public func add() {
|
||||
guard isEditable, cardFolder != nil else { return }
|
||||
let urls = Self.chooseFiles()
|
||||
let urls = AttachmentPanel.chooseFiles(message: "Choose files to attach to this card.")
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
// The sandbox's half, `BoardDropContext.commitFileDrop`'s rule: `start…` answers false for a
|
||||
@@ -172,22 +172,35 @@ public final class CardAttachments {
|
||||
return [cardFolder]
|
||||
}
|
||||
|
||||
// MARK: - The panel
|
||||
}
|
||||
|
||||
/// The multi-select open panel behind Add Attachment… — **every file type**, because a card's
|
||||
/// `attachments/` takes anything (01-storage-format.md § Attachments) and a filter here would be
|
||||
/// this app deciding what the user may keep beside their card.
|
||||
///
|
||||
/// Directories are not choosable, which is the panel's own spelling of the same refusal a
|
||||
/// folder drop gets (`FinderDrop`): the attachment model is flat top-level files.
|
||||
private static func chooseFiles() -> [URL] {
|
||||
// MARK: - The panel
|
||||
|
||||
/// The multi-select open panel behind every "add a file" affordance in the card window — File ▸ Add
|
||||
/// Attachment… and the attachments header's plus (card-scoped), and the composer's and inline
|
||||
/// editor's paperclips (comment-scoped, 05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// **Every file type**, because a card's `attachments/` takes anything (01-storage-format.md §
|
||||
/// Attachments) and a filter here would be this app deciding what the user may keep beside their
|
||||
/// card. Directories are not choosable, which is the panel's own spelling of the same refusal a
|
||||
/// folder drop gets (`FinderDrop`): the attachment model is flat top-level files.
|
||||
///
|
||||
/// Shared rather than copied per surface for `BoardWriter.importFiles`' reason one layer up: the
|
||||
/// paperclip is "the section header's add-affordance pattern" and a second panel that happened to
|
||||
/// allow folders would make that sentence false.
|
||||
@MainActor
|
||||
enum AttachmentPanel {
|
||||
|
||||
/// - Parameter message: the panel's one line of guidance — the only thing that differs between
|
||||
/// the card-scoped and comment-scoped calls, because it is the only thing that *is* different.
|
||||
static func chooseFiles(message: String) -> [URL] {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.resolvesAliases = true
|
||||
panel.prompt = "Add"
|
||||
panel.message = "Choose files to attach to this card."
|
||||
panel.message = message
|
||||
guard panel.runModal() == .OK else { return [] }
|
||||
return panel.urls
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The window's comments pane, as a handle
|
||||
|
||||
/// One card window's comments pane, reduced to what things *outside* it need: the thread it is
|
||||
/// showing, the composer's buffer, whichever comment has an inline edit session open, and the writes
|
||||
/// the pane can start (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// `CardAttachments`' shape and for its reason — one per window, `@State` in the host (on its
|
||||
/// session, so the close flush can reach it), published through the focus system so **menu items**
|
||||
/// (File ▸ Add Comment; View ▸ Show Comments' validation) can reach the frontmost card window without
|
||||
/// anyone keeping a which-window-is-key register. It is deliberately not on `BoardStore`: the store
|
||||
/// is the *board's*, shared by every window on it, and two card windows open on two cards have two
|
||||
/// different threads, two different drafts and two different sessions.
|
||||
///
|
||||
/// ### What it is not
|
||||
///
|
||||
/// It is **not** the thread's source of truth, and it is emphatically not a cache. Comments are
|
||||
/// window-scoped and outside the board snapshot (01-storage-format.md § Enhanced schema), so there is
|
||||
/// no snapshot to republish from — this handle re-reads the thread from disk through the store's own
|
||||
/// read, at exactly two moments: when the window opens, and when a reload lands. Everything else
|
||||
/// here is a buffer or a seam.
|
||||
///
|
||||
/// ### The three preference bits are not here either
|
||||
///
|
||||
/// Show Comments, Comments Beside Body and the sort direction are **app-wide and persisted**
|
||||
/// (`AppPreferences`), so they are read where they are used — `@AppStorage` in the views and the menu
|
||||
/// rows — rather than mirrored onto every window's handle, which would be one copy per window of a
|
||||
/// value that has exactly one.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CardComments {
|
||||
|
||||
// MARK: What the pane shows
|
||||
|
||||
/// The thread as the last read found it, in the loader's order (`created` ascending). The header's
|
||||
/// sort control reverses it for display and never re-sorts — see `CommentSortDirection`.
|
||||
public private(set) var thread: CommentThread = .empty
|
||||
|
||||
/// The card's own folder — `<root>/<lane>/<card>`. `nil` until the window has joined its board,
|
||||
/// which is exactly while there is nothing to comment on. Comment attachment URLs and Reveal in
|
||||
/// Finder resolve against it.
|
||||
public var cardFolder: URL?
|
||||
|
||||
/// Whether the pane's mutations are offered at all — `!store.isReadOnly`. Under the lock the
|
||||
/// composer, the paperclips, Post, Edit and Delete disable in place, which is
|
||||
/// 02-architecture.md's every-entry-point predicate applied to this pane.
|
||||
public var isEditable = false
|
||||
|
||||
// MARK: The two authoring surfaces
|
||||
|
||||
/// The composer's buffer — always present, because the composer is always visible when the pane
|
||||
/// is (05: "an always-visible text area").
|
||||
public let composer = CommentDraftSession()
|
||||
|
||||
/// The one open inline edit session, or `nil`. **One at a time**: 05 describes Edit as *the*
|
||||
/// comment's session and the window's close flushes *the* session, and two editors over one
|
||||
/// thread would each hold their own session-start bytes over files the other was writing.
|
||||
public private(set) var editing: CommentEditSession?
|
||||
|
||||
/// Bumped by File ▸ Add Comment (and by the pane's own affordances) to ask the composer for the
|
||||
/// keyboard. A **counter**, not a flag, so two Add Comments in a row are two focus requests —
|
||||
/// a `Bool` would need clearing, and a clear that raced the view would swallow the second one.
|
||||
public private(set) var focusComposerRequests = 0
|
||||
|
||||
// MARK: Seams — filled in by the host with the store's own bracketed methods
|
||||
|
||||
/// Re-reads the thread — `BoardStore.commentThread(inCard:)`.
|
||||
@ObservationIgnored
|
||||
public var readThread: (() -> CommentThread)?
|
||||
|
||||
/// Re-reads the draft — `BoardStore.commentDraft(inCard:)`.
|
||||
@ObservationIgnored
|
||||
public var readDraft: (() -> CommentDraft?)?
|
||||
|
||||
/// The crash-residue sweep, run once when the window opens —
|
||||
/// `BoardStore.sweepCommentTrashResidue(inCard:)`.
|
||||
@ObservationIgnored
|
||||
public var sweepTrashResidue: (() -> Void)?
|
||||
|
||||
/// The close purge — `BoardStore.purgeCommentTrash(inCard:)`.
|
||||
@ObservationIgnored
|
||||
public var purgeTrash: (() -> Void)?
|
||||
|
||||
/// Displaces the claimed names a thread read found squatted, and surfaces what moved —
|
||||
/// `BoardStore.displaceCommentClaimedNames(_:)` joined to `BannerCenter.postDisplacedClaimedNames`.
|
||||
@ObservationIgnored
|
||||
public var displaceSquatters: (([ClaimedNameSquatter]) -> Void)?
|
||||
|
||||
/// Deletes one comment — `BoardStore.deleteComment(_:inCard:)`. Immediate, no confirm, and the
|
||||
/// undo step is already registered store-side.
|
||||
@ObservationIgnored
|
||||
public var deleteComment: ((ItemID) -> Bool)?
|
||||
|
||||
/// One inline edit session's save — `BoardStore.editComment(_:inCard:body:)`, handed to each
|
||||
/// session as it opens.
|
||||
@ObservationIgnored
|
||||
public var editComment: ((ItemID, String) -> Bool)?
|
||||
|
||||
/// Imports files into an authoring surface's `attachments/` —
|
||||
/// `BoardStore.importCommentAttachments(_:inCard:target:)`.
|
||||
@ObservationIgnored
|
||||
public var importAttachments: (([URL], CommentTarget) -> Void)?
|
||||
|
||||
/// Moves one authoring chip's file to the system Trash —
|
||||
/// `BoardStore.removeCommentAttachment(named:inCard:target:)`.
|
||||
@ObservationIgnored
|
||||
public var removeAttachment: ((String, CommentTarget) -> Void)?
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// **The window-open sequence** — the sweep first, then the read (01-storage-format.md § Enhanced
|
||||
/// schema: "crash residue sweeps at the next card-window open"; the brief's order).
|
||||
///
|
||||
/// The sweep goes first because it *removes* folders, and a thread read taken before it would
|
||||
/// describe a `comments/.trash/` that is about to stop existing. It costs no bracket at all on a
|
||||
/// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one.
|
||||
public func open() {
|
||||
sweepTrashResidue?()
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Re-reads the thread and the draft, and routes anything the read found to be repaired.
|
||||
///
|
||||
/// **Foreign arrivals snap** (05 ▸ The comments column: "foreign arrivals snap in per the motion
|
||||
/// language"). `withAnimation(nil)` is the motion language's own spelling of that — the bare
|
||||
/// assignment `Motion.reloadAnimation` returns `nil` for, applied here rather than inherited,
|
||||
/// because this runs from an `onChange` that may still be inside the store's own reload
|
||||
/// transaction and a thread must not ride the board's structural spring.
|
||||
public func reload() {
|
||||
guard let readThread else { return }
|
||||
let thread = readThread()
|
||||
let draft = readDraft?()
|
||||
|
||||
withAnimation(nil) {
|
||||
self.thread = thread
|
||||
}
|
||||
composer.adopt(draft: draft)
|
||||
// The open session follows disk under the same dirty-buffer-wins rule the body has: a clean
|
||||
// editor takes the foreign edit, a dirty one keeps the keystrokes. A session whose comment
|
||||
// has gone — deleted here, or by another window — is simply dropped; there is no error UI to
|
||||
// show for a file that is not there (05 ▸ Deletion & lifecycle's "nowhere left to write").
|
||||
if let editing {
|
||||
guard let comment = thread.comments.first(where: { $0.id == editing.commentID }) else {
|
||||
self.editing = nil
|
||||
return
|
||||
}
|
||||
editing.adopt(diskBody: comment.body)
|
||||
}
|
||||
|
||||
// The thread's own claimed-name squatters — `comments/.draft`, `comments/.trash`, and a
|
||||
// comment's `attachments` — displaced through the store's batch, with the warning-tone
|
||||
// notice naming what moved. Detection is the read's, the repair is the store's, and the
|
||||
// notice is the banner surface's; this line is only the join.
|
||||
let squatters: [ClaimedNameSquatter] = thread.defects.compactMap {
|
||||
if case let .claimedNameSquatted(work) = $0 { work } else { nil }
|
||||
}
|
||||
if !squatters.isEmpty {
|
||||
displaceSquatters?(squatters)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The composer
|
||||
|
||||
/// **File ▸ Add Comment**, and the pane's own "add a comment" affordances: ask the composer for
|
||||
/// the keyboard.
|
||||
///
|
||||
/// Turning Show Comments *on* is deliberately not here — it is the menu row's, because the row is
|
||||
/// the thing that knows the preference and because the pane has to be mounted before there is a
|
||||
/// composer to focus. See `AddCommentCommand`, which does both in the one order that works.
|
||||
public func focusComposer() {
|
||||
focusComposerRequests += 1
|
||||
}
|
||||
|
||||
// MARK: - The inline edit session
|
||||
|
||||
/// Opens a session over one comment — the context menu's **Edit** (05 ▸ The comments column).
|
||||
///
|
||||
/// A session already open is **committed** first rather than abandoned: the user asked to edit a
|
||||
/// different comment, which is not a request to throw away what they typed in this one. Editing
|
||||
/// the comment that is already open is a no-op, so a double-click on Edit cannot restart a session
|
||||
/// and lose its start-of-session bytes.
|
||||
public func beginEdit(_ commentID: ItemID) {
|
||||
guard isEditable else { return }
|
||||
guard editing?.commentID != commentID else { return }
|
||||
endEdit()
|
||||
guard let comment = thread.comments.first(where: { $0.id == commentID }) else { return }
|
||||
|
||||
let session = CommentEditSession(commentID: commentID, body: comment.body)
|
||||
session.save = { [weak self] text in
|
||||
self?.editComment?(commentID, text) ?? false
|
||||
}
|
||||
editing = session
|
||||
}
|
||||
|
||||
/// **Save / ⌘↩** — the session's commit point.
|
||||
public func commitEdit() {
|
||||
editing?.commit()
|
||||
editing = nil
|
||||
}
|
||||
|
||||
/// **Cancel / Escape** — reverts to session-start bytes.
|
||||
public func cancelEdit() {
|
||||
editing?.cancel()
|
||||
editing = nil
|
||||
reload()
|
||||
}
|
||||
|
||||
/// The window close's end of the session — a flush, never a revert (see
|
||||
/// `CommentEditSession.endOnClose`).
|
||||
private func endEdit() {
|
||||
editing?.commit()
|
||||
editing = nil
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
/// The context menu's **Delete** — "immediate and undoable, no confirm" (05 ▸ The comments
|
||||
/// column). The step is `BoardStore.deleteComment`'s; nothing is registered here.
|
||||
///
|
||||
/// A session open over the comment being deleted ends first, and ends as a *commit*: the user's
|
||||
/// last keystrokes belong in the file that is about to move into `comments/.trash/`, so that an
|
||||
/// undo brings back what they wrote rather than what the file said a debounce ago.
|
||||
public func delete(_ commentID: ItemID) {
|
||||
guard isEditable else { return }
|
||||
if editing?.commentID == commentID {
|
||||
endEdit()
|
||||
}
|
||||
guard deleteComment?(commentID) == true else { return }
|
||||
reload()
|
||||
}
|
||||
|
||||
/// The context menu's **Reveal in Finder** — the comment's own folder.
|
||||
public func reveal(_ commentID: ItemID) {
|
||||
guard let cardFolder else { return }
|
||||
let folder = CommentThread.commentFolder(commentID, inCard: cardFolder)
|
||||
NSWorkspace.shared.activateFileViewerSelecting([folder])
|
||||
}
|
||||
|
||||
// MARK: - Attachments on the two authoring surfaces
|
||||
|
||||
/// Where a target's files live on disk — what a chip's thumbnail, its Quick Look and its Reveal
|
||||
/// resolve against. `nil` before the window has joined its board.
|
||||
public func attachmentURL(_ name: String, in target: CommentTarget) -> URL? {
|
||||
guard let cardFolder else { return nil }
|
||||
return target.folder(inCard: cardFolder)
|
||||
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
|
||||
.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
/// The paperclip affordance and the drop carve-out — **one act with two pointers at it**, the
|
||||
/// attachments section's add-affordance rule one level down.
|
||||
///
|
||||
/// The security-scope dance is `CardAttachments.add()`'s, verbatim and for its reason: `start…`
|
||||
/// answers false for a URL that carries no scope of its own, so only the ones that opened are
|
||||
/// closed again.
|
||||
public func addAttachments(to target: CommentTarget) {
|
||||
guard isEditable, cardFolder != nil else { return }
|
||||
let urls = AttachmentPanel.chooseFiles(message: "Choose files to attach to this comment.")
|
||||
guard !urls.isEmpty else { return }
|
||||
importFiles(urls, to: target)
|
||||
}
|
||||
|
||||
/// The drop's write. Named separately from `addAttachments(to:)` because a drop already has its
|
||||
/// URLs and must not open a panel.
|
||||
public func importFiles(_ urls: [URL], to target: CommentTarget) {
|
||||
guard isEditable, !urls.isEmpty else { return }
|
||||
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
|
||||
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
|
||||
importAttachments?(urls, target)
|
||||
reload()
|
||||
}
|
||||
|
||||
/// An authoring chip's Remove — the **system** Trash (05 ▸ The comments column).
|
||||
public func removeFile(named name: String, from target: CommentTarget) {
|
||||
guard isEditable else { return }
|
||||
removeAttachment?(name, target)
|
||||
reload()
|
||||
}
|
||||
|
||||
// MARK: - The close flush
|
||||
|
||||
/// **The window's close, in the order the brief fixes: saves first, purge last.**
|
||||
///
|
||||
/// The inline session flushes as the body's does (a flush, never a revert — a close is not an
|
||||
/// abandon), then the composer's draft lands, and only then is `comments/.trash/` emptied. The
|
||||
/// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and
|
||||
/// running it before a session's save could remove a folder that save was about to write into.
|
||||
///
|
||||
/// Ending twice does nothing the second time — the sessions latch, and a purge over an empty
|
||||
/// trash is a no-op — which is what makes the two paths that call this (a window closed on its
|
||||
/// own, and the board's close flush driving it) safe to both exist.
|
||||
public func endSession() {
|
||||
editing?.endOnClose()
|
||||
editing = nil
|
||||
composer.flush()
|
||||
purgeTrash?()
|
||||
}
|
||||
|
||||
/// Whether this pane holds content its files do not — the inline session's buffer, and only it.
|
||||
///
|
||||
/// **The draft is deliberately not counted.** 05 is explicit that the composer needs no
|
||||
/// save-or-lose ceremony ("Close and quit just proceed — no DirtyBufferGuard, nothing to lose"),
|
||||
/// and this property's one consumer is File ▸ Save as Template's carve-out, which is about
|
||||
/// keystrokes a suspended save cannot flush. An inline comment edit *is* such a case — it is a
|
||||
/// body-edit session in miniature — so it counts exactly as the body's does.
|
||||
public var holdsUnsavedContent: Bool {
|
||||
editing?.isDirty == true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The focused value
|
||||
|
||||
/// The focused card window's comments pane, beside `FocusedValues.cardAttachments` — see
|
||||
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
|
||||
struct FocusedCardCommentsKey: FocusedValueKey {
|
||||
typealias Value = CardComments
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var cardComments: CardComments? {
|
||||
get { self[FocusedCardCommentsKey.self] }
|
||||
set { self[FocusedCardCommentsKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - View ▸ Show Comments
|
||||
|
||||
/// View ▸ Show Comments (checkmark toggle, **no default chord**) — 11-command-nexus.md;
|
||||
/// 05-card-window.md ▸ The comments column.
|
||||
///
|
||||
/// ### One bit, app-wide, persisted — and the checkmark *is* the bit
|
||||
///
|
||||
/// > **View ▸ Show Comments** is a checkmark toggle à la Show Trash, and its choice is **app-wide and
|
||||
/// > persisted across restarts**. One bit, no content-derived auto-show: checked, every card window
|
||||
/// > carries the pane (a comment-less card shows the empty thread and the composer — the invitation
|
||||
/// > is the point); unchecked, threads and drafts are out of sight until the user says otherwise, the
|
||||
/// > Show Trash bargain. The checkmark reads the bit — the menu never lies.
|
||||
///
|
||||
/// `@AppStorage` is that sentence with no machinery under it: the row's `isOn` reads the very default
|
||||
/// every card window's pane reads, so there is no per-window mirror to keep in step and no auto-show
|
||||
/// rule that could disagree with the tick. "Deleting the last comment never closes the pane" needs no
|
||||
/// code at all for the same reason — nothing but this row writes the key.
|
||||
///
|
||||
/// **No App Group.** The suite is `UserDefaults.standard`, which the sandbox already scopes to this
|
||||
/// one app (`AppPreferences`); the group suite the 2026-07-29 ruling named went with the App Group
|
||||
/// itself when the edition split collapsed.
|
||||
///
|
||||
/// Validation is **scope and nothing else**: with no card window in front there is no `cardComments`
|
||||
/// focused value and the row disables. The read-only lock is deliberately not part of it — showing a
|
||||
/// pane is not a mutation, exactly as entering Edit is not (`EditBodyCommand`).
|
||||
struct ShowCommentsCommand: View {
|
||||
|
||||
@FocusedValue(\.cardComments) private var comments
|
||||
@AppStorage(AppPreferences.showCommentsKey) private var isShown = true
|
||||
|
||||
/// The row's validation, as a value a test can hold — `EditBodyCommand.isEnabled`'s shape, for
|
||||
/// its reason: a menu item's `.disabled` is otherwise only observable by driving the menu bar.
|
||||
static func isEnabled(_ comments: CardComments?) -> Bool {
|
||||
comments != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Toggle("Show Comments", isOn: $isShown)
|
||||
.disabled(!Self.isEnabled(comments))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View ▸ Comments Beside Body
|
||||
|
||||
/// View ▸ Comments Beside Body (checkmark toggle, **no default chord**) — "checked = side-by-side
|
||||
/// (default), unchecked = body over comments; app-wide, persisted" (11-command-nexus.md;
|
||||
/// 05-card-window.md ▸ Composition).
|
||||
///
|
||||
/// `ShowCommentsCommand`'s shape exactly, and for its reasons — one persisted bit, the checkmark
|
||||
/// reading it, scope-only validation. The two rows sit together because they are the pane's two
|
||||
/// user-facing facts and 11 lists them adjacent.
|
||||
///
|
||||
/// It stays enabled while Show Comments is off. The row is a *layout* preference, not a second
|
||||
/// visibility switch, and a user arranging their window before turning the pane on is doing something
|
||||
/// perfectly ordinary — the alternative (disabling it) would also make the checkmark lie about a bit
|
||||
/// that is still stored and still applies the moment the pane appears.
|
||||
struct CommentsBesideBodyCommand: View {
|
||||
|
||||
@FocusedValue(\.cardComments) private var comments
|
||||
@AppStorage(AppPreferences.commentsBesideBodyKey) private var isBeside = true
|
||||
|
||||
static func isEnabled(_ comments: CardComments?) -> Bool {
|
||||
comments != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Toggle("Comments Beside Body", isOn: $isBeside)
|
||||
.disabled(!Self.isEnabled(comments))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Add Comment
|
||||
|
||||
/// File ▸ Add Comment (**no default chord**) — card window, all tiers (11-command-nexus.md;
|
||||
/// 05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// ### One gesture, two effects, in the one order that works
|
||||
///
|
||||
/// > **File ▸ Add Comment** flips the bit on when it's off (the gesture *is* the user choosing to see
|
||||
/// > comments — same persistence) and focuses the composer in one gesture.
|
||||
///
|
||||
/// The write comes first and the focus request second, because the composer does not exist to be
|
||||
/// focused until the pane is mounted. The request is a counter on the window's handle rather than a
|
||||
/// call into a text view (`CardComments.focusComposerRequests`), so the pane picks it up on the
|
||||
/// update after the one that mounted it — which is the only ordering that survives the pane arriving
|
||||
/// in the same frame.
|
||||
///
|
||||
/// **Flipping the bit is persisted like any other flip of it.** That is the ruling, stated in the
|
||||
/// design as "the same user choice": a user who reaches for Add Comment has said they want to see
|
||||
/// comments, and a visibility that reverted at the next window would make the row a one-shot.
|
||||
///
|
||||
/// Validation is scope. The read-only lock is not part of it, `EditBodyCommand`'s rule: this focuses
|
||||
/// a text surface, and 02-architecture.md keeps editor buffers alive under the lock (only their saves
|
||||
/// suspend), so a locked board can still be typed into and its text copied out. What the lock does
|
||||
/// disable is the composer's Post button and its paperclip, in place, where they are.
|
||||
struct AddCommentCommand: View {
|
||||
|
||||
@FocusedValue(\.cardComments) private var comments
|
||||
@AppStorage(AppPreferences.showCommentsKey) private var isShown = true
|
||||
|
||||
static func isEnabled(_ comments: CardComments?) -> Bool {
|
||||
comments != nil
|
||||
}
|
||||
|
||||
/// What the row does to the two pieces of state it touches, as a pure pair a test can drive: the
|
||||
/// bit's new value, and whether a focus request is owed.
|
||||
///
|
||||
/// It is extracted for the reason every menu-row rule in this codebase is: "turns it on when it
|
||||
/// is off, and focuses either way" is one sentence with two clauses, and the clause that
|
||||
/// regresses silently is the second one — a row that only focused when it had just turned the
|
||||
/// pane on would look correct in the demo and be wrong every time after.
|
||||
static func effect(isShown: Bool) -> (isShown: Bool, focusesComposer: Bool) {
|
||||
(isShown: true, focusesComposer: true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("Add Comment") {
|
||||
let effect = Self.effect(isShown: isShown)
|
||||
isShown = effect.isShown
|
||||
guard effect.focusesComposer else { return }
|
||||
comments?.focusComposer()
|
||||
}
|
||||
.disabled(!Self.isEnabled(comments))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
// MARK: - Where the comments pane mounts
|
||||
|
||||
/// **Body and comments, side by side or stacked** — the layout option, as a value
|
||||
/// (05-card-window.md ▸ Composition, ruled 2026-07-29: "side-by-side is the default; **View ▸
|
||||
/// Comments Beside Body** unchecked stacks them — body pane above, comments pane below at a fixed
|
||||
/// ≈3:2 split, each keeping its own scroll — for narrow displays. App-wide, persisted").
|
||||
///
|
||||
/// It is an enum rather than the `Bool` the menu row stores because the two mounts differ in three
|
||||
/// arithmetic facts — the axis, the split, and whether the window's minimum width grows — and a
|
||||
/// `Bool` at each of those three sites is three chances to read it backwards.
|
||||
///
|
||||
/// **The panes are identical in both mounts.** Nothing below describes a pane; it describes the frame
|
||||
/// one is given. That is the componentization the design asks for stated as code: `CardCommentsPane`
|
||||
/// has no idea which of these it is inside, and neither has the body column.
|
||||
public enum CommentsMount: Sendable, Equatable {
|
||||
/// Comments beside the body, sharing the window's width. The default.
|
||||
case beside
|
||||
/// Comments under the body, sharing the window's height at ≈3:2.
|
||||
case stacked
|
||||
|
||||
/// The menu row's bit, read the one way — checked means beside.
|
||||
public init(besideBody: Bool) {
|
||||
self = besideBody ? .beside : .stacked
|
||||
}
|
||||
|
||||
/// **The stacked split: three parts body to two parts comments** — 05's "≈3:2", written once.
|
||||
///
|
||||
/// A fraction rather than a point height so the split survives every window size and every system
|
||||
/// text size, and a *pure* one so "the body keeps three fifths" is a fact a suite asserts rather
|
||||
/// than something checked by eye at one window height.
|
||||
public static let stackedBodyFraction: CGFloat = 3.0 / 5.0
|
||||
|
||||
/// How tall the body pane is in a window `height` tall — `height` itself when the panes are side
|
||||
/// by side, since then the body owns the full column.
|
||||
///
|
||||
/// Clamped at zero: a window mid-animation can propose a negative height, and a frame with one
|
||||
/// would be a layout error rather than a small pane.
|
||||
public func bodyHeight(in height: CGFloat) -> CGFloat {
|
||||
switch self {
|
||||
case .beside: max(0, height)
|
||||
case .stacked: max(0, height * Self.stackedBodyFraction)
|
||||
}
|
||||
}
|
||||
|
||||
/// The comments pane's height, the remainder — so the two always add up to the window and the
|
||||
/// divider between them never has a gap or an overlap to account for.
|
||||
public func commentsHeight(in height: CGFloat) -> CGFloat {
|
||||
max(0, height) - bodyHeight(in: height)
|
||||
}
|
||||
|
||||
/// **Whether the window's minimum width grows** — "the window's minimum width grows only while
|
||||
/// the column is shown side-by-side" (05 ▸ Composition).
|
||||
///
|
||||
/// Stacked, the comments pane takes the width the body already had, so a narrow display keeps the
|
||||
/// minimum it has always had. That is the whole reason the option exists.
|
||||
public var widensWindow: Bool { self == .beside }
|
||||
}
|
||||
|
||||
// MARK: - Which way the thread runs
|
||||
|
||||
/// **The thread's sort direction** — "chronological ascending by default, flippable to newest-first
|
||||
/// (app-wide, persisted)" (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// The *order* itself is storage's (`CommentThread.sorted`, `created` ascending with the undated
|
||||
/// after the dated and a canonical folder-name tie-break) and is not re-derived here: descending is
|
||||
/// that order reversed, so a tie between two undated comments breaks the same way in both directions
|
||||
/// rather than two sort predicates agreeing by luck.
|
||||
public enum CommentSortDirection: Sendable, Equatable, CaseIterable {
|
||||
/// Oldest first — chronology as it happened, and the default.
|
||||
case ascending
|
||||
/// Newest first.
|
||||
case descending
|
||||
|
||||
/// The menu/header control's bit, read the one way.
|
||||
public init(newestFirst: Bool) {
|
||||
self = newestFirst ? .descending : .ascending
|
||||
}
|
||||
|
||||
public var isNewestFirst: Bool { self == .descending }
|
||||
|
||||
/// Applies the direction to a thread the loader already sorted.
|
||||
///
|
||||
/// **A reverse, never a re-sort**: see the type's note — the loader's predicate is the only one
|
||||
/// in the app that decides what "before" means for two comments.
|
||||
public func apply(to comments: [Comment]) -> [Comment] {
|
||||
self == .ascending ? comments : comments.reversed()
|
||||
}
|
||||
|
||||
/// **Whether the composer sits above the thread** — "The composer sits at the thread's newest end
|
||||
/// (bottom ascending, top descending)" (05 ▸ The comments column).
|
||||
///
|
||||
/// One fact, derived once, because the composer's placement and the pane's opening scroll target
|
||||
/// are the same sentence read twice ("the window opens scrolled to it") and a window that opened
|
||||
/// at the wrong end would be wrong only for descending users.
|
||||
public var placesComposerFirst: Bool { self == .descending }
|
||||
|
||||
/// What the header control says it will do — the help text and the accessibility label, which are
|
||||
/// the same string and must stay so.
|
||||
public var controlLabel: String {
|
||||
self == .ascending ? "Oldest First" : "Newest First"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The header's count
|
||||
|
||||
/// The comments pane's small-caps header line — **"Comments · 3"** (05-card-window.md ▸ The comments
|
||||
/// column: "The section header carries the count").
|
||||
///
|
||||
/// Pure and separate from the view for the reason every count line in this app is: the empty case is
|
||||
/// the one that gets written wrong, and 05 is explicit that a comment-less card still shows the pane
|
||||
/// ("the empty thread and the composer — the invitation is the point"), so the zero has to render as
|
||||
/// a count rather than as an absence.
|
||||
public enum CommentsHeader {
|
||||
public static func title(count: Int) -> String {
|
||||
"Comments · \(count)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One comment's author line
|
||||
|
||||
/// The line above a comment's body: **who says they wrote it, when, and whether it has been edited**
|
||||
/// (05-card-window.md ▸ The comments column: "an author line (self-reported `author`, unattributed
|
||||
/// when absent; timestamp; '· edited' when `modified` differs from `created`)").
|
||||
///
|
||||
/// ### Absent means absent
|
||||
///
|
||||
/// "Missing renders unattributed" (`Comment.author`) — and *unattributed* is the absence of a name,
|
||||
/// not the word "unattributed" drawn in its place. A placeholder there would be this app inventing an
|
||||
/// identity for a file that deliberately carries none, which is the same reason there are no avatars.
|
||||
/// So a comment with no `author` renders its timestamp alone, and one with neither renders nothing at
|
||||
/// all rather than an empty row of separators.
|
||||
public enum CommentAuthorLine {
|
||||
|
||||
/// The separator every segment of this window's quiet lines uses — the card's created/modified
|
||||
/// line's, shared so the two read as one family.
|
||||
private static let separator = " · "
|
||||
|
||||
/// Composes the line, or `nil` when there is nothing to say.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - author: the self-reported `author`, already unwrapped. An **empty** string is treated as
|
||||
/// absent: the Writer never writes `author: ""` (it omits the key instead), so one on disk is
|
||||
/// a hand edit, and rendering a blank name with a separator beside it would be noise.
|
||||
/// - timestamp: the already-formatted `created`, or `nil` where the key is missing or
|
||||
/// unreadable (the coerce tier — a comment with no date still renders).
|
||||
/// - isEdited: `Comment.isEdited`.
|
||||
public static func text(author: String?, timestamp: String?, isEdited: Bool) -> String? {
|
||||
var parts: [String] = []
|
||||
if let author, !author.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
parts.append(author)
|
||||
}
|
||||
if let timestamp, !timestamp.isEmpty {
|
||||
parts.append(timestamp)
|
||||
}
|
||||
// "· edited" hangs off something. On a comment with neither a name nor a date there is no
|
||||
// line to hang it from, and a bare "edited" would be a row that says only that.
|
||||
guard !parts.isEmpty else { return nil }
|
||||
if isEdited {
|
||||
parts.append("edited")
|
||||
}
|
||||
return parts.joined(separator: separator)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The drop carve-out
|
||||
|
||||
/// **Which folder a file dropped in this window lands in** (05-card-window.md ▸ Attachments, ruled
|
||||
/// 2026-07-29: "One carve-out by hover target … a file dropped **within the comment composer's
|
||||
/// bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's
|
||||
/// bounds** to that comment's — the window-wide card default covers everywhere else").
|
||||
///
|
||||
/// ### Why this is a value and not three `.onDrop`s trusted to nest correctly
|
||||
///
|
||||
/// SwiftUI does dispatch a drop to the innermost target, and the implementation leans on exactly
|
||||
/// that — the composer and the inline editor each carry their own drop delegate inside the
|
||||
/// window-wide one. But *what the rule is* and *whether the nesting expresses it* are two questions,
|
||||
/// and only the first one is checkable without a running window. This enum is the first question's
|
||||
/// answer; the modifiers are the second's.
|
||||
///
|
||||
/// ### The precedence, and why it is stated at all
|
||||
///
|
||||
/// An inline edit session opens **over a comment row**, and the composer is a separate surface at the
|
||||
/// thread's newest end, so in practice the two never overlap and the order is moot. It is fixed
|
||||
/// anyway — the inline session wins — because the case where it stops being moot is a layout change,
|
||||
/// and a layout change should not be able to silently move a user's files into the wrong folder.
|
||||
public enum CommentDropCarveOut {
|
||||
|
||||
/// What the pointer is over, as the window knows it.
|
||||
///
|
||||
/// Deliberately two independent facts rather than one enum: each surface answers only for itself
|
||||
/// (a drop delegate knows its own bounds and nothing else), and the arbitration is this type's.
|
||||
public struct Hover: Sendable, Equatable {
|
||||
public var isOverComposer: Bool
|
||||
public var inlineEdit: ItemID?
|
||||
|
||||
public init(isOverComposer: Bool = false, inlineEdit: ItemID? = nil) {
|
||||
self.isOverComposer = isOverComposer
|
||||
self.inlineEdit = inlineEdit
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the files go.
|
||||
public enum Landing: Sendable, Equatable {
|
||||
/// The window-wide default: the **card**'s `attachments/` (`CardWindowDropDelegate`).
|
||||
case card
|
||||
/// One of the two authoring surfaces.
|
||||
case comment(CommentTarget)
|
||||
}
|
||||
|
||||
/// The rule, in one expression.
|
||||
public static func landing(for hover: Hover) -> Landing {
|
||||
if let editing = hover.inlineEdit { return .comment(.comment(editing)) }
|
||||
if hover.isOverComposer { return .comment(.draft) }
|
||||
return .card
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - CardCommentsPane
|
||||
|
||||
/// The card window's **comments pane** — the middle of the three componentized panes
|
||||
/// (05-card-window.md ▸ Composition, ▸ The comments column).
|
||||
///
|
||||
/// ### It does not know where it is mounted
|
||||
///
|
||||
/// Beside the body or under it, the pane is identical — "the panes are identical in both mounts" —
|
||||
/// so nothing in this file asks. It fills the frame it is given, scrolls its own content, and the
|
||||
/// arrangement is `CardWindowView`'s (`CommentsMount`). That is the componentization the 2026-07-29
|
||||
/// re-composition asks for, stated as an absence: there is no layout parameter here to get wrong.
|
||||
///
|
||||
/// ### Header, thread, composer — and the composer is at the newest end
|
||||
///
|
||||
/// > The composer sits at the thread's newest end (bottom ascending, top descending) and the window
|
||||
/// > opens scrolled to it — a thread opens where the conversation is happening.
|
||||
///
|
||||
/// Both halves come from one value (`CommentSortDirection.placesComposerFirst`), so the scroll target
|
||||
/// and the composer's position cannot disagree — a window that opened at the wrong end would be wrong
|
||||
/// only for the users who had flipped the sort, which is exactly the bug that ships.
|
||||
struct CardCommentsPane: View {
|
||||
|
||||
let comments: CardComments
|
||||
/// The **card**'s folder — what relative images and links in every comment resolve against.
|
||||
let cardFolder: URL?
|
||||
/// The window's thumbnail memory, shared with the sidebar's attachment rows so a file shown in
|
||||
/// both places is rendered once.
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
|
||||
/// **App-wide and persisted** (05 ▸ The comments column; 11-command-nexus.md files the header
|
||||
/// control under Configuration controls). Read here rather than mirrored onto the window's handle
|
||||
/// because there is exactly one of it and every open pane obeys it.
|
||||
@AppStorage(AppPreferences.commentsNewestFirstKey) private var newestFirst = false
|
||||
|
||||
/// The composer's scroll anchor. A constant rather than a generated id because there is one
|
||||
/// composer and two possible places for it.
|
||||
private static let composerAnchor = "comments.composer"
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
private var direction: CommentSortDirection { CommentSortDirection(newestFirst: newestFirst) }
|
||||
private var ordered: [Comment] { direction.apply(to: comments.thread.comments) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
|
||||
thread
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// 10-accessibility.md's container label for the pane ("Comments, N"). The elements inside it
|
||||
// and their custom actions are phase 3's; the container is here because the pane would
|
||||
// otherwise be an unnamed region the moment it exists.
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Comments, \(comments.thread.comments.count)")
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
/// "Comments · 3" with the sort control beside it — the sidebar's own small-caps section header,
|
||||
/// shared rather than restated so the pane and the sidebar read as one window (05: "the section
|
||||
/// header carries the count and the sort-direction control").
|
||||
private var header: some View {
|
||||
CardSidebarSectionHeader(title: CommentsHeader.title(count: comments.thread.comments.count)) {
|
||||
sortControl
|
||||
}
|
||||
}
|
||||
|
||||
/// The sort-direction control — **Tab-reachable beside the count** (11-command-nexus.md ▸
|
||||
/// Configuration controls).
|
||||
///
|
||||
/// A button rather than a segmented picker: there are two states and the second one is the
|
||||
/// reverse of the first, so a toggle whose glyph says which way the thread currently runs is the
|
||||
/// smaller thing that says the same. Its help text and its accessibility label are the same
|
||||
/// string (`CommentSortDirection.controlLabel`) — one label, two readers.
|
||||
private var sortControl: some View {
|
||||
Button {
|
||||
newestFirst.toggle()
|
||||
} label: {
|
||||
Image(systemName: direction == .ascending ? "arrow.down" : "arrow.up")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(direction.controlLabel)
|
||||
.accessibilityLabel("Sort")
|
||||
.accessibilityValue(direction.controlLabel)
|
||||
}
|
||||
|
||||
// MARK: - The thread
|
||||
|
||||
private var thread: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) {
|
||||
if direction.placesComposerFirst {
|
||||
composer.id(Self.composerAnchor)
|
||||
}
|
||||
ForEach(ordered) { comment in
|
||||
CommentRowView(
|
||||
comment: comment,
|
||||
comments: comments,
|
||||
cardFolder: cardFolder,
|
||||
thumbnails: thumbnails
|
||||
)
|
||||
.id(comment.id)
|
||||
}
|
||||
if !direction.placesComposerFirst {
|
||||
composer.id(Self.composerAnchor)
|
||||
}
|
||||
}
|
||||
.padding(CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
// **The window opens scrolled to the composer** (05). Deferred one turn rather than run
|
||||
// inline: `scrollTo` needs the content laid out to have somewhere to scroll to, and a
|
||||
// thread's rows measure their own rendered height (`CommentBodyView`).
|
||||
.task {
|
||||
await Task.yield()
|
||||
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
||||
}
|
||||
// File ▸ Add Comment focuses the composer — which is no use if the composer is off
|
||||
// screen, so the same request scrolls to it. One request, both effects.
|
||||
.onChange(of: comments.focusComposerRequests) { _, _ in
|
||||
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The composer
|
||||
|
||||
/// **The composer edits `comments/.draft/`** — always visible, at the thread's newest end
|
||||
/// (05 ▸ The comments column).
|
||||
private var composer: some View {
|
||||
CommentComposerView(comments: comments, thumbnails: thumbnails)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The authoring surfaces' drop carve-out
|
||||
|
||||
/// **A file dropped within an authoring surface's bounds lands in *that* surface's `attachments/`**
|
||||
/// (05-card-window.md ▸ Attachments, ruled 2026-07-29 — the hover-target carve-out on the
|
||||
/// window-wide card default).
|
||||
///
|
||||
/// The *arbitration* is SwiftUI's own innermost-target dispatch: this delegate is attached **inside**
|
||||
/// `CardWindowDropDelegate`'s region, so a drag released over the composer or over an open inline
|
||||
/// editor is offered here first and never reaches the window's card default.
|
||||
///
|
||||
/// The *destination* is `CommentDropCarveOut`, which is why this takes a hover rather than a folder:
|
||||
/// the surface says what the pointer is over and the pure rule says where the files go, so the ruling
|
||||
/// — including which authoring surface wins where they would ever overlap — is checkable without a
|
||||
/// window and cannot drift from what the delegate actually does. A hover the rule resolves to the
|
||||
/// **card** never reaches here at all (no authoring surface is under the pointer, so no authoring
|
||||
/// surface has a drop target on screen), and this refuses it rather than guessing a folder.
|
||||
///
|
||||
/// Everything else is `CardWindowDropDelegate`'s, deliberately: the same payload predicate (files,
|
||||
/// not folders, not text), the same read-only refusal, the same `.copy` badge, and the same
|
||||
/// asynchronous URL load with the sandbox's security scope around it. Only the destination differs,
|
||||
/// which is the entire point of the carve-out.
|
||||
struct CommentAttachmentDropDelegate: DropDelegate {
|
||||
|
||||
let comments: CardComments
|
||||
/// What the pointer is over, as this surface knows it — see `CommentDropCarveOut.Hover`.
|
||||
let hover: CommentDropCarveOut.Hover
|
||||
|
||||
/// Where the rule says the files go, or `nil` for the window-wide card default.
|
||||
private var target: CommentTarget? {
|
||||
guard case let .comment(target) = CommentDropCarveOut.landing(for: hover) else { return nil }
|
||||
return target
|
||||
}
|
||||
|
||||
private var acceptsFileDrops: Bool { comments.isEditable && target != nil }
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
guard acceptsFileDrops else { return false }
|
||||
return CardWindowDrop.accepts(
|
||||
payloads: info.itemProviders(for: [.fileURL]).map(\.registeredTypeIdentifiers)
|
||||
)
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
DropProposal(operation: validateDrop(info: info) ? .copy : .cancel)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
guard acceptsFileDrops, let target else { return false }
|
||||
let providers = info.itemProviders(for: [.fileURL])
|
||||
guard !providers.isEmpty else { return false }
|
||||
|
||||
let comments = comments
|
||||
Task { @MainActor in
|
||||
var urls: [URL] = []
|
||||
for provider in providers {
|
||||
if let url = await FileDropLoading.url(from: provider) { urls.append(url) }
|
||||
}
|
||||
guard !urls.isEmpty else { return }
|
||||
comments.importFiles(urls, to: target)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,61 @@ enum CardWindowMetrics {
|
||||
previewPadding(bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The comments pane
|
||||
|
||||
/// How wide the comments pane is when it is mounted **beside** the body, in characters
|
||||
/// (05-card-window.md ▸ Composition; ▸ The comments column).
|
||||
///
|
||||
/// Wider than the sidebar and narrower than the body's default measure, which is what it holds:
|
||||
/// a rendered Markdown paragraph, an author line, a wrapping chip or two, and a composer. It is a
|
||||
/// *fixed* width for the sidebar's reason — "resize flex always goes to the body, never the fixed
|
||||
/// panes" (05 ▸ Composition) — so this is not a fraction of anything either.
|
||||
///
|
||||
/// Stacked, the pane takes the body's width instead and this number is not consulted at all,
|
||||
/// which is why the window's minimum grows only in the beside mount (`CommentsMount.widensWindow`).
|
||||
static let commentsColumnCharacters: CGFloat = 40
|
||||
|
||||
static func commentsColumnWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
columnWidth(characters: commentsColumnCharacters, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The narrowest the comments pane is allowed to get — the floor its share of the window's
|
||||
/// minimum is measured at, a shorter measure than the body's because a comment is a remark rather
|
||||
/// than a document.
|
||||
static let commentsMinimumCharacters: CGFloat = 28
|
||||
|
||||
static func commentsMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
columnWidth(characters: commentsMinimumCharacters, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The smallest an attachment chip may be before the row wraps — a thumbnail, a few characters of
|
||||
/// filename, and the padding around them. Middle truncation does the rest, so a long name shrinks
|
||||
/// rather than widening the pane.
|
||||
static func commentChipMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
(attachmentThumbnailSide(bodyPointSize: bodyPointSize) + bodyPointSize * 6).rounded()
|
||||
}
|
||||
|
||||
/// The composer's resting height — **four lines and a bit**, which is the shape of the thing it
|
||||
/// invites: enough that a two-sentence remark is visible whole, short enough that it never
|
||||
/// dominates a thread. It scrolls internally past that rather than growing the pane, so a long
|
||||
/// draft cannot push the thread off screen.
|
||||
static func composerHeight(bodyPointSize: CGFloat) -> CGFloat {
|
||||
(lineHeight(bodyPointSize: bodyPointSize) * 4.5).rounded()
|
||||
}
|
||||
|
||||
/// An inline edit session's editor, one line taller than the composer: it opens over text that
|
||||
/// already exists, so the common case is reading it before changing it.
|
||||
static func inlineEditorHeight(bodyPointSize: CGFloat) -> CGFloat {
|
||||
(lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
|
||||
}
|
||||
|
||||
/// The gap between two comments in the thread — a full gutter, one step larger than the rhythm
|
||||
/// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
|
||||
/// rather than with its neighbour.
|
||||
static func commentSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
||||
gutter(bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
// MARK: - The rendered body
|
||||
|
||||
/// One step of structural indent in Preview — a list level, a quote level. One and a half ems,
|
||||
@@ -149,9 +204,22 @@ enum CardWindowMetrics {
|
||||
|
||||
/// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough
|
||||
/// for a title, its date line and a few lines of body.
|
||||
static func minimumSize(bodyPointSize: CGFloat) -> CGSize {
|
||||
CGSize(
|
||||
width: sidebarWidth(bodyPointSize: bodyPointSize) + bodyMinimumWidth(bodyPointSize: bodyPointSize),
|
||||
///
|
||||
/// - Parameter commentsColumn: whether the comments pane is currently mounted **beside** the body
|
||||
/// — "the window's minimum width grows only while the column is shown side-by-side"
|
||||
/// (05-card-window.md ▸ Composition). Stacked, or hidden, the pane costs the window no width at
|
||||
/// all, which is the narrow-display case the layout option exists for. The height is unchanged
|
||||
/// either way: a stacked pane divides the height it is given rather than demanding more, and a
|
||||
/// window at its minimum height simply gets a short thread.
|
||||
///
|
||||
/// Defaulted to `false` so every caller that predates the comments pane still asks the same
|
||||
/// question it always did.
|
||||
static func minimumSize(bodyPointSize: CGFloat, commentsColumn: Bool = false) -> CGSize {
|
||||
let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0
|
||||
return CGSize(
|
||||
width: sidebarWidth(bodyPointSize: bodyPointSize)
|
||||
+ bodyMinimumWidth(bodyPointSize: bodyPointSize)
|
||||
+ comments,
|
||||
height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,22 @@ import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - CardWindowView
|
||||
|
||||
/// The card window's content: **two full-height, independently scrolling columns** — a wide body
|
||||
/// column leading, a narrow attributes sidebar trailing (05-card-window.md ▸ Composition).
|
||||
/// The card window's content: **three componentized panes** — a wide body pane leading, the comments
|
||||
/// pane in the middle when it is shown, and the narrow attributes sidebar trailing
|
||||
/// (05-card-window.md ▸ Composition, re-composed 2026-07-29).
|
||||
///
|
||||
/// ### The composition arranges; the panes do not know about each other
|
||||
///
|
||||
/// > each an independent component with its own scroll, arranged by the window's layout rather than
|
||||
/// > wired to each other; componentization is the rule, so the comments pane mounts beside the body
|
||||
/// > or below it (the layout option) without either pane knowing which.
|
||||
///
|
||||
/// That is enforced here by there being nothing to enforce: `CardCommentsPane` takes no layout
|
||||
/// parameter and the body column takes none either. This view puts one of them in a frame; the
|
||||
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
|
||||
///
|
||||
/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width, and
|
||||
/// the resize flex still goes to the body and never to the two fixed panes.
|
||||
///
|
||||
/// ### What this milestone builds, and what it deliberately does not
|
||||
///
|
||||
@@ -71,6 +85,10 @@ struct CardWindowView: View {
|
||||
/// This window's attachments section: the listing, the selection, and the two writes it starts
|
||||
/// (05 ▸ Attachments).
|
||||
let attachments: CardAttachments
|
||||
/// This window's comments pane: the thread, the composer's draft buffer, and the one open inline
|
||||
/// edit session (05 ▸ The comments column). It lives on the window's *session* so the close flush
|
||||
/// can reach it, which is why it arrives here rather than being made here.
|
||||
let comments: CardComments
|
||||
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
/// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole
|
||||
@@ -79,11 +97,24 @@ struct CardWindowView: View {
|
||||
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
|
||||
let onToggleTask: (Int, Bool) -> Void
|
||||
|
||||
/// **View ▸ Show Comments** and **View ▸ Comments Beside Body** — app-wide, persisted, read here
|
||||
/// rather than passed in (05 ▸ The comments column; ▸ Composition).
|
||||
///
|
||||
/// `@AppStorage` because the two bits genuinely are app-wide: every open card window obeys the
|
||||
/// same pair, so a window that took them as parameters would need something above it keeping
|
||||
/// every window in step with a value that has exactly one instance. It is also what makes the
|
||||
/// menu rows' checkmarks and these panes provably the same bit (`ShowCommentsCommand`).
|
||||
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
||||
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
||||
|
||||
/// The body font's point size, read once per body evaluation: every measurement in this view —
|
||||
/// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale
|
||||
/// together when the system text size changes.
|
||||
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
/// Where the comments pane sits, when it is shown at all.
|
||||
private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }
|
||||
|
||||
/// The two columns — **or the raw-source editor in place of both of them**.
|
||||
///
|
||||
/// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the
|
||||
@@ -111,10 +142,14 @@ struct CardWindowView: View {
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if rawSource.isActive {
|
||||
// **All three panes**, comments included: "Raw Source still swaps the entire content area
|
||||
// — all panes, comments included; the raw outlet's rule is unchanged" (05 ▸ The comments
|
||||
// column). The swap encloses the whole composition below rather than any one pane, which
|
||||
// is what keeps that true as the composition grows.
|
||||
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
|
||||
} else {
|
||||
HStack(spacing: 0) {
|
||||
bodyColumn
|
||||
contentPanes
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
||||
Divider()
|
||||
@@ -128,6 +163,59 @@ struct CardWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The body pane and the comments pane, in whichever of the two mounts is current — or the body
|
||||
/// pane alone, when Show Comments is off.
|
||||
///
|
||||
/// **The thread stays visible through body Edit in either mount**, and needs no rule of its own:
|
||||
/// Edit swaps the content of the body pane (`CardBodySurface`), which is *inside* the body column
|
||||
/// here, so nothing about the composition changes when the mode flips. That is the sidebar's own
|
||||
/// precedent, which 05 names when it states the rule.
|
||||
@ViewBuilder
|
||||
private var contentPanes: some View {
|
||||
if showComments {
|
||||
switch mount {
|
||||
case .beside:
|
||||
HStack(spacing: 0) {
|
||||
bodyColumn
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
||||
Divider()
|
||||
|
||||
commentsPane
|
||||
// Fixed, like the sidebar: "resize flex always goes to the body, never the
|
||||
// fixed panes" (05 ▸ Composition).
|
||||
.frame(width: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
case .stacked:
|
||||
// The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to
|
||||
// have one — `layoutPriority` and flexible frames express *preferences*, and this is
|
||||
// a ratio the design fixes. The body takes its share; the comments pane takes the
|
||||
// remainder, so the divider between them can never leave a gap or overlap.
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
bodyColumn
|
||||
.frame(height: mount.bodyHeight(in: proxy.size.height))
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
|
||||
Divider()
|
||||
|
||||
commentsPane
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bodyColumn
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
private var commentsPane: some View {
|
||||
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
|
||||
}
|
||||
|
||||
// MARK: - Body column
|
||||
|
||||
/// Title, the quiet created/modified line, then the body — 05's top-to-bottom order.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The chip row
|
||||
|
||||
/// A comment's or the draft's `attachments/`, as **chips** (05-card-window.md ▸ The comments column:
|
||||
/// "attachment chips when its `attachments/` is non-empty (Quick Look, the sidebar section's
|
||||
/// pattern)").
|
||||
///
|
||||
/// ### Chips, not rows — and the same parts
|
||||
///
|
||||
/// The sidebar's inventory is a vertical list because it is a *complete* listing of a card's files in
|
||||
/// a narrow column. A comment's files are a handful of things said in passing, so they wrap
|
||||
/// horizontally under the text that mentions them. What does not change is the anatomy — the small
|
||||
/// QuickLook thumbnail with its Finder-icon fallback, the middle-truncated filename, Space/click to
|
||||
/// Quick Look — because that is what "the sidebar section's pattern" names, and a user who has
|
||||
/// learned the sidebar has learned this.
|
||||
///
|
||||
/// ### The one difference that is a rule
|
||||
///
|
||||
/// > Chips on an authoring surface carry remove (to the **system** Trash — the sidebar row's rule); a
|
||||
/// > posted comment's chips are read-only, Quick Look only — Edit the comment to change its files.
|
||||
///
|
||||
/// `onRemove` is that sentence: `nil` is a posted comment's chip and there is no remove affordance at
|
||||
/// all — not a disabled one, because the file is not un-removable, it is simply not removable *here*.
|
||||
struct CommentAttachmentChips: View {
|
||||
|
||||
let names: [String]
|
||||
/// Where each name lives — the pane resolves it, since only it knows which authoring surface (or
|
||||
/// which posted comment) these belong to.
|
||||
let url: (String) -> URL?
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
/// `nil` on a posted comment's read-only chips; the remove write on an authoring surface's.
|
||||
var onRemove: ((String) -> Void)?
|
||||
|
||||
@Environment(\.displayScale) private var displayScale
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
// Wrapping, because a chip row is horizontal and a comment can carry more files than fit:
|
||||
// `Layout`-free wrapping through a flexible `WrappingHStack` would be a new layout to own, so
|
||||
// this leans on SwiftUI's own — a `LazyVGrid` with adaptive columns wraps and needs nothing.
|
||||
LazyVGrid(
|
||||
columns: [GridItem(
|
||||
.adaptive(minimum: CardWindowMetrics.commentChipMinimumWidth(bodyPointSize: pointSize)),
|
||||
spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize),
|
||||
alignment: .leading
|
||||
)],
|
||||
alignment: .leading,
|
||||
spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize)
|
||||
) {
|
||||
ForEach(names, id: \.self) { name in
|
||||
chip(name)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chip(_ name: String) -> some View {
|
||||
let fileURL = url(name)
|
||||
let side = CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize)
|
||||
let padding = CardWindowMetrics.previewPadding(bodyPointSize: pointSize)
|
||||
|
||||
HStack(spacing: padding) {
|
||||
CommentChipThumbnail(
|
||||
url: fileURL,
|
||||
side: side,
|
||||
thumbnails: thumbnails,
|
||||
displayScale: displayScale
|
||||
)
|
||||
.frame(width: side, height: side)
|
||||
|
||||
Text(name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
if let onRemove {
|
||||
Button {
|
||||
onRemove(name)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Remove")
|
||||
.accessibilityLabel("Remove \(name)")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, padding)
|
||||
.padding(.vertical, padding / 2)
|
||||
.background(.quaternary, in: Capsule(style: .continuous))
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
// Quick Look on click, the chip being small enough that a select-then-Space dance would be
|
||||
// ceremony over a thing you can already point at. The panel's ←/→ then walk this surface's
|
||||
// files, exactly as Space over the sidebar walks the card's.
|
||||
.onTapGesture {
|
||||
quickLook(name)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Open") {
|
||||
guard let fileURL else { return }
|
||||
NSWorkspace.shared.open(fileURL)
|
||||
}
|
||||
Button("Reveal in Finder") {
|
||||
guard let fileURL else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
|
||||
}
|
||||
if let onRemove {
|
||||
Divider()
|
||||
Button("Remove") { onRemove(name) }
|
||||
}
|
||||
}
|
||||
.help(name)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(name)
|
||||
}
|
||||
|
||||
private func quickLook(_ name: String) {
|
||||
guard let index = names.firstIndex(of: name) else { return }
|
||||
AttachmentQuickLook.shared.toggle(urls: names.compactMap(url), at: index)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One chip's thumbnail
|
||||
|
||||
/// The generated QuickLook thumbnail once there is one, the file's Finder icon until then — and
|
||||
/// forever, for anything QuickLook declines. `AttachmentRow`'s own fallback ladder, shared by being
|
||||
/// written the same way rather than by being the same view: the sidebar's row is a row, this is a
|
||||
/// chip, and only the picture is common.
|
||||
private struct CommentChipThumbnail: View {
|
||||
|
||||
let url: URL?
|
||||
let side: CGFloat
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
let displayScale: CGFloat
|
||||
|
||||
private var slot: AttachmentThumbnailKey.Slot? {
|
||||
url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.task(id: url?.path) {
|
||||
guard let slot, let url else { return }
|
||||
await thumbnails.load(slot, url: url, scale: displayScale)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let slot, let image = thumbnails.thumbnail(for: slot) {
|
||||
Image(decorative: image, scale: displayScale)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} else if let url {
|
||||
Image(nsImage: thumbnails.icon(forFileAt: url))
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} else {
|
||||
Image(systemName: "doc")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - A rendered comment body
|
||||
|
||||
/// One comment's Markdown, rendered — **the card-body subset, through the card body's own renderer**
|
||||
/// (05-card-window.md ▸ The comments column: "the rendered Markdown body (the card-body subset)").
|
||||
///
|
||||
/// ### Why the same renderer and not a `Text(AttributedString(markdown:))`
|
||||
///
|
||||
/// Because "the card-body subset" is a promise about *this* app's subset: fenced code, nested quotes,
|
||||
/// GFM tables with per-column alignment, task markers, images resolved against the card's own folder,
|
||||
/// HTML shown verbatim as literal code-styled text. `BodyMarkupRenderer` is where every one of those
|
||||
/// is decided, and a second renderer here would be a second answer to each — a comment quoting a code
|
||||
/// block would look like a different app from the card that carries it.
|
||||
///
|
||||
/// ### Why it is not `CardBodySurface`
|
||||
///
|
||||
/// That surface is a hosted **scroll** view, because ⌘F's find bar lives in one and because a card
|
||||
/// body is a document. A thread is a *list* of bodies inside one scroller, and a scroll view per row
|
||||
/// would be a scroll view that fights its parent — the same reasoning that put the card's title above
|
||||
/// the body's scroller rather than inside it. So this is the same TextKit 1 stack with the scroller
|
||||
/// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports
|
||||
/// exactly the height its text needs.
|
||||
///
|
||||
/// The pane's find-in-text over the whole rendered thread is phase 3's (05 ▸ Preview scopes ⌘F to
|
||||
/// "the comments pane, where it searches the whole rendered thread"); nothing here forecloses it.
|
||||
struct CommentBodyView: NSViewRepresentable {
|
||||
|
||||
let body: String
|
||||
/// The **card**'s folder, not the comment's — relative images and links in a comment resolve the
|
||||
/// same way a card body's do, which is what makes `` mean one thing in
|
||||
/// this window (05 ▸ Preview).
|
||||
let cardFolder: URL?
|
||||
|
||||
/// The height a measurement pass lays out into — tall enough that no comment reaches it, finite
|
||||
/// so the arithmetic stays well-defined.
|
||||
private static let layoutCeiling: CGFloat = 100_000
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSTextView {
|
||||
// TextKit 1, explicitly, for `CardBodySurface`'s reason: `NSTextTable` — the browser sizing
|
||||
// rule GFM tables are laid out by — does not lay out in TextKit 2.
|
||||
let storage = NSTextStorage()
|
||||
let layoutManager = NSLayoutManager()
|
||||
storage.addLayoutManager(layoutManager)
|
||||
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
||||
container.widthTracksTextView = true
|
||||
container.lineFragmentPadding = 0
|
||||
layoutManager.addTextContainer(container)
|
||||
|
||||
let textView = NSTextView(frame: .zero, textContainer: container)
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = false
|
||||
// Selectable and copyable, the whole thread — Preview's own posture, and the reason a comment
|
||||
// can be quoted without a mode flip.
|
||||
textView.isSelectable = true
|
||||
textView.isRichText = true
|
||||
textView.drawsBackground = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.textContainerInset = .zero
|
||||
textView.linkTextAttributes = [.cursor: NSCursor.pointingHand]
|
||||
textView.displaysLinkToolTips = true
|
||||
return textView
|
||||
}
|
||||
|
||||
func updateNSView(_ textView: NSTextView, context: Context) {
|
||||
let key = Coordinator.RenderKey(
|
||||
body: body,
|
||||
cardFolder: cardFolder,
|
||||
pointSize: CardWindowMetrics.bodyPointSize
|
||||
)
|
||||
guard context.coordinator.rendered != key else { return }
|
||||
context.coordinator.rendered = key
|
||||
textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString(
|
||||
for: BodyMarkup.parse(body),
|
||||
context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder)
|
||||
))
|
||||
}
|
||||
|
||||
/// **The intrinsic height** — the whole reason this is not a scroll view.
|
||||
///
|
||||
/// The container is laid out at the proposed width and asked what it used. `ensureLayout` is not
|
||||
/// optional: `usedRect` is only meaningful once the glyphs have been laid, and an unlaid container
|
||||
/// answers a zero-height rect, which would collapse every comment in the thread to nothing.
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? {
|
||||
guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else {
|
||||
return nil
|
||||
}
|
||||
guard let width = proposal.width, width > 0, width.isFinite else { return nil }
|
||||
|
||||
// A large finite height rather than `.greatestFiniteMagnitude`: the container tracks the
|
||||
// view's width, so the frame is how the width is proposed at all, and an infinite frame
|
||||
// height propagates into the layout arithmetic as a value nothing can subtract from.
|
||||
nsView.frame = NSRect(x: 0, y: 0, width: width, height: Self.layoutCeiling)
|
||||
layoutManager.ensureLayout(for: container)
|
||||
return CGSize(width: width, height: layoutManager.usedRect(for: container).height.rounded(.up))
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
|
||||
struct RenderKey: Equatable {
|
||||
let body: String
|
||||
let cardFolder: URL?
|
||||
let pointSize: CGFloat
|
||||
}
|
||||
|
||||
var rendered: RenderKey?
|
||||
|
||||
/// Links behave exactly as they do in a card body: external URLs go to the browser, relative
|
||||
/// ones — already resolved to file URLs by the renderer — go to their default app.
|
||||
///
|
||||
/// **A task marker in a comment is inert.** 05 makes live checkboxes a rule about *Preview*,
|
||||
/// the card body's one interactive exception, and gives a comment no toggle write path at all
|
||||
/// — the way to change a comment is Edit it. Swallowing the click (rather than letting it fall
|
||||
/// through to `NSWorkspace.open`, which would try to open a `kanban-task:` URL) is what keeps
|
||||
/// the checkbox drawn-but-dead rather than drawn-and-broken.
|
||||
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
||||
guard let url = Self.url(from: link) else { return false }
|
||||
guard CardBodyLink.parseTask(url) == nil else { return true }
|
||||
NSWorkspace.shared.open(url)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func url(from link: Any) -> URL? {
|
||||
switch link {
|
||||
case let url as URL: url
|
||||
case let string as String: URL(string: string)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
// MARK: - CommentDraftSession
|
||||
|
||||
/// The composer's buffer: the text the user is typing into `comments/.draft/`, what disk last said,
|
||||
/// and the **slow** cadence between them (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// ### It is not the body's session, and the difference is the whole point
|
||||
///
|
||||
/// `CardBodyEditSession` is a 700 ms trailing debounce: the body is the card, and a card should be on
|
||||
/// disk almost as fast as it is typed. A draft is neither.
|
||||
///
|
||||
/// > **Draft saves are slow-cadence, never prompted** (flow breakage minimized): the draft writes on
|
||||
/// > composer blur, window close, quit, and a lazy interval (~30 s) — not the body editor's 700 ms,
|
||||
/// > so a Pro user's typing never becomes a commit stream.
|
||||
///
|
||||
/// So the timer here is a **lazy interval, not a debounce**: it is armed the moment the buffer first
|
||||
/// goes dirty and it is *not* restarted by the keystrokes after it. A debounce would never fire while
|
||||
/// someone was typing steadily and would then fire the instant they paused — which is exactly the
|
||||
/// commit stream the rule exists to prevent, and exactly the wrong moment to interrupt them. An
|
||||
/// interval fires on its own schedule, at most once per period, whatever the typing is doing.
|
||||
///
|
||||
/// ### Escape is not here, and that is a ruling
|
||||
///
|
||||
/// "**Escape moves focus out of the composer, draft untouched**" (ruled 2026-07-29 — Escape never
|
||||
/// discards: the draft is a durable file, so 'abandon' has no meaning here; emptying the draft is the
|
||||
/// discard gesture). There is therefore no `cancel()` on this type at all — the absence is the
|
||||
/// design, not an omission, and adding one later would be adding a way to lose a file.
|
||||
///
|
||||
/// ### The emptied draft deletes itself, and this type does not know that
|
||||
///
|
||||
/// "A draft emptied of text with no attachments deletes its folder — no litter." That rule lives in
|
||||
/// `BoardWriter.saveCommentDraft`, which is why an emptied composer here simply *saves empty text*
|
||||
/// and reports `.deleted` back. Re-deriving the condition would be a second place for "no text and no
|
||||
/// attachments" to mean something slightly different.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CommentDraftSession {
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// What the composer is showing — the buffer when it is dirty, disk when it is not, exactly the
|
||||
/// order `CardBodyEditSession` settles for the body.
|
||||
public private(set) var text: String = ""
|
||||
|
||||
/// What the last read said `comments/.draft/index.md` holds. The write gate's other half; never
|
||||
/// shown.
|
||||
public private(set) var disk: String = ""
|
||||
|
||||
/// The draft's `attachments/`, republished from every thread read — the chips the composer draws,
|
||||
/// and half of what decides whether there is anything to post.
|
||||
///
|
||||
/// It lives here rather than beside the thread because the *rule* it feeds is this session's: a
|
||||
/// draft with no text but a file in it is still a draft (it does not delete, and it does post).
|
||||
public var attachments: [String] = []
|
||||
|
||||
/// Whether the buffer holds keystrokes the file does not.
|
||||
public var isDirty: Bool { text != disk }
|
||||
|
||||
/// **Whether ⌘↩ / the Comment button have anything to post** — the emptied-draft rule read
|
||||
/// forwards: a save of this buffer would delete the folder exactly when there is nothing to post,
|
||||
/// so the two questions have one answer (`CommentDraft.isEmpty`).
|
||||
public var canPost: Bool {
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
|
||||
}
|
||||
|
||||
// MARK: Seams
|
||||
|
||||
/// The lazy interval — **~30 s** (05 ▸ The comments column), and settable so a test does not have
|
||||
/// to spend it. `CardBodyEditSession.debounceInterval`'s precedent, with its production default
|
||||
/// on the property.
|
||||
@ObservationIgnored
|
||||
public var saveInterval: Duration = .seconds(30)
|
||||
|
||||
/// Where a save goes — `BoardStore.saveCommentDraft(inCard:body:)`, filled in by the window once
|
||||
/// it has a store and a card to aim at.
|
||||
///
|
||||
/// A closure for `CardBodyEditSession.save`'s reason exactly: this type is a buffer and a clock,
|
||||
/// and it stays testable by having no idea what a board is. `nil` — or a `nil` answer, which is
|
||||
/// what a failed write and a vanished card both give — is a save that did not land, and the
|
||||
/// buffer stays dirty rather than reporting success.
|
||||
@ObservationIgnored
|
||||
public var save: ((String) -> CommentDraftOutcome?)?
|
||||
|
||||
/// The post — `BoardStore.postComment(inCard:)`, which renames `.draft` to a fresh UUID and
|
||||
/// restamps in one bracket. `nil` answers a post that did not happen.
|
||||
@ObservationIgnored
|
||||
public var post: (() -> ItemID?)?
|
||||
|
||||
/// How many saves have actually been attempted through `save` — the cadence's own testimony,
|
||||
/// which a test would otherwise have to infer from `mtime`s.
|
||||
@ObservationIgnored
|
||||
public private(set) var saveAttempts = 0
|
||||
|
||||
@ObservationIgnored
|
||||
private var pending: Task<Void, Never>?
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Disk → buffer
|
||||
|
||||
/// A thread read arrived. **Dirty-buffer-wins**, the body's rule applied here for its reason: the
|
||||
/// composer is a text surface with a cursor in it, and a reload landing another machine's draft
|
||||
/// under that cursor would be the app eating keystrokes.
|
||||
///
|
||||
/// `nil` is a card with no draft at all — the ordinary state before the first keystroke, and the
|
||||
/// state a post leaves behind. Disk is then the empty string, which is what a clean composer
|
||||
/// shows.
|
||||
public func adopt(draft: CommentDraft?) {
|
||||
let wasDirty = isDirty
|
||||
attachments = draft?.attachments ?? []
|
||||
disk = draft?.body ?? ""
|
||||
guard !wasDirty else { return }
|
||||
// Assigning an equal string would still notify observers, and an observer here is a text view
|
||||
// that would replace its contents under the cursor.
|
||||
if text != disk { text = disk }
|
||||
}
|
||||
|
||||
// MARK: - Buffer → disk
|
||||
|
||||
/// The composer changed. **Arms the interval; never restarts it** — see the type's note.
|
||||
///
|
||||
/// A change that brings the buffer back to what disk already says cancels the armed save outright,
|
||||
/// `CardBodyEditSession.edited(_:)`'s *reverted* gate: a tick that fired on a no-op would stamp
|
||||
/// `modified` on a draft nobody touched.
|
||||
public func edited(_ newText: String) {
|
||||
guard text != newText else { return }
|
||||
text = newText
|
||||
guard isDirty else {
|
||||
cancelPending()
|
||||
return
|
||||
}
|
||||
armInterval()
|
||||
}
|
||||
|
||||
/// **Composer blur** — the first of 05's four cadence moments. Named rather than folded into
|
||||
/// `flush()` because it is the one a view calls, and because the other three are the window's.
|
||||
@discardableResult
|
||||
public func blurred() -> CommentDraftOutcome? {
|
||||
flush()
|
||||
}
|
||||
|
||||
/// Saves now if there is anything to save, disarming the interval first — window close, app quit,
|
||||
/// and blur all land here.
|
||||
///
|
||||
/// Synchronous, because the write is: the close path has to know the answer before it lets the
|
||||
/// window go (`CardBodyEditSession.flush()`'s reason, unchanged).
|
||||
@discardableResult
|
||||
public func flush() -> CommentDraftOutcome? {
|
||||
cancelPending()
|
||||
return saveNow()
|
||||
}
|
||||
|
||||
/// **⌘↩ and the Comment button** — one gesture: flush the buffer into `.draft/`, then rename it
|
||||
/// into the thread (05 ▸ The comments column: "posting renames `.draft` → a fresh lowercase UUID
|
||||
/// and **restamps** `created`/`modified` in the same write bracket … one gesture, one commit").
|
||||
///
|
||||
/// **The flush comes first and is not optional.** The post is a rename of a *folder*, so whatever
|
||||
/// the composer has not yet written would simply not be in the comment — the one place the slow
|
||||
/// cadence would otherwise be visible as lost text.
|
||||
///
|
||||
/// Nothing to post is a no-op rather than a refusal: the button is disabled and ⌘↩ in an empty
|
||||
/// composer should do nothing at all, not post an empty comment and not raise anything.
|
||||
///
|
||||
/// **After a post the composer empties**, because the draft is gone — the folder it was editing is
|
||||
/// now a comment in the thread. Clearing `disk` too is what keeps the buffer clean rather than
|
||||
/// dirty-against-a-file-that-no-longer-exists.
|
||||
@discardableResult
|
||||
public func postNow() -> ItemID? {
|
||||
guard canPost else { return nil }
|
||||
flush()
|
||||
guard let posted = post?() else { return nil }
|
||||
cancelPending()
|
||||
text = ""
|
||||
disk = ""
|
||||
attachments = []
|
||||
return posted
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Arms the interval **once**. A second dirty keystroke inside the period rides the timer that is
|
||||
/// already running, which is the difference between an interval and a debounce.
|
||||
private func armInterval() {
|
||||
guard pending == nil else { return }
|
||||
let interval = saveInterval
|
||||
pending = Task { [weak self] in
|
||||
try? await Task.sleep(for: interval)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.pending = nil
|
||||
_ = self.saveNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPending() {
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
}
|
||||
|
||||
/// The gate, and the one place `save` is called.
|
||||
///
|
||||
/// A landing moves `disk` up to the text that landed, so the thread read arriving a moment later
|
||||
/// finds the buffer already clean. A `nil` — a failure, a suspension under the read-only lock, a
|
||||
/// vanished card — leaves `disk` where it was, which keeps the buffer dirty and therefore keeps
|
||||
/// the text.
|
||||
///
|
||||
/// `.deleted` is a landing like any other: the folder is gone *because* the buffer was empty, so
|
||||
/// disk and the buffer agree perfectly.
|
||||
private func saveNow() -> CommentDraftOutcome? {
|
||||
guard isDirty else { return nil }
|
||||
guard let save else { return nil }
|
||||
|
||||
saveAttempts += 1
|
||||
let outcome = save(text)
|
||||
if outcome != nil {
|
||||
disk = text
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
// MARK: - CommentEditSession
|
||||
|
||||
/// One inline comment edit — **a body-edit session in miniature** (05-card-window.md ▸ The comments
|
||||
/// column: "no second draft mechanism: debounced saves to the comment's own file keep it crash-safe,
|
||||
/// Save (or ⌘↩) ends the session as its commit point, Cancel — or Escape, its keyboard twin —
|
||||
/// reverts to session-start bytes, window close flushes the session exactly as the body's does").
|
||||
///
|
||||
/// ### What it borrows from `CardBodyEditSession`, and what it adds
|
||||
///
|
||||
/// Borrowed, deliberately verbatim: the buffer/disk pair, the single write predicate (*write if and
|
||||
/// only if the buffer differs from disk*), dirty-buffer-wins on `adopt(diskBody:)`, the ~700 ms
|
||||
/// trailing debounce, and the flush that a mode exit or a window close performs. **The 700 ms is
|
||||
/// right here**, and its being right here is what the slow cadence next door is a contrast to: the
|
||||
/// comment already exists as a file, so a save is an ordinary edit to it — it is the *draft* that
|
||||
/// must not become a commit stream (`CommentDraftSession`).
|
||||
///
|
||||
/// Added, and the only genuinely new thing in this type: **session-start bytes**. The body has no
|
||||
/// Cancel — leaving Edit is a commit, and ⌘Z in the editor is the text view's own undo — while an
|
||||
/// inline comment edit has a Cancel button and an Escape that means it. 13-native-undo.md forbids
|
||||
/// byte capture *on the undo stack* in every tier, and this is not that: the capture is a live
|
||||
/// buffer's, held for the length of one session, discarded when the session ends, and never
|
||||
/// registered anywhere. `BoardStoreComments`' own note says so — "an inline edit's revert is its
|
||||
/// *session*'s … which is a live buffer, not a stack entry".
|
||||
///
|
||||
/// ### The revert is a write, not an unwrite
|
||||
///
|
||||
/// Cancel puts the captured bytes back **through the ordinary save** (`BoardStore.editComment`), so
|
||||
/// the file returns to what it said with one more `modified` stamp and one more bracketed write. That
|
||||
/// is the honest shape for a files-first app: the debounced saves genuinely happened, other windows
|
||||
/// and other machines have already seen them, and pretending otherwise would mean holding the file
|
||||
/// open for the length of a session.
|
||||
///
|
||||
/// A cancel that has nothing to put back writes nothing — a session that only ever read leaves the
|
||||
/// file byte-identical, `mtime` included, which is the body's untouched gate applied to the exit.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CommentEditSession {
|
||||
|
||||
// MARK: Identity
|
||||
|
||||
/// Which comment is open. The row renders an editor instead of its body while this session names
|
||||
/// it, and the drop carve-out aims at its `attachments/`.
|
||||
public let commentID: ItemID
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// What the editor is showing.
|
||||
public private(set) var text: String
|
||||
|
||||
/// What the last read said the comment's `index.md` holds. The write gate's other half.
|
||||
public private(set) var disk: String
|
||||
|
||||
/// **The bytes this session opened on** — Cancel's destination, captured once at `init` and never
|
||||
/// updated. See the type's note for why this capture is not the one 13 forbids.
|
||||
@ObservationIgnored
|
||||
public let sessionStart: String
|
||||
|
||||
public var isDirty: Bool { text != disk }
|
||||
|
||||
// MARK: Seams
|
||||
|
||||
/// The debounce interval — **~700 ms**, the body's own (05 ▸ Edit), and settable so a test does
|
||||
/// not have to spend it.
|
||||
@ObservationIgnored
|
||||
public var debounceInterval: Duration = .milliseconds(700)
|
||||
|
||||
/// Where a save goes — `BoardStore.editComment(_:inCard:body:)`, filled in by the window.
|
||||
///
|
||||
/// `true` means the bytes landed. `false` covers everything that means they did not — a failed
|
||||
/// write (already bannered by `performWrite`), a suspended one under the read-only lock, and a
|
||||
/// comment or card that has gone — and they are one case here for the reason 05 gives the window:
|
||||
/// each of them leaves the buffer dirty, which keeps the text, and none of them has a different
|
||||
/// thing for this type to do.
|
||||
@ObservationIgnored
|
||||
public var save: ((String) -> Bool)?
|
||||
|
||||
/// How many saves have actually been attempted through `save`.
|
||||
@ObservationIgnored
|
||||
public private(set) var saveAttempts = 0
|
||||
|
||||
@ObservationIgnored
|
||||
private var pending: Task<Void, Never>?
|
||||
|
||||
/// Whether this session has ended. A session ends once — Save, Cancel, or the window close that
|
||||
/// beat both of them to it — and ending twice must not write twice.
|
||||
@ObservationIgnored
|
||||
public private(set) var hasEnded = false
|
||||
|
||||
/// Opens a session over `body`, which is both the buffer's starting text and Cancel's
|
||||
/// destination.
|
||||
public init(commentID: ItemID, body: String) {
|
||||
self.commentID = commentID
|
||||
text = body
|
||||
disk = body
|
||||
sessionStart = body
|
||||
}
|
||||
|
||||
// MARK: - Disk → buffer
|
||||
|
||||
/// A thread read arrived. **Dirty-buffer-wins**, the body's single `if`: `disk` always follows the
|
||||
/// read; `text` follows it only when the buffer had nothing unsaved.
|
||||
public func adopt(diskBody: String) {
|
||||
let wasDirty = isDirty
|
||||
disk = diskBody
|
||||
guard !wasDirty else { return }
|
||||
if text != diskBody { text = diskBody }
|
||||
}
|
||||
|
||||
// MARK: - Buffer → disk
|
||||
|
||||
/// A keystroke. Restarts the debounce, or cancels it when the change brought the buffer back to
|
||||
/// what disk already says.
|
||||
public func edited(_ newText: String) {
|
||||
guard text != newText else { return }
|
||||
text = newText
|
||||
guard isDirty else {
|
||||
cancelPending()
|
||||
return
|
||||
}
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/// Saves now if there is anything to save, cancelling the pending debounce first — the window
|
||||
/// close's flush, which "flushes the session exactly as the body's does".
|
||||
@discardableResult
|
||||
public func flush() -> Bool {
|
||||
cancelPending()
|
||||
return saveNow()
|
||||
}
|
||||
|
||||
// MARK: - The two ends
|
||||
|
||||
/// **Save, or ⌘↩** — the session's commit point (05 ▸ The comments column). Flushes and ends.
|
||||
///
|
||||
/// A named call rather than a bare `flush()` for `CardBodyEditSession.endEditSession()`'s reason:
|
||||
/// this is the boundary a Pro auto-commit coalesces on, one commit per session and never per save
|
||||
/// tick (06-history-undo.md ▸ Rules ▸ Auto-commit).
|
||||
@discardableResult
|
||||
public func commit() -> Bool {
|
||||
guard !hasEnded else { return false }
|
||||
hasEnded = true
|
||||
return flush()
|
||||
}
|
||||
|
||||
/// **Cancel, or Escape** — reverts to session-start bytes and ends (05; 11-command-nexus.md's
|
||||
/// grammar table gives Escape as the button's keyboard twin).
|
||||
///
|
||||
/// The revert is a write, and it is attempted only when something of this session's actually
|
||||
/// landed: `disk` is what the file says as far as this session knows, so `disk == sessionStart`
|
||||
/// is a session that has overwritten nothing and has nothing to put back.
|
||||
///
|
||||
/// A *foreign* edit landing mid-session moves `disk` too, and Cancel then writes the session's
|
||||
/// start bytes over it — deliberate last-writer-wins, the same no-merge-UI philosophy the body's
|
||||
/// dirty-buffer rule states (05 ▸ Write rules). The alternative would be a merge prompt in a
|
||||
/// comment editor.
|
||||
@discardableResult
|
||||
public func cancel() -> Bool {
|
||||
guard !hasEnded else { return false }
|
||||
hasEnded = true
|
||||
cancelPending()
|
||||
guard disk != sessionStart else { return false }
|
||||
saveAttempts += 1
|
||||
guard save?(sessionStart) == true else { return false }
|
||||
text = sessionStart
|
||||
disk = sessionStart
|
||||
return true
|
||||
}
|
||||
|
||||
/// The window close's end: flush, then mark the session over — the same one-way latch Save and
|
||||
/// Cancel use, so a close that beat the buttons cannot be followed by a second write.
|
||||
///
|
||||
/// It is **not** Cancel: a close is not an abandon (05 ▸ Deletion & lifecycle — "Dismissal never
|
||||
/// eats typed work silently where a save can land"), and reverting the user's typing because they
|
||||
/// closed a window would be the opposite of that promise.
|
||||
@discardableResult
|
||||
public func endOnClose() -> Bool {
|
||||
guard !hasEnded else { return false }
|
||||
hasEnded = true
|
||||
return flush()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func scheduleSave() {
|
||||
cancelPending()
|
||||
let interval = debounceInterval
|
||||
pending = Task { [weak self] in
|
||||
try? await Task.sleep(for: interval)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.pending = nil
|
||||
_ = self.saveNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPending() {
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
}
|
||||
|
||||
/// The gate, and the one place `save` is called. A landing moves `disk` up to the text that
|
||||
/// landed; anything else leaves it, which keeps the buffer dirty and therefore keeps the text.
|
||||
private func saveNow() -> Bool {
|
||||
guard isDirty, let save else { return false }
|
||||
saveAttempts += 1
|
||||
guard save(text) else { return false }
|
||||
disk = text
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - One comment
|
||||
|
||||
/// One comment in the thread: **an author line, the rendered Markdown body, and attachment chips when
|
||||
/// its `attachments/` is non-empty** (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// ### No avatars
|
||||
///
|
||||
/// "There is no identity system, and initials faked from self-reported strings would be decoration."
|
||||
/// So the row's whole identity surface is a line of secondary text, and a comment with no `author`
|
||||
/// renders **without a name** rather than with a placeholder standing in for one
|
||||
/// (`CommentAuthorLine`).
|
||||
///
|
||||
/// ### The row is two views, not one with a mode
|
||||
///
|
||||
/// While an inline edit session names this comment, the body and its read-only chips are replaced by
|
||||
/// the authoring surface — the *same* authoring surface the composer uses
|
||||
/// (`CommentAuthoringSurface`), aimed at this comment's folder. That is what makes "the same pair
|
||||
/// applies within an inline comment edit session" true by construction rather than by two views
|
||||
/// agreeing.
|
||||
struct CommentRowView: View {
|
||||
|
||||
let comment: Comment
|
||||
let comments: CardComments
|
||||
let cardFolder: URL?
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
private var padding: CGFloat { CardWindowMetrics.previewPadding(bodyPointSize: pointSize) }
|
||||
|
||||
/// The session open over *this* comment, or `nil` — one at a time, window-wide
|
||||
/// (`CardComments.editing`).
|
||||
private var session: CommentEditSession? {
|
||||
guard let editing = comments.editing, editing.commentID == comment.id else { return nil }
|
||||
return editing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: padding) {
|
||||
if let line = authorLine {
|
||||
Text(line)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if let session {
|
||||
editor(session)
|
||||
} else {
|
||||
reading
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu { menu }
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(authorLine ?? "Comment")
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
private var reading: some View {
|
||||
VStack(alignment: .leading, spacing: padding) {
|
||||
CommentBodyView(body: comment.body, cardFolder: cardFolder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !comment.attachments.isEmpty {
|
||||
// **Read-only** — "a posted comment's chips are read-only, Quick Look only — Edit the
|
||||
// comment to change its files" (05). `onRemove` left `nil` is that sentence.
|
||||
CommentAttachmentChips(
|
||||
names: comment.attachments,
|
||||
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
|
||||
thumbnails: thumbnails
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Editing
|
||||
|
||||
/// The inline session's surface: the composer's chrome, aimed at this comment, with Save and
|
||||
/// Cancel where the composer has Comment.
|
||||
private func editor(_ session: CommentEditSession) -> some View {
|
||||
CommentAuthoringSurface(
|
||||
comments: comments,
|
||||
target: .comment(comment.id),
|
||||
text: session.text,
|
||||
height: CardWindowMetrics.inlineEditorHeight(bodyPointSize: pointSize),
|
||||
// A constant, and it is enough: this editor is mounted by the session opening and
|
||||
// unmounted by it ending, so its coordinator sees exactly one transition from the
|
||||
// never-requested 0 — Edit puts the caret in the text, once, without a counter of its
|
||||
// own (contrast the composer, which is always mounted and needs one).
|
||||
focusRequest: 1,
|
||||
attachments: comment.attachments,
|
||||
thumbnails: thumbnails,
|
||||
onEdit: { session.edited($0) },
|
||||
onCommandReturn: { comments.commitEdit() },
|
||||
// **Escape is Cancel's keyboard twin** (ruled 2026-07-29; 11-command-nexus.md's grammar
|
||||
// table) — unlike the composer's Escape, which never discards, because a session *has* a
|
||||
// start state to go back to and a draft has not.
|
||||
onEscape: { comments.cancelEdit() }
|
||||
) {
|
||||
// ⌘↩ is Save's chord and Escape is Cancel's, both intercepted by the editor's own text
|
||||
// view — see the composer's Comment button for why neither is `.defaultAction`.
|
||||
Button("Cancel") { comments.cancelEdit() }
|
||||
.controlSize(.small)
|
||||
Button("Save") { comments.commitEdit() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(!comments.isEditable)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The context menu
|
||||
|
||||
/// **Edit / Delete / Reveal in Finder** — the per-item action inventory 11-command-nexus.md
|
||||
/// inventories for a comment, and the surface VoiceOver reads.
|
||||
///
|
||||
/// Delete is "immediate and undoable, no confirm" (05; 01's ruling — the comment moves into
|
||||
/// `comments/.trash/` and ⌘Z is the move back), so there is no confirmation sheet here and no
|
||||
/// destructive-role ceremony beyond the divider that separates it.
|
||||
@ViewBuilder
|
||||
private var menu: some View {
|
||||
Button("Edit") { comments.beginEdit(comment.id) }
|
||||
.disabled(!comments.isEditable)
|
||||
Button("Reveal in Finder") { comments.reveal(comment.id) }
|
||||
Divider()
|
||||
Button("Delete") { comments.delete(comment.id) }
|
||||
.disabled(!comments.isEditable)
|
||||
}
|
||||
|
||||
// MARK: - The author line
|
||||
|
||||
private var authorLine: String? {
|
||||
CommentAuthorLine.text(
|
||||
author: comment.author.value,
|
||||
timestamp: comment.created.value.map(Self.timestamp),
|
||||
isEdited: comment.isEdited
|
||||
)
|
||||
}
|
||||
|
||||
/// The card's own created/modified line's format, shared so a comment's timestamp and its card's
|
||||
/// read the same way.
|
||||
private static func timestamp(_ date: Date) -> String {
|
||||
date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - CommentTextEditor
|
||||
|
||||
/// The text surface both **authoring** surfaces use: the composer, and an inline comment edit
|
||||
/// session (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// ### One editor for both, because they are one thing twice
|
||||
///
|
||||
/// 05 describes the composer as "an always-visible text area ('Add a comment…', Edit-mode Markdown
|
||||
/// highlighting)" and the inline session as "a body-edit session in miniature". Both are raw Markdown
|
||||
/// with the body editor's highlighting over it, both end on ⌘↩, both answer Escape, and both are
|
||||
/// where a dropped file lands for their own folder. What differs is entirely outside this view —
|
||||
/// which buffer the keystrokes go to, what ⌘↩ means, what Escape means — so all four arrive as
|
||||
/// closures and none of them is decided here.
|
||||
///
|
||||
/// ### The highlighting is the body editor's, exactly
|
||||
///
|
||||
/// `MarkdownHighlighter` emits ranges and never a string, so "the text is the raw Markdown, character
|
||||
/// for character — no hidden transforms, no smart substitutions" (05 ▸ Edit) holds here for free, and
|
||||
/// the same smart-substitution deregistrations are repeated below because a comment is as much the
|
||||
/// user's file as a body is.
|
||||
///
|
||||
/// ### It declines file drags, like the body editor
|
||||
///
|
||||
/// `acceptableDragTypes` drops the file types so AppKit's hit-test walks past the text view — which
|
||||
/// is what lets the SwiftUI drop target *around* this view take the drop (the composer's carve-out,
|
||||
/// `CommentDropCarveOut`) instead of `NSTextView` inserting a path into the user's Markdown. The
|
||||
/// mechanism is `CardBodyTextView`'s, verbatim; only the target above it differs.
|
||||
struct CommentTextEditor: NSViewRepresentable {
|
||||
|
||||
let text: String
|
||||
let isEditable: Bool
|
||||
/// Every keystroke — straight into the session, which owns the cadence.
|
||||
let onEdit: (String) -> Void
|
||||
/// ⌘↩ — Post for the composer, Save for an inline session (11-command-nexus.md's grammar table).
|
||||
let onCommandReturn: () -> Void
|
||||
/// Escape — "focus moves out, draft untouched" for the composer; Cancel for an inline session.
|
||||
let onEscape: () -> Void
|
||||
/// Focus left. The composer's first cadence moment ("composer blur"); nothing for an inline
|
||||
/// session, whose commit points are its two buttons.
|
||||
var onBlur: () -> Void = {}
|
||||
/// Bumped to ask for the keyboard — File ▸ Add Comment's second half, and an inline session
|
||||
/// opening. A counter rather than a flag: two requests in a row are two requests.
|
||||
var focusRequest: Int = 0
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let storage = NSTextStorage()
|
||||
let layoutManager = NSLayoutManager()
|
||||
storage.addLayoutManager(layoutManager)
|
||||
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
||||
container.widthTracksTextView = true
|
||||
layoutManager.addTextContainer(container)
|
||||
|
||||
let textView = CommentEditorTextView(frame: .zero, textContainer: container)
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = isEditable
|
||||
textView.isSelectable = true
|
||||
textView.isRichText = false
|
||||
textView.drawsBackground = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.autoresizingMask = NSView.AutoresizingMask.width
|
||||
textView.minSize = CGSize(width: 0, height: 0)
|
||||
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
// The body editor's list, and normative here for its reason: a smart quote substituted into a
|
||||
// fenced code block would be the app silently corrupting the user's file.
|
||||
textView.isAutomaticLinkDetectionEnabled = false
|
||||
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||
textView.isAutomaticDashSubstitutionEnabled = false
|
||||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||
textView.isAutomaticDataDetectionEnabled = false
|
||||
textView.smartInsertDeleteEnabled = false
|
||||
textView.allowsUndo = true
|
||||
textView.usesFindBar = true
|
||||
textView.isIncrementalSearchingEnabled = true
|
||||
|
||||
let padding = CardWindowMetrics.previewPadding(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
textView.textContainerInset = CGSize(width: padding, height: padding)
|
||||
|
||||
textView.onCommandReturn = onCommandReturn
|
||||
textView.onEscape = onEscape
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.documentView = textView
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = true
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.findBarPosition = .aboveContent
|
||||
|
||||
context.coordinator.textView = textView
|
||||
context.coordinator.onEdit = onEdit
|
||||
context.coordinator.onBlur = onBlur
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
let coordinator = context.coordinator
|
||||
coordinator.onEdit = onEdit
|
||||
coordinator.onBlur = onBlur
|
||||
|
||||
guard let textView = scrollView.documentView as? CommentEditorTextView else { return }
|
||||
textView.isEditable = isEditable
|
||||
textView.onCommandReturn = onCommandReturn
|
||||
textView.onEscape = onEscape
|
||||
|
||||
coordinator.show(text, in: textView, pointSize: CardWindowMetrics.bodyPointSize)
|
||||
|
||||
guard focusRequest != coordinator.servedFocusRequest else { return }
|
||||
coordinator.servedFocusRequest = focusRequest
|
||||
guard focusRequest > 0 else { return }
|
||||
// Deferred a turn: this runs inside a SwiftUI update, and making a view first responder
|
||||
// re-enters AppKit's responder machinery (`CardBodySurface.Coordinator.enter`'s rule).
|
||||
Task { @MainActor [weak textView] in
|
||||
guard let textView else { return }
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
|
||||
weak var textView: NSTextView?
|
||||
var onEdit: ((String) -> Void)?
|
||||
var onBlur: (() -> Void)?
|
||||
var servedFocusRequest = 0
|
||||
|
||||
/// Set while this coordinator is replacing the view's text, so the resulting change
|
||||
/// notification is not mistaken for typing.
|
||||
private var isSettingText = false
|
||||
|
||||
/// `CardBodySurface.Coordinator.show(_:in:pointSize:)`, unchanged and for its reason: the
|
||||
/// equality guard is load-bearing rather than an optimization, because this runs on every
|
||||
/// keystroke and replacing the storage with the string it already holds would collapse the
|
||||
/// selection and throw away the undo stack on every character typed.
|
||||
func show(_ text: String, in textView: NSTextView, pointSize: CGFloat) {
|
||||
guard let storage = textView.textStorage else { return }
|
||||
|
||||
if storage.string != text {
|
||||
let selected = textView.selectedRange()
|
||||
isSettingText = true
|
||||
storage.setAttributedString(NSAttributedString(
|
||||
string: text,
|
||||
attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
||||
))
|
||||
isSettingText = false
|
||||
let length = (text as NSString).length
|
||||
textView.setSelectedRange(NSRange(
|
||||
location: min(selected.location, length),
|
||||
length: min(selected.length, max(0, length - min(selected.location, length)))
|
||||
))
|
||||
}
|
||||
|
||||
MarkdownHighlighter.highlight(storage, pointSize: pointSize)
|
||||
textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize)
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !isSettingText, let textView = notification.object as? NSTextView else { return }
|
||||
onEdit?(textView.string)
|
||||
}
|
||||
|
||||
/// **Composer blur is a save** (05 ▸ The comments column, the first of the four cadence
|
||||
/// moments). For an inline session `onBlur` is empty: its commit points are Save and Cancel,
|
||||
/// and clicking away from it is neither.
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
onBlur?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The editor's text view
|
||||
|
||||
/// The authoring editor's text view, subclassed for the two keys 11-command-nexus.md's grammar table
|
||||
/// gives it and for the drag types it must not take.
|
||||
final class CommentEditorTextView: NSTextView {
|
||||
|
||||
var onCommandReturn: (() -> Void)?
|
||||
var onEscape: (() -> Void)?
|
||||
|
||||
/// **⌘↩** — "Post the draft … / end the edit session at its commit point"
|
||||
/// (11-command-nexus.md ▸ Fixed grammar keys). Intercepted before `super`, which would otherwise
|
||||
/// insert a newline: the chord is the gesture, not a decorated Return.
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let isReturn = event.keyCode == 36 || event.keyCode == 76
|
||||
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.function, .numericPad, .capsLock])
|
||||
if isReturn, modifiers == .command, let onCommandReturn {
|
||||
onCommandReturn()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
/// **Escape.** What it *means* is the caller's — focus out for the composer (never a discard),
|
||||
/// Cancel for an inline session — which is why this only forwards. Intercepted before
|
||||
/// `NSTextView`'s own meaning for it (text completion); with the find bar up the bar is first
|
||||
/// responder and never reaches this.
|
||||
override func cancelOperation(_ sender: Any?) {
|
||||
guard let onEscape else {
|
||||
super.cancelOperation(sender)
|
||||
return
|
||||
}
|
||||
onEscape()
|
||||
}
|
||||
|
||||
/// **A file drop is never the editor's** — `CardBodyTextView`'s deregistration, here so the drop
|
||||
/// falls through to the authoring surface's own target and lands in *this* surface's
|
||||
/// `attachments/` (05 ▸ Attachments, the hover-target carve-out).
|
||||
override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
|
||||
let fileTypes: Set<NSPasteboard.PasteboardType> = [
|
||||
.fileURL,
|
||||
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||
]
|
||||
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user