Files
lanework/Kanban/LiveStore/BoardStoreComments.swift
T
rzen d076427ee0 Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 21:19:42 -04:00

436 lines
24 KiB
Swift

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
/// 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 — one store per board, whatever the window (02-architecture.md § Components), so a
/// comment posted here takes the same bracket, the same receipts and the same banner surface as every
/// other write in the app.
///
/// **Which stack the steps land on is not the store's answer** (re-ruled 2026-07-31 — the
/// session-coarsening model, 13 ▸ Rules ▸ two levels): every gesture below takes the issuing window's
/// stack as a parameter, because a comment gesture is a *card window's* gesture and "the board stack
/// never carries a granular comment step" (13 ▸ Interaction with the trash). Board history sees the
/// window's session as one coarse step when the window closes (`registerCardSession`).
///
/// ### What registers a step, and what deliberately does not
///
/// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture.
/// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture.
/// - **`registerCommentEdit`** — one *session*, at its commit point, holding the bytes it opened on
/// (added 2026-07-31 with the window stack, which is where 13 now puts the inline edit). The
/// per-tick `editComment` write registers nothing, exactly as a card body's per-tick save does not.
/// - **`saveCommentDraft` registers nothing**: a draft is a durable file being edited in place, with
/// no session boundary to coalesce at and no meaning for "undo" that emptying it does not already
/// have (05-card-window.md ▸ The comments column).
/// - **`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)
}
/// **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)
}
/// **Which of this card's comments the app itself just wrote** — the ledger's receipts, classified
/// by path shape and retired (`EchoLedger.vouchedComments(inCard:cardPath:)`).
///
/// The card window asks this on every landed reload, before deciding whether its thread's changes
/// are worth announcing: "app-mediated echoes never announce" (10-accessibility.md ▸ Live board
/// announcements) needs a per-comment answer, and the board reload cannot give one because comments
/// are not in the snapshot it compares.
///
/// `[]` for a card that is gone — the same vanished-target answer every other card-scoped call
/// gives, and the conservative direction: nothing vouched for means everything speaks.
public func vouchedComments(inCard id: ItemID) -> Set<ItemID> {
guard let card = commentSubject(id) else { return [] }
return echoes.vouchedComments(inCard: card.folder, cardPath: 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.
///
/// **The step lands on the issuing window's stack** (13 ▸ Rules ▸ two levels, re-ruled
/// 2026-07-31): the composer is a card window's surface, so the window passes its own
/// (`CardWindowHost.configureComments`). `nil` is the board's stack — a caller with no window,
/// which in the app is nobody and in a test is the shortest way to drive the write.
@discardableResult
public func postComment(inCard id: ItemID, on window: CardWindowUndo? = nil) -> 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 title = card.title
// **Anchored to the card, not to its folder** (13 ▸ Rules, ruled 2026-07-31): both halves of
// the move name the card's identity plus the thread position, and the card's folder is
// resolved at apply time — so a lane move under an open window leaves this step, and the
// coarse step folding it, exactly as sound as they were.
let cardAnchor = HistoryAnchor.card(id)
let operation = WriteOperation.postComment(title: title)
registerStep(
HistoryPhrase.comment,
subject: title,
on: window,
undoExpects: [.present(.comment(posted.id, inCard: id)), .absent(.commentDraft(inCard: id))],
redoExpects: [.present(.commentDraft(inCard: id)), .absent(.comment(posted.id, inCard: id))]
) { store in
try BoardWriter.unpostComment(
posted.id,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
try BoardWriter.repostComment(
as: posted.id,
inCard: try store.requiredFolder(for: cardAnchor, operation),
stamping: posted.posted,
cardTitle: title
)
}
return posted.id
}
// MARK: Editing
/// An inline edit session's save — one bracket, **no step**, exactly as a card body's ~700 ms
/// tick writes no step: a session is not a save (`registerCommentEdit`, below).
///
/// - 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
}
/// **Registers one inline edit session as one step** — `registerBodyEdit`'s shape one level down,
/// and its reasons verbatim.
///
/// The window stack is what made this possible and what made it necessary. 13-native-undo.md's
/// no-byte-capture rule is about the *board* stack, where a comment's granular history has no
/// business ("the board stack never carries a granular comment step"); the re-ruled two-level
/// model puts every gesture issued in a card window on that window's own stack at fine grain,
/// "comment post/delete/**edit**" named among them. So an inline edit registers where the body's
/// Edit session registers, at the same kind of boundary — Save, ⌘↩, or the close flush — with the
/// bytes the session opened on (`CommentEditSession.sessionStart`).
///
/// Cancel registers nothing, and needs no rule of its own: it writes the session-start bytes back,
/// so the session's net effect is nothing and there is nothing to undo.
///
/// Its predicate is the body's: "body steps compare bytes" (13 ▸ Rules), against the comment's own
/// folder — so a foreign edit to that comment skips the step, and a foreign edit to the card or to
/// a sibling comment leaves it alone.
public func registerCommentEdit(
_ commentID: ItemID,
inCard id: ItemID,
priorBody: String,
newBody: String,
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let card = commentSubject(id) else { return }
let title = card.title
let comment = HistoryAnchor.comment(commentID, inCard: id)
let operation = WriteOperation.editComment(title: title)
registerStep(
HistoryPhrase.name(.edit, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(comment, .body(newBody))],
redoExpects: [.present(comment, .body(priorBody))]
) { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: priorBody,
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: newBody,
cardTitle: title
)
}
}
// 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 *anchor* exactly as it rides
/// in the path 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, while the card's own lane is nowhere in either claim.
///
/// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
/// window stack): the undo needs the trashed folder there *and the live path free*, because the
/// move back would otherwise land on top of whatever now sits at it — and because the close fold
/// reads these lists as the state the session left. A move that named only its destination would
/// leave the session's fold claiming a comment is still at a path it has left, and the coarse step
/// would be stale the moment it was registered (`CardWindowUndo.netEffect`).
///
/// **The step lands on the issuing window's stack** — `postComment`'s rule, and the one 13's
/// comments paragraph states outright: "the step lives on the card window's own stack ... so the
/// old stale-after-close skip scenario cannot arise". What reaches the board is the close step
/// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is
/// keeping alive (`registerCardSession`).
@discardableResult
public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> 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 }
// `postComment`'s anchoring, for its reason: the pair of anchors is the move, and the card's
// folder is resolved at apply time rather than baked into the step (13 ▸ Rules, ruled
// 2026-07-31).
let cardAnchor = HistoryAnchor.card(id)
let comment = HistoryAnchor.comment(commentID, inCard: id)
let trashed = HistoryAnchor.trashedComment(commentID, inCard: id)
let operation = WriteOperation.deleteComment(title: title)
registerStep(
HistoryPhrase.name(.delete, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(trashed), .absent(comment)],
redoExpects: [.present(comment), .absent(trashed)]
) { store in
try BoardWriter.restoreComment(
commentID,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.deleteComment(
at: try store.requiredFolder(for: comment, operation),
cardTitle: title
)
}
return true
}
// MARK: The purge, and the crash residue it leaves
/// **Empties one card's `comments/.trash/`** — **when undo no longer needs it** (01-storage-format.md
/// § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md ▸ Interaction with the trash).
///
/// The call site moved with that re-ruling and this method did not change: the window's close no
/// longer purges on its own, it hands this work to the coarse close step as that step's
/// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board
/// stack — undone-and-superseded, dropped, gone stale — or when the board session ends. On a git
/// board the step is never kept, so the retirement fires at the close flush, which is where the
/// purge always ran there ("purge rides the close flush"). The crash-residue sweep at the next
/// card-window open is unchanged.
///
/// One bracket, no step. Leftover comment steps on a 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 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`,
/// `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
)
}
}