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