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:
2026-07-30 20:19:52 -04:00
parent f68ac3668e
commit fe3ffac48e
27 changed files with 4496 additions and 24 deletions
+47
View File
@@ -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
+133 -3
View File
@@ -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
+5
View File
@@ -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")
}
+1
View File
@@ -214,6 +214,7 @@ struct KanbanApp: App {
SaveAsTemplateCommand(appModel: appModel)
RevealInFinderCommand()
AddAttachmentCommand()
AddCommentCommand()
Divider()
+72
View File
@@ -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`,
+41 -4
View File
@@ -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 24 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?
+62
View File
@@ -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".
+73
View File
@@ -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
+23 -10
View File
@@ -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
}
+329
View File
@@ -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 }
}
}
+127
View File
@@ -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))
}
}
+219
View File
@@ -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
}
}
+206
View File
@@ -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
}
}
+71 -3
View File
@@ -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()
)
}
+91 -3
View File
@@ -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.
+168
View File
@@ -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)
}
}
}
+140
View File
@@ -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 `![](attachments/shot.png)` 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
}
}
}
}
+206
View File
@@ -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)
}
}
+221
View File
@@ -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
}
}
+212
View File
@@ -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
}
}
+148
View File
@@ -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)
}
}
+226
View File
@@ -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) }
}
}
+261
View File
@@ -0,0 +1,261 @@
import CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// The comments pane's **pure** seams: where it mounts, which way the thread runs, what a comment's
/// author line says, and which folder a dropped file lands in (05-card-window.md Composition, The
/// comments column; Attachments' carve-out).
///
/// Every one of them is a rule that fails silently in a running window a 3:2 split that is 2:3
/// looks deliberate, a composer at the wrong end looks like a design choice, and a file that landed
/// in the card's folder instead of the draft's is not visible at all until someone opens Finder. So
/// they are values and functions, and this is their suite; the views that arrange them are the
/// manual-verification list's.
// MARK: - The mount
@Suite("Comments ▸ where the pane mounts")
struct CommentsMountTests {
@Test("The menu row's bit reads one way: checked is beside")
func theBitReadsOneWay() {
#expect(CommentsMount(besideBody: true) == .beside)
#expect(CommentsMount(besideBody: false) == .stacked)
}
@Test("Stacked splits three parts body to two parts comments")
func stackedSplitsThreeToTwo() {
let mount = CommentsMount.stacked
#expect(mount.bodyHeight(in: 500) == 300)
#expect(mount.commentsHeight(in: 500) == 200)
}
@Test("The two heights always add up to the window — no gap, no overlap")
func theHeightsSum() {
for height in [0.0, 1.0, 37.0, 500.0, 1013.5] as [CGFloat] {
let mount = CommentsMount.stacked
#expect(mount.bodyHeight(in: height) + mount.commentsHeight(in: height) == height)
}
}
@Test("Beside, the body owns the full height and the comments pane divides none of it")
func besideDividesNoHeight() {
#expect(CommentsMount.beside.bodyHeight(in: 500) == 500)
#expect(CommentsMount.beside.commentsHeight(in: 500) == 0)
}
@Test("A negative proposed height is clamped rather than laid out")
func negativeHeightsClamp() {
#expect(CommentsMount.stacked.bodyHeight(in: -10) == 0)
#expect(CommentsMount.beside.bodyHeight(in: -10) == 0)
}
@Test("Only the beside mount widens the window")
func onlyBesideWidens() {
// "The window's minimum width grows only while the column is shown side-by-side" (05
// Composition) which is the entire reason the stacked mount exists.
#expect(CommentsMount.beside.widensWindow)
#expect(!CommentsMount.stacked.widensWindow)
}
}
// MARK: - The window's minimum
@Suite("Comments ▸ the window minimum")
struct CommentsWindowMinimumTests {
@Test("The minimum grows by the comments floor only with the column beside the body")
func theMinimumGrowsOnlyBeside() {
let without = CardWindowMetrics.minimumSize(bodyPointSize: 13)
let with = CardWindowMetrics.minimumSize(bodyPointSize: 13, commentsColumn: true)
#expect(with.width == without.width + CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13))
#expect(with.height == without.height, "a stacked pane divides height rather than demanding more")
}
@Test("Every comments measurement scales with the body font")
func measurementsScale() {
// 10-accessibility.md Text: "metrics derive from font metrics, so layout survives the
// largest system text sizes". The pane's numbers are no exception to the window's rule.
#expect(
CardWindowMetrics.commentsColumnWidth(bodyPointSize: 18)
> CardWindowMetrics.commentsColumnWidth(bodyPointSize: 11)
)
#expect(
CardWindowMetrics.composerHeight(bodyPointSize: 18)
> CardWindowMetrics.composerHeight(bodyPointSize: 11)
)
#expect(
CardWindowMetrics.inlineEditorHeight(bodyPointSize: 13)
> CardWindowMetrics.composerHeight(bodyPointSize: 13),
"an inline editor opens over text that already exists"
)
}
}
// MARK: - The sort direction
@Suite("Comments ▸ the sort direction")
struct CommentSortDirectionTests {
private func comments(_ ids: [String]) -> [Kanban.Comment] {
ids.map { id in
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: [],
document: FrontmatterDocument(body: "")
)
}
}
@Test("Ascending is the default, and the control's bit reads one way")
func ascendingIsTheDefault() {
#expect(CommentSortDirection(newestFirst: false) == .ascending)
#expect(CommentSortDirection(newestFirst: true) == .descending)
#expect(!CommentSortDirection.ascending.isNewestFirst)
}
@Test("Descending is the loader's order reversed, never a second sort")
func descendingReverses() {
let thread = comments([CommentIdent.one, CommentIdent.two, CommentIdent.three])
#expect(CommentSortDirection.ascending.apply(to: thread).map(\.id) == thread.map(\.id))
#expect(CommentSortDirection.descending.apply(to: thread).map(\.id) == thread.reversed().map(\.id))
}
@Test("An empty thread reverses to an empty thread")
func emptyReverses() {
#expect(CommentSortDirection.descending.apply(to: []).isEmpty)
}
@Test("The composer sits at the newest end — bottom ascending, top descending")
func theComposerSitsAtTheNewestEnd() {
#expect(!CommentSortDirection.ascending.placesComposerFirst)
#expect(CommentSortDirection.descending.placesComposerFirst)
}
@Test("The control names the order it would give, and names it once")
func theControlLabelIsOneString() {
#expect(CommentSortDirection.ascending.controlLabel == "Oldest First")
#expect(CommentSortDirection.descending.controlLabel == "Newest First")
}
}
// MARK: - The header
@Suite("Comments ▸ the header count")
struct CommentsHeaderTests {
@Test("An empty thread still counts — the pane is an invitation, not an absence")
func zeroIsACount() {
#expect(CommentsHeader.title(count: 0) == "Comments · 0")
#expect(CommentsHeader.title(count: 3) == "Comments · 3")
}
}
// MARK: - The author line
@Suite("Comments ▸ the author line")
struct CommentAuthorLineTests {
@Test("Name and timestamp join with the window's own separator")
func nameAndTimestamp() {
#expect(
CommentAuthorLine.text(author: "Ada Lovelace", timestamp: "1 Jan 2026", isEdited: false)
== "Ada Lovelace · 1 Jan 2026"
)
}
@Test("A missing author renders without a name — never a placeholder string")
func missingAuthorRendersNothingInItsPlace() {
// "Missing renders unattributed" (`Comment.author`), and unattributed is the absence of a
// name: a placeholder there would be the app inventing an identity for a file that carries
// none, which is the same reason there are no avatars.
#expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
}
@Test("An empty author string is absent too — the Writer never writes one")
func blankAuthorIsAbsent() {
#expect(CommentAuthorLine.text(author: "", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
#expect(CommentAuthorLine.text(author: " ", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026")
}
@Test("'edited' appends when modified differs from created")
func editedAppends() {
#expect(
CommentAuthorLine.text(author: "Ada", timestamp: "1 Jan 2026", isEdited: true)
== "Ada · 1 Jan 2026 · edited"
)
#expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: true) == "1 Jan 2026 · edited")
}
@Test("A comment with neither a name nor a date renders no line at all")
func nothingToSayIsNoLine() {
#expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: false) == nil)
// Not even for the edit marker: "· edited" hangs off something, and a row saying only
// "edited" would be a row about nothing.
#expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: true) == nil)
}
}
// MARK: - The drop carve-out
@Suite("Comments ▸ the drop carve-out")
struct CommentDropCarveOutTests {
@Test("Everywhere else in the window is the card's — the default stands")
func theWindowDefaultStands() {
#expect(CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover()) == .card)
}
@Test("Within the composer's bounds, files land in the draft")
func composerLandsInTheDraft() {
#expect(
CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(isOverComposer: true))
== .comment(.draft)
)
}
@Test("Within an inline edit session's bounds, files land in that comment")
func inlineEditLandsInItsComment() {
let id = ItemID(rawValue: CommentIdent.one)
#expect(
CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(inlineEdit: id))
== .comment(.comment(id))
)
}
@Test("The inline session outranks the composer where they would ever overlap")
func theInlineSessionWins() {
// Moot today an inline session opens over a comment row and the composer is a separate
// surface and fixed anyway, because the case where it stops being moot is a layout change
// and a layout change must not silently move a user's files into the wrong folder.
let id = ItemID(rawValue: CommentIdent.one)
let hover = CommentDropCarveOut.Hover(isOverComposer: true, inlineEdit: id)
#expect(CommentDropCarveOut.landing(for: hover) == .comment(.comment(id)))
}
}
// MARK: - Where a target lives
@Suite("Comments ▸ the target's folder")
struct CommentTargetTests {
@Test("The draft and a posted comment resolve to their own folders, and the thread's rule owns both")
func targetsResolve() {
let card = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
let id = ItemID(rawValue: CommentIdent.one)
#expect(CommentTarget.draft.folder(inCard: card) == CommentThread.draftFolder(inCard: card))
#expect(
CommentTarget.comment(id).folder(inCard: card)
== CommentThread.commentFolder(id, inCard: card)
)
}
}
+647
View File
@@ -0,0 +1,647 @@
import Foundation
import Testing
@testable import Kanban
/// The card window's comments **handle** the object every menu row, every pane view and the close
/// flush actually talk to (05-card-window.md The comments column).
///
/// What it owns is orderings and lifecycles, which is exactly the class of thing that looks right in
/// a running window and is wrong: the sweep-before-read at open, the saves-before-purge at close, a
/// session that must be dropped when its comment vanishes underneath it. So the seams are closures,
/// this suite fills them with a log, and the assertions are about *sequence*.
///
/// The bytes are `CommentWriteTests`'; the buffers are `CommentSessionTests`'; the pure layout is
/// `CardCommentsLayoutTests`'.
// MARK: - The log
/// Every seam a `CardComments` can reach, recorded in the order it was reached.
@MainActor
private final class CommentsSpy {
enum Event: Equatable {
case sweep
case readThread
case readDraft
case purge
case displace([String])
case delete(String)
case edit(String, String)
case saveDraft(String)
case post
case importFiles([String], CommentTarget)
case removeFile(String, CommentTarget)
}
private(set) var events: [Event] = []
var thread: CommentThread = .empty
var draft: CommentDraft?
var deleteLands = true
var editLands = true
var postedID: ItemID? = ItemID(rawValue: CommentIdent.two)
func install(on comments: CardComments) {
comments.readThread = { [self] in events.append(.readThread); return thread }
comments.readDraft = { [self] in events.append(.readDraft); return draft }
comments.sweepTrashResidue = { [self] in events.append(.sweep) }
comments.purgeTrash = { [self] in events.append(.purge) }
comments.displaceSquatters = { [self] squatters in
events.append(.displace(squatters.map(\.name)))
}
comments.deleteComment = { [self] id in
events.append(.delete(id.rawValue))
return deleteLands
}
comments.editComment = { [self] id, body in
events.append(.edit(id.rawValue, body))
return editLands
}
comments.importAttachments = { [self] urls, target in
events.append(.importFiles(urls.map(\.lastPathComponent), target))
}
comments.removeAttachment = { [self] name, target in
events.append(.removeFile(name, target))
}
comments.composer.save = { [self] text in
events.append(.saveDraft(text))
return .updated
}
comments.composer.post = { [self] in
events.append(.post)
return postedID
}
}
}
/// A comment value with only the fields this suite reads the loader builds the real ones
/// (`CommentThreadTests`), and a thread assembled here is only ever a stand-in for one it produced.
private func comment(_ id: String, body: String = "text\n", attachments: [String] = []) -> Kanban.Comment {
Kanban.Comment(
id: ItemID(rawValue: id),
schema: .valid(1),
author: .missing,
created: .missing,
modified: .missing,
modifiedBy: .missing,
attachments: attachments,
document: FrontmatterDocument(body: body)
)
}
private func thread(_ comments: [Kanban.Comment], defects: [IntegrityRules.Defect] = []) -> CommentThread {
CommentThread(comments: comments, strays: [], defects: defects, hasDraft: false)
}
@MainActor
private func makeComments(_ spy: CommentsSpy) -> CardComments {
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
spy.install(on: comments)
return comments
}
// MARK: - Opening
@MainActor
@Suite("Card comments ▸ opening")
struct CardCommentsOpenTests {
@Test("The residue sweep runs before the thread read")
func sweepPrecedesTheRead() {
// The sweep *removes* folders, so a read taken before it would describe a
// `comments/.trash/` that is about to stop existing (01-storage-format.md § Enhanced schema).
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.open()
#expect(spy.events.prefix(2) == [.sweep, .readThread])
}
@Test("Opening reads the draft too — restore-on-reopen is just the read")
func openRestoresTheDraft() {
let spy = CommentsSpy()
spy.draft = CommentDraft(body: "half a thought\n", attachments: ["shot.png"])
let comments = makeComments(spy)
comments.open()
#expect(comments.composer.text == "half a thought\n")
#expect(comments.composer.attachments == ["shot.png"])
#expect(!comments.composer.isDirty)
}
@Test("A pane with no store reads nothing rather than showing an empty thread it invented")
func noSeamsIsNoRead() {
let comments = CardComments()
comments.reload()
#expect(comments.thread == .empty)
}
}
// MARK: - Reloading
@MainActor
@Suite("Card comments ▸ the live reload")
struct CardCommentsReloadTests {
@Test("A reload replaces the thread")
func reloadReplacesTheThread() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
#expect(comments.thread.comments.map(\.id.rawValue) == [CommentIdent.one])
}
@Test("Claimed-name squatters the read found are routed to the displacement")
func squattersAreDisplaced() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([], defects: [.claimedNameSquatted(ClaimedNameSquatter(
name: ".draft",
found: .file,
expected: .directory,
location: .commentThread(cardPath: "lane/card")
))])
comments.reload()
#expect(spy.events.contains(.displace([".draft"])))
}
@Test("A clean read displaces nothing — no bracket for a thread with no work in it")
func aCleanReadDisplacesNothing() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
#expect(!spy.events.contains { if case .displace = $0 { true } else { false } })
}
@Test("An open session follows disk while clean, and keeps its keystrokes while dirty")
func theSessionObeysDirtyBufferWins() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "original\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
spy.thread = thread([comment(CommentIdent.one, body: "an agent rewrote it\n")])
comments.reload()
#expect(comments.editing?.text == "an agent rewrote it\n")
comments.editing?.edited("mine\n")
spy.thread = thread([comment(CommentIdent.one, body: "and again\n")])
comments.reload()
#expect(comments.editing?.text == "mine\n")
}
@Test("A session whose comment vanished is dropped, with no error UI of its own")
func aVanishedCommentDropsItsSession() {
// 05 Deletion & lifecycle's "nowhere left to write", applied one level down: the store
// answers `false`/`nil` for a target that is gone, and there is nothing here to tell the user
// that `performWrite` has not already said.
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing != nil)
spy.thread = .empty
comments.reload()
#expect(comments.editing == nil)
}
}
// MARK: - The inline session
@MainActor
@Suite("Card comments ▸ the inline edit session")
struct CardCommentsEditTests {
private func opened(_ spy: CommentsSpy) -> CardComments {
let comments = makeComments(spy)
spy.thread = thread([
comment(CommentIdent.one, body: "first\n"),
comment(CommentIdent.two, body: "second\n")
])
comments.reload()
return comments
}
@Test("Edit opens a session over the comment's current body")
func editOpensASession() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing?.commentID.rawValue == CommentIdent.one)
#expect(comments.editing?.text == "first\n")
#expect(comments.editing?.sessionStart == "first\n")
}
@Test("One session at a time — opening a second commits the first")
func oneSessionAtATime() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("first, revised\n")
comments.beginEdit(ItemID(rawValue: CommentIdent.two))
#expect(spy.events.contains(.edit(CommentIdent.one, "first, revised\n")),
"the user asked to edit another comment — not to throw this one away")
#expect(comments.editing?.commentID.rawValue == CommentIdent.two)
}
@Test("Re-editing the comment already open is a no-op, not a restarted session")
func reEditingIsANoOp() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("typed\n")
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing?.text == "typed\n", "a restart would lose the start-of-session bytes")
#expect(comments.editing?.sessionStart == "first\n")
}
@Test("Save flushes and closes the session")
func saveCommits() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("revised\n")
comments.commitEdit()
#expect(spy.events.contains(.edit(CommentIdent.one, "revised\n")))
#expect(comments.editing == nil)
}
@Test("Cancel writes the session-start bytes back and closes the session")
func cancelReverts() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("half a rewrite\n")
comments.editing?.flush()
comments.cancelEdit()
#expect(spy.events.contains(.edit(CommentIdent.one, "first\n")))
#expect(comments.editing == nil)
}
@Test("Under the read-only lock no session opens at all")
func theLockRefusesASession() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.isEditable = false
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(comments.editing == nil)
}
@Test("Editing a comment the read never found opens nothing")
func editingAVanishedCommentOpensNothing() {
let spy = CommentsSpy()
let comments = opened(spy)
comments.beginEdit(ItemID(rawValue: CommentIdent.three))
#expect(comments.editing == nil)
}
}
// MARK: - Delete
@MainActor
@Suite("Card comments ▸ delete")
struct CardCommentsDeleteTests {
@Test("Delete is immediate — no confirm, and the store's own step")
func deleteIsImmediate() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one)])
comments.reload()
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(spy.events.contains(.delete(CommentIdent.one)))
}
@Test("Deleting the comment being edited commits the session first")
func deleteCommitsTheOpenSession() {
// The user's last keystrokes belong in the file that is about to move into
// `comments/.trash/`, so an undo brings back what they wrote.
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("last words\n")
comments.delete(ItemID(rawValue: CommentIdent.one))
let edit = spy.events.firstIndex(of: .edit(CommentIdent.one, "last words\n"))
let delete = spy.events.firstIndex(of: .delete(CommentIdent.one))
#expect(edit != nil)
#expect(delete != nil)
#expect((edit ?? 0) < (delete ?? 0))
#expect(comments.editing == nil)
}
@Test("Under the read-only lock nothing is deleted")
func theLockRefusesADelete() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.isEditable = false
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(!spy.events.contains(.delete(CommentIdent.one)))
}
}
// MARK: - Attachments on the authoring surfaces
@MainActor
@Suite("Card comments ▸ authoring attachments")
struct CardCommentsAttachmentTests {
@Test("A drop on the composer imports into the draft; one on an inline session, into its comment")
func importsAimAtTheirSurface() {
let spy = CommentsSpy()
let comments = makeComments(spy)
let file = URL(fileURLWithPath: "/tmp/shot.png")
let id = ItemID(rawValue: CommentIdent.one)
comments.importFiles([file], to: .draft)
#expect(spy.events.contains(.importFiles(["shot.png"], .draft)))
comments.importFiles([file], to: .comment(id))
#expect(spy.events.contains(.importFiles(["shot.png"], .comment(id))))
}
@Test("An authoring chip's Remove aims at its own surface")
func removesAimAtTheirSurface() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.removeFile(named: "shot.png", from: .draft)
#expect(spy.events.contains(.removeFile("shot.png", .draft)))
}
@Test("Under the read-only lock neither import nor remove happens")
func theLockRefusesBoth() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.isEditable = false
comments.importFiles([URL(fileURLWithPath: "/tmp/shot.png")], to: .draft)
comments.removeFile(named: "shot.png", from: .draft)
#expect(spy.events.isEmpty)
}
@Test("A chip resolves against its own surface's attachments folder")
func chipURLsResolve() throws {
let spy = CommentsSpy()
let comments = makeComments(spy)
let id = ItemID(rawValue: CommentIdent.one)
let draft = try #require(comments.attachmentURL("shot.png", in: .draft))
#expect(draft.path.hasSuffix("/comments/.draft/attachments/shot.png"))
let posted = try #require(comments.attachmentURL("shot.png", in: .comment(id)))
#expect(posted.path.hasSuffix("/comments/\(CommentIdent.one)/attachments/shot.png"))
}
}
// MARK: - The close flush
@MainActor
@Suite("Card comments ▸ the close flush")
struct CardCommentsCloseTests {
@Test("Saves first, purge last")
func savesPrecedeThePurge() {
// The purge removes the folders a delete moved aside; running it before a session's save
// could remove a folder that save was about to write into.
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("mid-sentence\n")
comments.composer.edited("a draft\n")
comments.endSession()
let edit = try? #require(spy.events.firstIndex(of: .edit(CommentIdent.one, "mid-sentence\n")))
let draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n")))
let purge = try? #require(spy.events.firstIndex(of: .purge))
#expect((edit ?? 0) < (purge ?? 0))
#expect((draft ?? 0) < (purge ?? 0))
}
@Test("The close flushes the session — it never reverts it")
func theCloseIsNotACancel() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("kept\n")
comments.endSession()
#expect(spy.events.contains(.edit(CommentIdent.one, "kept\n")))
#expect(!spy.events.contains(.edit(CommentIdent.one, "first\n")), "a close is not an abandon")
}
@Test("Ending twice does the work once")
func endingTwiceIsIdempotent() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.composer.edited("a draft\n")
comments.endSession()
comments.endSession()
#expect(spy.events.filter { $0 == .saveDraft("a draft\n") }.count == 1)
}
@Test("A clean pane still purges — the trash is the app's leftovers, not the user's work")
func aCleanPaneStillPurges() {
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.endSession()
#expect(spy.events == [.purge])
}
}
// MARK: - Unsaved content
@MainActor
@Suite("Card comments ▸ what counts as unsaved")
struct CardCommentsUnsavedTests {
@Test("An inline edit session's dirty buffer counts")
func theInlineSessionCounts() {
let spy = CommentsSpy()
let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
comments.reload()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
#expect(!comments.holdsUnsavedContent)
comments.editing?.edited("typed\n")
#expect(comments.holdsUnsavedContent)
}
@Test("The composer's draft deliberately does not")
func theDraftDoesNotCount() {
// "Close and quit just proceed no DirtyBufferGuard, nothing to lose" (05 The comments
// column): the draft is a durable file being edited in place, not unsaved work, and this
// property's one consumer is File Save as Template's carve-out.
let spy = CommentsSpy()
let comments = makeComments(spy)
comments.composer.edited("half a thought\n")
#expect(comments.composer.isDirty)
#expect(!comments.holdsUnsavedContent)
}
}
// MARK: - Add Comment
@MainActor
@Suite("Card comments ▸ File ▸ Add Comment")
struct AddCommentCommandTests {
@Test("The row turns Show Comments on and focuses the composer, whichever state it was in")
func theRowDoesBoth() {
// "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" and it focuses
// *either way*, which is the clause that regresses silently.
#expect(AddCommentCommand.effect(isShown: false) == (isShown: true, focusesComposer: true))
#expect(AddCommentCommand.effect(isShown: true) == (isShown: true, focusesComposer: true))
}
@Test("Each request is its own — the counter never coalesces two")
func requestsAreCounted() {
let comments = CardComments()
#expect(comments.focusComposerRequests == 0)
comments.focusComposer()
comments.focusComposer()
#expect(comments.focusComposerRequests == 2)
}
@Test("All three comment rows are card-window-scoped and nothing else")
func theRowsAreScopedToACardWindow() {
#expect(!AddCommentCommand.isEnabled(nil))
#expect(!ShowCommentsCommand.isEnabled(nil))
#expect(!CommentsBesideBodyCommand.isEnabled(nil))
let comments = CardComments()
#expect(AddCommentCommand.isEnabled(comments))
#expect(ShowCommentsCommand.isEnabled(comments))
#expect(CommentsBesideBodyCommand.isEnabled(comments))
}
@Test("The lock does not disable them — showing a pane and focusing a buffer are not mutations")
func theLockLeavesThemAlone() {
let comments = CardComments()
comments.isEditable = false
#expect(AddCommentCommand.isEnabled(comments))
#expect(ShowCommentsCommand.isEnabled(comments))
}
}
// MARK: - The real wiring
@MainActor
@Suite("Card comments ▸ the store wiring")
struct CardCommentsWiringTests {
/// The **real** seams, over a real store `CardWindowHost.configureComments` is `static` and
/// takes its collaborators precisely so this can drive the production wiring rather than a
/// re-typed copy of it (`configureRawSource`'s precedent).
@Test("A draft composed, posted and re-read round-trips through the wired seams")
func theWiringRoundTrips() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: cardID)
comments.open()
comments.composer.edited("A remark.\n")
#expect(comments.composer.flush() == .created)
#expect(fixture.exists("\(card)/comments/.draft"))
// Re-reading is restore-on-reopen, and it is only the read.
comments.reload()
#expect(comments.composer.text == "A remark.\n")
#expect(comments.composer.postNow() != nil)
comments.reload()
#expect(comments.thread.comments.count == 1)
#expect(comments.thread.comments[0].body == "A remark.\n")
#expect(comments.composer.text == "", "the draft is gone — it is a comment now")
#expect(!fixture.exists("\(card)/comments/.draft"))
}
@Test("Delete moves into comments/.trash/, and the close purge empties it")
func deleteThenPurge() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
let store = try BoardStore(rootURL: fixture.root)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
comments.open()
#expect(comments.thread.comments.count == 1)
comments.delete(ItemID(rawValue: CommentIdent.one))
#expect(comments.thread.comments.isEmpty)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
comments.endSession()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
@Test("An inline session's Cancel puts the file back through the wired save")
func cancelWritesThroughTheStore() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(body: "original\n"))
let store = try BoardStore(rootURL: fixture.root)
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1))
comments.open()
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
comments.editing?.edited("rewritten\n")
comments.editing?.flush()
#expect(try fixture.indexText(commentPath(CommentIdent.one, inCard: card)).hasSuffix("rewritten\n"))
comments.cancelEdit()
#expect(try fixture.indexText(commentPath(CommentIdent.one, inCard: card)).hasSuffix("original\n"))
}
}
@@ -0,0 +1,284 @@
import Foundation
import Testing
@testable import Kanban
/// **Comment attachments author in-window** (05-card-window.md The comments column, ruled
/// 2026-07-29): a file dropped in the composer lands in the draft's `attachments/`, one dropped in an
/// inline edit session lands in that comment's, and an authoring chip's Remove moves the file to the
/// *system* Trash.
///
/// This suite is the **write** half the two primitives and the shape guard that decides which
/// folders are authoring surfaces at all plus the draft *read* the composer restores from. The
/// hover rule that chooses between them is pure and lives in `CardCommentsLayoutTests`; the pane's
/// wiring is `CardCommentsTests`'.
///
/// Assertions are against the bytes on disk, `CommentWriteTests`' rule.
private let cardTitle = "Fix login"
// MARK: - Reading the draft
@Suite("Comments ▸ reading the draft")
struct CommentDraftReadTests {
@Test("The composer's restore is the read: body and chips, straight off the folder")
func loadDraftReadsBodyAndChips() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText(body: "half a thought\n"))
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))
let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card)))
#expect(draft.body == "half a thought\n")
#expect(draft.attachments == ["shot.png"])
#expect(!draft.isEmpty)
}
@Test("A card with no draft has nothing to restore")
func noDraftIsNil() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
#expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil)
}
@Test("A draft folder with no readable index.md still reports its files")
func anIndexlessDraftStillHasChips() throws {
// The two-step-create shape, and the shape a drop-before-a-keystroke leaves. Reporting `nil`
// would hide files the user can see in Finder.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))
let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card)))
#expect(draft.body == "")
#expect(draft.attachments == ["shot.png"])
#expect(!draft.isEmpty, "a draft with no text but a file in it is still a draft")
}
@Test("Emptiness is the Writer's own gate, asked forwards")
func emptinessMirrorsTheDeleteRule() {
#expect(CommentDraft(body: "", attachments: []).isEmpty)
#expect(CommentDraft(body: " \n ", attachments: []).isEmpty)
#expect(!CommentDraft(body: "", attachments: ["shot.png"]).isEmpty)
#expect(!CommentDraft(body: "x", attachments: []).isEmpty)
}
@Test("A .draft held by a file is not a draft")
func aSquattedDraftNameIsNotADraft() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.file("\(card)/comments/.draft", Data("not a folder".utf8))
#expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil)
}
}
// MARK: - Importing
@Suite("Comments ▸ authoring attachments")
struct CommentAttachmentImportTests {
private func source(_ fixture: WriterFixture, named name: String, _ text: String = "x") throws -> URL {
try fixture.file("sources/\(name)", Data(text.utf8))
}
@Test("A drop in the composer lands in the draft's attachments/")
func importsIntoTheDraft() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
let file = try source(fixture, named: "shot.png")
let landed = try BoardWriter.importCommentAttachments(
[file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
)
#expect(landed.map(\.fileName) == ["shot.png"])
#expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
}
@Test("A drop in an inline edit session lands in that comment's attachments/")
func importsIntoAComment() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
let file = try source(fixture, named: "shot.png")
_ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path))
#expect(fixture.exists("\(path)/attachments/shot.png"))
}
@Test("A name already taken is renamed Finder-style, never overwritten")
func collisionsRenameFinderStyle() throws {
// The card level's rule, shared rather than restated: `importFiles` is one implementation, so
// a file added to a comment behaves exactly like one added to a card.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("first".utf8))
let file = try source(fixture, named: "shot.png", "second")
let landed = try BoardWriter.importCommentAttachments(
[file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
)
#expect(landed.map(\.fileName) == ["shot 2.png"])
#expect(try fixture.data("\(card)/comments/.draft/attachments/shot.png") == Data("first".utf8))
}
@Test("Importing opens no index.md — a file says nothing about the text beside it")
func importStampsNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
let before = try fixture.indexText(path)
let file = try source(fixture, named: "shot.png")
_ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path))
#expect(try fixture.indexText(path) == before, "the relocation rule: no stamp for a file move")
}
@Test("A folder is refused, as it is at card level")
func foldersAreRefused() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
let folder = fixture.url("sources/afolder")
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let failure = writeFailure {
_ = try BoardWriter.importCommentAttachments(
[folder], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
)
}
#expect(failure?.operation == .importAttachment(filename: "afolder"))
}
@Test("comments/.trash/ is not an authoring surface")
func theTrashRefusesAnImport() throws {
// 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 and the Writer says so
// rather than trusting that.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
let file = try source(fixture, named: "shot.png")
let failure = writeFailure {
_ = try BoardWriter.importCommentAttachments(
[file],
intoComment: CommentThread.trashedCommentFolder(
ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card)
)
)
}
#expect(failure != nil)
}
@Test("A card folder is not a comment folder either")
func theCardIsRefused() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let file = try source(fixture, named: "shot.png")
let failure = writeFailure {
_ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(card))
}
#expect(failure != nil)
}
}
// MARK: - Removing
@Suite("Comments ▸ removing an authoring chip")
struct CommentAttachmentRemoveTests {
@Test("Remove moves the file to the system Trash — never a hard delete")
func removeTrashesTheFile() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))
let trashed = try BoardWriter.removeCommentAttachment(
named: "shot.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card))
)
let landing = try #require(trashed)
#expect(FileManager.default.fileExists(atPath: landing.path), "the file still exists, in the Trash")
#expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
try? FileManager.default.removeItem(at: landing)
}
@Test("A name that is no longer there is not a failure")
func aVanishedNameIsANoOp() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
#expect(try BoardWriter.removeCommentAttachment(
named: "gone.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card))
) == nil)
}
}
// MARK: - The store bracket
@MainActor
@Suite("Comments ▸ authoring attachments at the store")
struct CommentAttachmentStoreTests {
@Test("The store's import and remove land through the ordinary bracket")
func theStoreBracketsBoth() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
let file = try fixture.file("sources/shot.png", Data("png".utf8))
let store = try BoardStore(rootURL: fixture.root)
let cardID = ItemID(rawValue: Ident.card1)
store.importCommentAttachments([file], inCard: cardID, target: .draft)
#expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
store.removeCommentAttachment(named: "shot.png", inCard: cardID, target: .draft)
#expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
}
@Test("A card that is not on the board writes nothing")
func aVanishedCardWritesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let file = try fixture.file("sources/shot.png", Data("png".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.importCommentAttachments([file], inCard: ItemID(rawValue: Ident.card4), target: .draft)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card4)"))
}
@Test("The draft read reaches the store the same way the thread read does")
func theStoreReadsTheDraft() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText(body: "restored\n"))
let store = try BoardStore(rootURL: fixture.root)
#expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card1))?.body == "restored\n")
#expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card4)) == nil, "a card that is not there")
}
}
+476
View File
@@ -0,0 +1,476 @@
import Foundation
import Testing
@testable import Kanban
/// The comments pane's **two buffers** and the cadences that separate them (05-card-window.md The
/// comments column):
///
/// - the composer's draft, whose saves are slow "composer blur, window close, quit, and a lazy
/// interval (~30 s) **not** the body editor's 700 ms";
/// - an inline comment edit, which *is* the body editor's 700 ms, plus the one thing the body has
/// not got: a Cancel that reverts to session-start bytes.
///
/// `CardBodyEditSessionTests`' shape, and for its reason: both types are a buffer and a clock with a
/// closure for a destination, so this suite is about the rules rather than about files the fakes
/// below record what they were asked to write, and "writes nothing" is an assertion about a count.
/// The bytes those saves put on disk are `CommentWriteTests`'.
// MARK: - Fakes
/// A stand-in for `BoardStore.saveCommentDraft` / `postComment`.
@MainActor
private final class DraftSpy {
private(set) var written: [String] = []
private(set) var posts = 0
/// `nil` is a save that did not land a failure, the read-only lock, a vanished card.
var outcome: CommentDraftOutcome? = .updated
var postedID: ItemID? = ItemID(rawValue: CommentIdent.one)
var count: Int { written.count }
func save(_ text: String) -> CommentDraftOutcome? {
written.append(text)
return outcome
}
func post() -> ItemID? {
posts += 1
return postedID
}
}
/// A stand-in for `BoardStore.editComment`.
@MainActor
private final class EditSpy {
private(set) var written: [String] = []
var lands = true
var count: Int { written.count }
var last: String? { written.last }
func save(_ text: String) -> Bool {
written.append(text)
return lands
}
}
@MainActor
private func makeComposer(_ spy: DraftSpy, draft: CommentDraft? = nil) -> CommentDraftSession {
let session = CommentDraftSession()
// Fast enough that a test never waits on the real 30 s. `CardBodyEditSessionTests`' precedent
// a production default on the property, the suite dialling it down.
session.saveInterval = .milliseconds(40)
session.save = { [spy] text in spy.save(text) }
session.post = { [spy] in spy.post() }
session.adopt(draft: draft)
return session
}
@MainActor
private func makeEditor(_ spy: EditSpy, body: String = "original\n") -> CommentEditSession {
let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: body)
session.debounceInterval = .milliseconds(30)
session.save = { [spy] text in spy.save(text) }
return session
}
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while !condition() {
guard ContinuousClock.now - start < deadline else { return }
try? await Task.sleep(for: .milliseconds(5))
}
}
// MARK: - The composer's cadence
@MainActor
@Suite("Comment composer ▸ the slow cadence")
struct CommentComposerCadenceTests {
@Test("The production interval is ~30 s, not the body's 700 ms")
func theDefaultIntervalIsTheDesignsNumber() {
// The one thing the seam must not do is quietly become the body's cadence "so a Pro user's
// typing never becomes a commit stream" (05 The comments column).
#expect(CommentDraftSession().saveInterval == .seconds(30))
#expect(CommentDraftSession().saveInterval != CardBodyEditSession().debounceInterval)
}
@Test("A keystroke does not save; the interval does")
func theIntervalSaves() async {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("half a thought")
#expect(spy.count == 0, "not on the keystroke itself")
await waitUntil { spy.count == 1 }
#expect(spy.written == ["half a thought"])
#expect(!session.isDirty)
}
@Test("It is an interval, not a debounce: steady typing still lands a save")
func typingDoesNotPostponeTheSave() async {
let spy = DraftSpy()
let session = makeComposer(spy)
// A debounce would be restarted by each of these and never fire while the user typed. An
// interval fires on its own schedule which is the whole difference, and the reason the
// design says "lazy interval" rather than "debounce".
let start = ContinuousClock.now
while spy.count == 0, ContinuousClock.now - start < .seconds(2) {
session.edited(session.text + "a")
try? await Task.sleep(for: .milliseconds(5))
}
#expect(spy.count == 1)
}
@Test("Blur saves — the first of the four cadence moments")
func blurSaves() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("typed")
#expect(session.blurred() == .updated)
#expect(spy.written == ["typed"])
#expect(!session.isDirty)
}
@Test("A flush pre-empts the armed interval, and the interval does not fire behind it")
func flushPreemptsTheInterval() async {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("typed")
_ = session.flush()
#expect(spy.count == 1)
await waitUntil { spy.count > 1 }
#expect(spy.count == 1)
}
@Test("An untouched composer writes nothing, ever")
func anUntouchedComposerWritesNothing() async {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))
#expect(session.text == "restored\n", "restore-on-reopen is just the read")
#expect(session.flush() == nil)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A typed-then-reverted draft writes nothing, and leaves no timer standing")
func aRevertedDraftWritesNothing() async {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))
session.edited("restored\nand more")
session.edited("restored\n")
#expect(!session.isDirty)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A save that did not land keeps the buffer dirty, and the text")
func aFailedSaveKeepsTheText() {
let spy = DraftSpy()
let session = makeComposer(spy)
spy.outcome = nil
session.edited("precious")
#expect(session.flush() == nil)
#expect(session.text == "precious")
#expect(session.isDirty, "the read-only lock, a failure and a vanished card all keep the text")
}
@Test("Emptying the composer saves empty text — the Writer owns the delete rule")
func emptyingSavesEmptyText() {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "was here\n", attachments: []))
spy.outcome = .deleted
session.edited("")
#expect(session.flush() == .deleted)
#expect(spy.written == [""], "no second definition of 'no text and no attachments'")
#expect(!session.isDirty)
}
}
// MARK: - Dirty-buffer-wins, in the composer
@MainActor
@Suite("Comment composer ▸ dirty-buffer-wins")
struct CommentComposerAdoptTests {
@Test("A clean composer follows the file — a draft synced in arrives by itself")
func aCleanComposerFollowsDisk() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.adopt(draft: CommentDraft(body: "from another machine\n", attachments: ["shot.png"]))
#expect(session.text == "from another machine\n")
#expect(session.attachments == ["shot.png"])
#expect(!session.isDirty)
}
@Test("A dirty composer keeps its keystrokes under a reload")
func aDirtyComposerKeepsItsText() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("mine, unsaved")
session.adopt(draft: CommentDraft(body: "theirs\n", attachments: []))
#expect(session.text == "mine, unsaved")
#expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
#expect(session.isDirty)
}
@Test("Chips follow the file even while the text is dirty — a drop is not a keystroke")
func chipsFollowDiskWhileDirty() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("mid-sentence")
// The user dropped a file into the composer: the import is a write of its own, and its
// reload must show the chip without disturbing the text above it.
session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))
#expect(session.text == "mid-sentence")
#expect(session.attachments == ["shot.png"])
}
@Test("No draft at all is an empty, clean composer")
func noDraftIsEmpty() {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "here\n", attachments: []))
session.adopt(draft: nil)
#expect(session.text == "")
#expect(!session.isDirty)
}
}
// MARK: - Posting
@MainActor
@Suite("Comment composer ▸ posting")
struct CommentComposerPostTests {
@Test("There is nothing to post exactly when there would be nothing to keep")
func canPostMirrorsTheEmptiedDraftRule() {
let spy = DraftSpy()
let session = makeComposer(spy)
#expect(!session.canPost)
session.edited(" \n ")
#expect(!session.canPost, "whitespace is empty, the Writer's own gate")
session.edited("something")
#expect(session.canPost)
session.edited("")
session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))
#expect(session.canPost, "a draft with no text but a file in it is still a draft")
}
@Test("⌘↩ flushes before it posts — the slow cadence is never visible as lost text")
func postFlushesFirst() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("about to post")
#expect(session.postNow() != nil)
#expect(spy.written == ["about to post"], "the post renames a folder; unwritten text is not in it")
#expect(spy.posts == 1)
}
@Test("After a post the composer is empty and clean — the draft is gone")
func postEmptiesTheComposer() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("posted")
session.adopt(draft: CommentDraft(body: "posted", attachments: ["shot.png"]))
_ = session.postNow()
#expect(session.text == "")
#expect(session.disk == "")
#expect(session.attachments.isEmpty)
#expect(!session.isDirty)
}
@Test("An empty composer posts nothing at all")
func anEmptyComposerPostsNothing() {
let spy = DraftSpy()
let session = makeComposer(spy)
#expect(session.postNow() == nil)
#expect(spy.posts == 0)
#expect(spy.count == 0)
}
@Test("A post that did not land leaves the composer holding its text")
func aRefusedPostKeepsTheDraft() {
let spy = DraftSpy()
let session = makeComposer(spy)
spy.postedID = nil
session.edited("still mine")
#expect(session.postNow() == nil)
#expect(session.text == "still mine")
}
}
// MARK: - The inline edit session
@MainActor
@Suite("Inline comment edit ▸ the body session in miniature")
struct CommentEditSessionTests {
@Test("The cadence is the body's ~700 ms, not the draft's")
func theCadenceIsTheBodys() {
let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: "")
#expect(session.debounceInterval == CardBodyEditSession().debounceInterval)
}
@Test("Typing saves once the keystrokes stop — crash safety without a commit point")
func typingSaves() async {
let spy = EditSpy()
let session = makeEditor(spy)
session.edited("revised\n")
#expect(spy.count == 0)
await waitUntil { spy.count == 1 }
#expect(spy.written == ["revised\n"])
#expect(!session.isDirty)
}
@Test("A burst is one save, of the last text")
func aBurstCoalesces() async {
let spy = EditSpy()
let session = makeEditor(spy)
for text in ["a", "ab", "abc"] { session.edited(text) }
await waitUntil { spy.count >= 1 }
#expect(spy.written == ["abc"])
}
@Test("An untouched session writes nothing, and ends writing nothing")
func anUntouchedSessionWritesNothing() async {
let spy = EditSpy()
let session = makeEditor(spy)
#expect(!session.commit())
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("Save is the commit point: it flushes and ends")
func commitFlushesAndEnds() {
let spy = EditSpy()
let session = makeEditor(spy)
session.edited("revised\n")
#expect(session.commit())
#expect(spy.written == ["revised\n"])
#expect(session.hasEnded)
#expect(!session.commit(), "a session ends once")
}
@Test("Cancel writes the session-start bytes back")
func cancelRevertsToSessionStart() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("half a rewrite\n")
#expect(session.flush())
#expect(spy.written == ["half a rewrite\n"])
#expect(session.cancel())
#expect(spy.written == ["half a rewrite\n", "original\n"])
#expect(session.text == "original\n")
#expect(session.hasEnded)
}
@Test("A cancel with nothing landed writes nothing — the file stays byte-identical")
func cancelAfterNoSaveWritesNothing() {
let spy = EditSpy()
let session = makeEditor(spy)
// Typed, but the debounce never fired: nothing of this session's is on disk, so there is
// nothing to put back and no `modified` stamp to spend.
session.edited("never landed\n")
#expect(!session.cancel())
#expect(spy.count == 0)
}
@Test("Cancel after several ticks reverts to the session's start, not to the last tick")
func cancelUndoesTheWholeSession() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("one\n")
_ = session.flush()
session.edited("two\n")
_ = session.flush()
session.edited("three\n")
_ = session.flush()
#expect(session.cancel())
#expect(spy.last == "original\n")
}
@Test("A window close flushes the session — it never reverts it")
func closeFlushesRatherThanReverts() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("mid-sentence\n")
#expect(session.endOnClose())
#expect(spy.written == ["mid-sentence\n"], "dismissal never eats typed work where a save can land")
#expect(session.hasEnded)
}
@Test("A save that did not land keeps the buffer dirty, and the text")
func aFailedSaveKeepsTheText() {
let spy = EditSpy()
let session = makeEditor(spy)
spy.lands = false
session.edited("precious\n")
#expect(!session.flush())
#expect(session.text == "precious\n")
#expect(session.isDirty)
}
@Test("Dirty-buffer-wins: a foreign edit never lands under the cursor")
func dirtyBufferWins() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("mine\n")
session.adopt(diskBody: "theirs\n")
#expect(session.text == "mine\n")
#expect(session.disk == "theirs\n")
// And a clean buffer follows disk.
let clean = makeEditor(spy, body: "original\n")
clean.adopt(diskBody: "theirs\n")
#expect(clean.text == "theirs\n")
#expect(!clean.isDirty)
}
@Test("Cancel after a foreign edit still writes the session's start bytes — last writer wins")
func cancelOverAForeignEdit() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.adopt(diskBody: "an agent rewrote it\n")
#expect(session.cancel())
#expect(spy.last == "original\n", "the same no-merge-UI philosophy the body's dirty-buffer rule states")
}
}
+7 -1
View File
@@ -33,12 +33,18 @@ Lanework is in early development. This list tracks what has actually shipped and
- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel seeded with the template's own name; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. **A template is itself a board** — an ordinary schema-valid folder read by the ordinary loader, so its display name, icon, blurb and lanes all come off its own `index.md`, and authoring one is adding a folder rather than writing code. Creating from one copies that folder: fresh GUIDs for every lane and card, `created`/`modified` stamped today (born, not forked from the template), the blurb becoming the new board's description, attachments and card bodies byte for byte, strays and symlinks carried verbatim — and `.git` and `.trash/` deliberately left behind, so a new board is never silently in git mode and never born with trash. Nothing half-made is ever left where you pointed: a create that fails or is cancelled removes its own partial, and a name already taken is refused rather than replaced. The chooser lists **your own templates** after the bundled ten — keyed ones in the order they carry, then keyless boards by name — and a button beside its heading reveals the templates folder in the Finder, creating it if you've never used one: they're plain board folders in there, so dropping a board in makes it a template, no key required, and the app never edits what it didn't write. A folder it can't read is **still listed**, marked and carrying the loader's own sentence, because one bad template must never take the chooser down with it. **File ▸ Save as Template** copies the frontmost board into that folder — pending work flushed first so it misses no keystroke, `.git` and `.trash/` left behind (a template is content, not history, and not a fork), everything else including `CLAUDE.user.md` and every GUID and timestamp carried verbatim, and a chooser position appended after your existing templates. A name already in the folder auto-renames Finder-style ("Roadmap 2") rather than overwriting or refusing, the copy is cancellable from its progress row (cancelling removes the partial), and a quiet line tells you which template you just made. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — `.trash/` carried along so the copy matches its own copied history, strays and timestamps untouched.
- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a card focuses the window it already has, and the window closes itself the moment its card stops being on the board — moved to the trash (entering the trash counts as deleted), gone with its deleted lane, purged, or moved to another board; a dirty Edit buffer flushes into the card's new location first, so the keystrokes survive a later restore.
- **The card window** — ⌘↩ or a double-click opens a card in its own window: three componentized, independently scrolling panes — a wide body pane, the comments pane (below) when it's shown, and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a card focuses the window it already has, and the window closes itself the moment its card stops being on the board — moved to the trash (entering the trash counts as deleted), gone with its deleted lane, purged, or moved to another board; a dirty Edit buffer flushes into the card's new location first, so the keystrokes survive a later restore.
- **Attachments** — the sidebar's first section is the card's complete file inventory: every top-level file of its `attachments/` in Finder order, body-embedded ones included, as compact rows carrying a small QuickLook thumbnail (the file's Finder icon until one is generated, and for anything QuickLook won't preview) beside a middle-truncated filename. The whole window is the drop surface — drag files anywhere in it, Edit mode and raw source included, and they import as attachments with Finder-style renames on collision, because the text editor deliberately declines file drags while dragged *text* still lands at the caret exactly as it always did. Folders refuse at the cursor and a mixed drag imports its files and says how many folders it skipped. File ▸ Add Attachment… (⇧⌘A) and a quiet plus in the section header are the same act from the menu bar and the pointer, both opening a multi-select panel into the same import path the board's own file drops use. The section is keyboard-native: it takes focus, arrows walk the rows, Space QuickLooks the selected one in the system's own panel, Return opens it in its default app, and ⌫ moves it to the **system** Trash — never a hard delete, and deliberately distinct from the board's own trash, which is why a failure there says "Couldn't move 'shot.png' to the Trash". Rows drag out their file URL, so a file goes to Finder or another app with no export path of its own; a right-click offers Open, Reveal in Finder and Remove; and File ▸ Reveal in Finder points at the selected attachment while the section holds focus, the card's folder otherwise. Every write is an ordinary bracketed one — one reload, one commit, one banner on failure — and the read-only lock disables adding and removing in place.
- **The card body — Preview and Edit** — the body is read as a fully rendered Markdown preview and written as raw Markdown, never a WYSIWYG halfway house. Preview lays out headings, emphasis, code, quotes, lists, GFM tables, thematic breaks and images resolved against the card's own folder; HTML shows verbatim as code, remote images never load (Preview does no networking), links open in the browser or the file's default app, and task-list checkboxes are live — clicking one flips exactly that character in the file and touches no other byte. ⌘E toggles View ▸ Edit Body, Return in Preview enters it, Escape leaves it, and a card whose body is empty opens straight into the editor with the cursor ready. Edit is a monospaced editor with lightweight syntax highlighting — headings emphasized, bold and italic styled, code tinted, link targets and structural markers dimmed — that is presentation only: the text stays the raw Markdown character for character, smart quotes and dashes off. It saves ~700 ms after you stop typing, and flushes the moment you leave Edit or close the window, so neither the preview nor the disk ever lags what you typed. ⌘Z is the editor's own undo, scoped to the session; ⌘F is find-in-text over whichever surface is showing. Three write rules keep the file honest: a body nobody touched is never re-serialized (byte-identical on disk, modification date included), an edit typed and then undone is not written, and the app's own save echoing back through the watcher is not written again. If the file changes underneath you while the buffer has unsaved keystrokes, the buffer wins — the board, the preview and every other window take the new version while your text stays exactly where it is, and your save then lands over theirs. A close that cannot save stops and asks: try again, save a copy elsewhere, or discard.
- **Comments** — every card carries a thread of comments, each one a folder of its own beside the card's body, so a comment is a Markdown file an agent can write and a human can read in Finder. **View ▸ Show Comments** puts the pane in every card window and keeps it there — one app-wide setting that persists across launches, with no auto-hiding cleverness: a card with nothing said about it yet shows the empty thread and the composer, because the invitation is the point, and deleting the last comment never closes the pane. **View ▸ Comments Beside Body** chooses where it sits — beside the body by default, or stacked under it at a fixed three-to-two split for narrow displays — and the panes are identical either way; the window's minimum width grows only while the column is beside the body. The thread stays visible through Edit mode, and Raw Source still swaps the whole content area, comments included. Each comment reads as a quiet author line (the self-reported `author`, the timestamp, and "· edited" when it has been), the rendered Markdown body in the card body's own subset — tables, code, quotes, images resolved against the card's folder — and attachment chips with Quick Look. There are no avatars: there is no identity system behind the name, and a comment with no author renders without one rather than with a placeholder standing in. The header carries the count and a sort control that flips the thread between oldest-first and newest-first, app-wide and remembered.
- **The composer, and the draft behind it** — the always-visible text area at the thread's newest end is backed by a real file, `comments/.draft/`, so restore-on-reopen is just the app reading it again: close the window mid-sentence, come back tomorrow, and your half-written comment is where you left it — and it rides git and syncs across machines like anything else in the board. Its saves are deliberately **slow**: on blur, on window close, on quit, and on a lazy half-minute tick — not the body editor's 700 ms — so typing never becomes a stream of commits. Escape moves focus out and touches nothing; the draft is a durable file, so emptying it is the discard gesture, and a draft emptied of text with no files deletes its own folder rather than leaving litter. ⌘↩ posts (a Comment button twins it), which renames the draft to a fresh identity and restamps it in one write — chronology is when you posted, not when you started drafting. File ▸ Add Comment turns the pane on if it's off and puts the cursor in the composer, in one gesture.
- **Editing, deleting, and attaching to comments** — a comment's context menu carries Edit, Delete and Reveal in Finder. **Edit is the body editor in miniature**: an inline session with syntax highlighting and debounced saves straight into the comment's own file (so a crash costs nothing), Save or ⌘↩ as its commit point, and Cancel — or Escape, its keyboard twin — reverting to the bytes the session opened on; closing the window flushes it exactly as the body's does, because a dismissal is not an abandon. **Delete is immediate and undoable, with no confirmation**: the comment moves into a `comments/.trash/` beside the draft, ⌘Z is the move back, and the folder is emptied when the window closes — with any residue from a session that died swept at the next open. **Comment attachments author in place**: a file dropped inside the composer lands in the draft's `attachments/`, one dropped inside an open inline edit lands in that comment's, and everywhere else in the window the ordinary card-wide import still applies. A quiet paperclip on both authoring surfaces covers the no-drag path, chips on a surface you're authoring carry Remove (to the **system** Trash, never a hard delete), and a posted comment's chips are read-only — Edit the comment to change its files.
- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." The read-only lock disables the surface without closing it.