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:
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user