Comments, phase 1 — storage, writer primitives, and the undo inventory
The kind: comment field table lands in IntegrityRules (the per-kind hook's first exercise), CommentThread reads one card's thread window-scoped (the board walk stays O(cards)), and CommentWriter gains the five gestures: draft save, post (rename .draft to a fresh UUID, created/modified restamped in the bracket), edit, delete into comments/.trash/, and the purge with its crash-residue memo. Post and delete register move-based undo steps; draft saves, edits, and the purge deliberately register nothing (13's no-capture rule). Copy boundaries strip comments/.trash, carry .draft verbatim, and remint threads; comments graduates to a displacing claimed name, with .draft, .trash, and a comment's attachments claimed one level down. CommentPath classifies changed paths into the 06 verb family for later announcer/composer wiring. One stated narrowing pending a ruling (filed on the findings board): the copy transaction's refuse-whole preflight stays cards-and-lanes — an unstampable copied comment copies verbatim with a log line, because comment defects never refuse. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -848,6 +848,31 @@ public final class BannerCenter {
|
||||
// The buffer is still on screen: the banner says the app could not put those bytes on
|
||||
// disk, not that they are gone.
|
||||
if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" }
|
||||
|
||||
// **The comment family names the card, because that is the title these operations carry** —
|
||||
// a comment has none of its own (01-storage-format.md § Enhanced schema: "No `title`, no
|
||||
// `order`"), and the card is the thing on the window in front of them. Each sentence is the
|
||||
// gesture's own word, the vocabulary's standing rule: they pressed ⌘↩, or Delete, or nothing
|
||||
// at all in the draft's case.
|
||||
case let .saveCommentDraft(title):
|
||||
// **"draft", never "save the comment"**: nothing has been posted, and a banner claiming a
|
||||
// comment could not be saved would name one that does not exist yet. The typed text is
|
||||
// still in the composer — this says the app could not put it on disk.
|
||||
if let title { "Couldn't save your draft comment on '\(title)'" } else { "Couldn't save your draft comment" }
|
||||
case let .postComment(title):
|
||||
// The Comment button's own word (05-card-window.md ▸ The comments column, "⌘↩ posts").
|
||||
// The draft is untouched on disk, which is what makes this a retry rather than a loss.
|
||||
if let title { "Couldn't post your comment on '\(title)'" } else { "Couldn't post your comment" }
|
||||
case let .editComment(title):
|
||||
if let title { "Couldn't save your edit to a comment on '\(title)'" } else { "Couldn't save your edit to the comment" }
|
||||
case let .deleteComment(title):
|
||||
if let title { "Couldn't delete a comment on '\(title)'" } else { "Couldn't delete the comment" }
|
||||
case .purgeCommentTrash:
|
||||
// No title, and no mention of a trash the user has never seen: `comments/.trash/` is
|
||||
// "never a UI surface", so the sentence is about the *card's* files being tidied — the
|
||||
// agent guide's posture, one level down. Nothing is lost either way; the next window
|
||||
// open sweeps again.
|
||||
"Couldn't tidy up deleted comments"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - The comment gestures
|
||||
|
||||
/// **The comment thread at the store boundary** — every comment write the app makes, bracketed, with
|
||||
/// its inverse registered where 13-native-undo.md says one belongs.
|
||||
///
|
||||
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
|
||||
/// living on `BoardStore` at all: comments are read by the *card window* and written through the
|
||||
/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every
|
||||
/// window over a board (board window, its card windows) shares the store and shares the stack"
|
||||
/// (13 ▸ Rules). A comment posted in a card window is undone by ⌘Z in the board window, which is only
|
||||
/// true if the step was registered here.
|
||||
///
|
||||
/// ### What registers a step, and what deliberately does not
|
||||
///
|
||||
/// Two of the five (13 ▸ Interaction with the trash, the comment clause; 13 ▸ Rules):
|
||||
///
|
||||
/// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture.
|
||||
/// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture.
|
||||
/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule
|
||||
/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in
|
||||
/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start
|
||||
/// bytes — 05-card-window.md), which is a live buffer, not a stack entry.
|
||||
/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. It is also what makes
|
||||
/// the interaction with the stack correct for free: leftover comment steps go stale after a purge
|
||||
/// and skip with the ordinary info-tone banner, because the folders their expectations name are
|
||||
/// gone. **The expectations validate disk, never the snapshot** (`HistoryStaleness`), which is
|
||||
/// exactly why this works — comments are not in the snapshot at all.
|
||||
@MainActor
|
||||
extension BoardStore {
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
/// One card's thread, read fresh from disk — **window-scoped**, never cached on the store and
|
||||
/// never part of `snapshot` (01-storage-format.md § Enhanced schema: "the board snapshot never
|
||||
/// loads comment content", so the board walk stays O(cards)).
|
||||
///
|
||||
/// `.empty` for an id that names no card on the board — the same vanished-target answer every
|
||||
/// other card-scoped call gives (`boardItem`).
|
||||
public func commentThread(inCard id: ItemID) -> CommentThread {
|
||||
guard let card = commentSubject(id) else { return .empty }
|
||||
return CommentThread.load(inCard: card.folder, path: card.path)
|
||||
}
|
||||
|
||||
// MARK: The draft
|
||||
|
||||
/// Saves the composer's draft — one bracket, no step.
|
||||
///
|
||||
/// - Returns: what landed, or `nil` when the write failed (the banner is already
|
||||
/// `performWrite`'s) or the card is gone.
|
||||
@discardableResult
|
||||
public func saveCommentDraft(inCard id: ItemID, body: String) -> CommentDraftOutcome? {
|
||||
guard let card = commentSubject(id) else { return nil }
|
||||
return try? performWrite { () throws(BoardWriteError) -> CommentDraftOutcome in
|
||||
try BoardWriter.saveCommentDraft(inCard: card.folder, body: body, cardTitle: card.title)
|
||||
}
|
||||
}
|
||||
|
||||
/// **Posts the draft, and registers the one step the gesture owes** (⌘↩ or the Comment button).
|
||||
///
|
||||
/// The step's predicate is the brief the ruling gives it: the undo needs the posted folder still
|
||||
/// at its path **and `.draft` still absent** — a draft the user has started typing since is not
|
||||
/// this step's to overwrite — and the redo needs the mirror. Both are `HistoryExpectation`s over
|
||||
/// disk, and the `.absent` half is the same one an undone create uses.
|
||||
///
|
||||
/// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
|
||||
/// that re-minted would post a *different* comment, and any step registered above this one naming
|
||||
/// the posted id would name nothing.
|
||||
@discardableResult
|
||||
public func postComment(inCard id: ItemID) -> ItemID? {
|
||||
guard let card = commentSubject(id) else { return nil }
|
||||
guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
|
||||
try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let folder = card.folder
|
||||
let title = card.title
|
||||
let draftFolder = CommentThread.draftFolder(inCard: folder)
|
||||
let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder)
|
||||
registerStep(
|
||||
HistoryPhrase.comment,
|
||||
subject: title,
|
||||
undoExpects: [.present(postedFolder), .absent(draftFolder)],
|
||||
redoExpects: [.present(draftFolder), .absent(postedFolder)]
|
||||
) { _ in
|
||||
try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title)
|
||||
} redo: { _ in
|
||||
try BoardWriter.repostComment(
|
||||
as: posted.id,
|
||||
inCard: folder,
|
||||
stamping: posted.posted,
|
||||
cardTitle: title
|
||||
)
|
||||
}
|
||||
return posted.id
|
||||
}
|
||||
|
||||
// MARK: Editing
|
||||
|
||||
/// An inline edit session's save — one bracket, **no step** (13's no-capture rule; the session
|
||||
/// owns Cancel).
|
||||
///
|
||||
/// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
|
||||
@discardableResult
|
||||
public func editComment(_ commentID: ItemID, inCard id: ItemID, body: String) -> Bool {
|
||||
guard let card = commentSubject(id) else { return false }
|
||||
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
|
||||
let wrote = try? performWrite { () throws(BoardWriteError) -> Bool in
|
||||
try BoardWriter.editComment(at: folder, body: body, cardTitle: card.title)
|
||||
}
|
||||
return wrote ?? false
|
||||
}
|
||||
|
||||
// MARK: Delete and its inverse
|
||||
|
||||
/// **Deletes a comment — a move into `comments/.trash/`, immediate, no confirm, undoable**
|
||||
/// (01-storage-format.md § Enhanced schema; 05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// The step is the move read backwards, `moveToTrash`'s registration one level down and without
|
||||
/// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
|
||||
/// and existence is the whole predicate. The container rides in the path exactly as it does at
|
||||
/// board level — the undo expects the comment in `comments/.trash/`, the redo expects it back in
|
||||
/// the thread — so a foreign restore or a foreign re-delete skips the right half by itself.
|
||||
@discardableResult
|
||||
public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool {
|
||||
guard let card = commentSubject(id) else { return false }
|
||||
let folder = card.folder
|
||||
let title = card.title
|
||||
let live = CommentThread.commentFolder(commentID, inCard: folder)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
|
||||
let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder)
|
||||
registerStep(
|
||||
HistoryPhrase.name(.delete, kind: .comment),
|
||||
subject: title,
|
||||
undoExpects: [.present(trashed)],
|
||||
redoExpects: [.present(live)]
|
||||
) { _ in
|
||||
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
|
||||
} redo: { _ in
|
||||
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: The purge, and the crash residue it leaves
|
||||
|
||||
/// **Empties one card's `comments/.trash/`** — the card window's close flush (01-storage-format.md
|
||||
/// § Enhanced schema: "purged when the card window closes (rides the close flush)").
|
||||
///
|
||||
/// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must
|
||||
/// not be: invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with
|
||||
/// the ordinary info-tone banner the first time one is crossed.
|
||||
public func purgeCommentTrash(inCard id: ItemID) {
|
||||
guard let card = commentSubject(id) else { return }
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.purgeCommentTrash(inCard: card.folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue
|
||||
/// sweeps at the next card-window open, armed-then-cleared like every heal memo").
|
||||
///
|
||||
/// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash
|
||||
/// is empty (which is every open on a board that closed cleanly, and costs no bracket at all),
|
||||
/// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear
|
||||
/// on success.
|
||||
///
|
||||
/// **Silent** — `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is
|
||||
/// the app's own leftovers from a session that died; there is nothing here a user could act on.
|
||||
///
|
||||
/// The memo is board-wide and keyed by class, so two cards' residue swept in turn re-arm each
|
||||
/// other's picture. That is harmless rather than tolerated: the picture *is* the work, the write
|
||||
/// half re-verifies against disk, and a sweep with nothing to remove is a no-op.
|
||||
public func sweepCommentTrashResidue(inCard id: ItemID) {
|
||||
guard let card = commentSubject(id) else { return }
|
||||
let folder = card.folder
|
||||
let residue = CommentThread.trashedCommentIDs(inCard: folder)
|
||||
heals.run(
|
||||
.commentTrashResidue,
|
||||
signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.purgeCommentTrash(inCard: folder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The thread's claimed names
|
||||
|
||||
/// Displaces the claimed names one thread read found squatted — `comments/.draft`,
|
||||
/// `comments/.trash`, and a comment's own `attachments` (01-storage-format.md § Fractal layout
|
||||
/// ▸ Rules, the level-uniform displacement, applied at the two levels the thread adds).
|
||||
///
|
||||
/// **One bracket over the batch, and no memo**, which is the one place this heal differs from its
|
||||
/// board-level twin (`displaceClaimedNames`) — deliberately, and worth stating:
|
||||
/// `HealScheduler`'s memo is keyed by defect *class*, and these squatters are the same class as
|
||||
/// the board walk's. Sharing the key would have one card's thread picture overwrite the board's
|
||||
/// and back again on every reload, so the two would spend their memos fighting instead of guarding.
|
||||
/// What replaces the memo here is the trigger: a thread is re-read when its window opens or its
|
||||
/// files change, not on a timer, so there is no hot loop for a memo to break.
|
||||
///
|
||||
/// - Parameter squatters: the defects a `CommentThread` read reported (its `defects`, filtered).
|
||||
/// - Returns: what was actually moved aside, for the caller's warning-tone notice — the Writer
|
||||
/// re-verifies each against disk and answers `nil` for one that freed itself.
|
||||
@discardableResult
|
||||
public func displaceCommentClaimedNames(_ squatters: [ClaimedNameSquatter]) -> [BannerCenter.Displacement] {
|
||||
guard !squatters.isEmpty else { return [] }
|
||||
let root = rootURL
|
||||
var displaced: [BannerCenter.Displacement] = []
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for squatter in squatters {
|
||||
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
|
||||
continue
|
||||
}
|
||||
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
|
||||
}
|
||||
}
|
||||
return displaced
|
||||
}
|
||||
|
||||
// MARK: - Resolving the card
|
||||
|
||||
/// Where a card's thread lives and what to call the gesture — the three facts every comment write
|
||||
/// needs, resolved off the snapshot the way every other card-scoped call resolves them
|
||||
/// (`boardItem`): `nil` for an id that names no live card, which is the vanished-target guard.
|
||||
///
|
||||
/// The path is rebuilt from `rootURL` rather than remembered, so a board renamed mid-session
|
||||
/// writes at its new location.
|
||||
private func commentSubject(_ id: ItemID) -> (folder: URL, path: String, title: String?)? {
|
||||
guard let item = Self.boardItem(id, in: snapshot), let cardID = item.cardID else { return nil }
|
||||
return (
|
||||
folder: ItemPath.card(lane: item.laneID, id: cardID).folder(under: rootURL),
|
||||
path: "\(item.laneID.rawValue)/\(cardID.rawValue)",
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommentPath
|
||||
|
||||
/// **What a changed path under a card's `comments/` is** — the pure classification the announcer and
|
||||
/// the commit-message composer read (01-storage-format.md § Enhanced schema: "foreign comment changes
|
||||
/// are described by **path shape** — the 'Update agent guide (vN)' mechanism: a changed path under
|
||||
/// `…/comments/<uuid>/` composes 'Comment on ⟨card title⟩' / 'Edit comment on…' / 'Delete comment
|
||||
/// on…', and the announcer speaks arrivals the same way").
|
||||
///
|
||||
/// ### Why a path, and not the snapshot
|
||||
///
|
||||
/// Comments are window-scoped and the board snapshot never carries their content, so the two
|
||||
/// consumers that describe *change* have no diff to read: the composer is "a pure snapshot diff" and
|
||||
/// the snapshot has nothing to say here, exactly as it has nothing to say about `CLAUDE.md`. Path
|
||||
/// shape is what is left, and it is enough — the verb family is a function of *where* a file sits,
|
||||
/// not of what it contains.
|
||||
///
|
||||
/// ### It classifies, it does not name
|
||||
///
|
||||
/// The shape says comment / draft / trashed and which card. Which *verb* that composes needs one more
|
||||
/// fact the path cannot carry — whether the folder is an arrival or a change to one already there —
|
||||
/// and that belongs to the caller with the before-and-after in hand. This type stays a pure function
|
||||
/// of a string so both consumers can share it without sharing anything else.
|
||||
///
|
||||
/// Homed beside `EchoLedger` and `BoardDiff`, which are the two things that turn observed paths into
|
||||
/// described events.
|
||||
public struct CommentPath: Sendable, Equatable {
|
||||
|
||||
/// The **card**'s path relative to the board root — `<lane>/<card>`, or `.trash/<card>` for a
|
||||
/// trashed card, which carries its thread like any other content.
|
||||
public let cardPath: String
|
||||
|
||||
/// Which of the thread's three homes the path is in.
|
||||
public let kind: Kind
|
||||
|
||||
public enum Kind: Sendable, Equatable {
|
||||
/// A posted comment — `<card>/comments/<uuid>/…`. The thread's own content.
|
||||
case comment(ItemID)
|
||||
/// The card's single draft — `<card>/comments/.draft/…`. Composes the quiet
|
||||
/// "Draft comment on '⟨card⟩'".
|
||||
case draft
|
||||
/// A deleted comment waiting for the close purge — `<card>/comments/.trash/<uuid>/…`.
|
||||
case trashed(ItemID)
|
||||
}
|
||||
|
||||
/// The comment's identity, or `nil` for the draft — which has none, and is the one member of the
|
||||
/// thread that is a name rather than an id.
|
||||
public var id: ItemID? {
|
||||
switch kind {
|
||||
case let .comment(id), let .trashed(id): id
|
||||
case .draft: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies one **root-relative, `/`-separated** path, or `nil` when it is not inside a thread.
|
||||
///
|
||||
/// The rule is one index: a thread lives at `<lane>/<card>/comments/` and a trashed card's at
|
||||
/// `.trash/<card>/comments/`, so `comments` is always the third component and the card is always
|
||||
/// the second — one check covers both containers without either being spelled twice.
|
||||
///
|
||||
/// Everything else answers `nil`, including the paths that are *nearly* one: `comments/` itself
|
||||
/// (a container, never an event), a stray folder inside it, `comments/.trash` with no entry under
|
||||
/// it. A `nil` is not a defect — it is this function saying the path is somebody else's to
|
||||
/// describe.
|
||||
public static func classify(_ relativePath: String) -> CommentPath? {
|
||||
let components = relativePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||
guard components.count >= 4,
|
||||
components[2].lowercased() == IntegrityRules.commentsFolderName,
|
||||
IntegrityRules.isIdentityShaped(components[1])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let cardPath = components[0] + "/" + components[1]
|
||||
|
||||
let entry = components[3]
|
||||
if entry.lowercased() == IntegrityRules.commentDraftFolderName {
|
||||
return CommentPath(cardPath: cardPath, kind: .draft)
|
||||
}
|
||||
if entry.lowercased() == IntegrityRules.commentTrashFolderName {
|
||||
guard components.count >= 5, IntegrityRules.isIdentityShaped(components[4]) else { return nil }
|
||||
return CommentPath(cardPath: cardPath, kind: .trashed(ItemID(rawValue: components[4])))
|
||||
}
|
||||
guard IntegrityRules.isIdentityShaped(entry) else { return nil }
|
||||
return CommentPath(cardPath: cardPath, kind: .comment(ItemID(rawValue: entry)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user