Comments, phase 3 — search, the thread find, announcements, and a11y
Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -278,6 +278,69 @@ public final class EchoLedger: Sendable {
|
||||
receipts.withLock { $0[path]?.isHeal ?? false }
|
||||
}
|
||||
|
||||
/// **Which comments under one card the app itself just wrote** — the receipts, read through
|
||||
/// `CommentPath.classify`, and **retired on the way out**.
|
||||
///
|
||||
/// This is the comment half of "app-mediated echoes never announce" (10-accessibility.md ▸ Live
|
||||
/// board announcements), and it has to be a separate read because the board reload cannot do the
|
||||
/// job: comments are outside the snapshot, so `verdicts(from:to:diff:includingTrash:)` has no two
|
||||
/// pictures to compare and `Footprint.observations` deliberately skips comment paths (see its
|
||||
/// note). What *does* have two pictures is the card window, which re-reads its thread on every
|
||||
/// landed reload — so it asks this, diffs its thread, and speaks only about the changes nobody
|
||||
/// here vouched for (`CardComments.reload`).
|
||||
///
|
||||
/// ### Why classification rather than a prefix test
|
||||
///
|
||||
/// Because the answer has to distinguish three homes under one prefix, and `CommentPath` is the
|
||||
/// app's one reader of that distinction (01-storage-format.md ▸ Enhanced schema's path shape): a
|
||||
/// receipt under `comments/<uuid>/` vouches for a posted comment, one under `comments/.trash/<uuid>/`
|
||||
/// vouches for a *delete* of that comment — which is the same identity disappearing from the thread
|
||||
/// and must be just as silent — and one under `comments/.draft/` vouches for nothing in the thread
|
||||
/// at all, because a draft is not in it. A `hasPrefix` would fold all three together.
|
||||
///
|
||||
/// ### Retired, not merely read
|
||||
///
|
||||
/// Consumption is what keeps the rule "one write, one echo": the receipts are removed, so a second
|
||||
/// reload observing the same thread finds nothing vouching for it and would speak — which is
|
||||
/// correct, because by then the change is a second change. It is also what stops the ledger growing
|
||||
/// a receipt per comment write for the life of a session; nothing else ever collects them.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - cardFolder: the card's folder on disk. Receipts are keyed by absolute path, so this is what
|
||||
/// the sweep is rooted at.
|
||||
/// - cardPath: the same card's path relative to the board root (`<lane>/<card>`), which is the
|
||||
/// spelling `CommentPath.classify` reads. The two are handed in together rather than derived
|
||||
/// from each other because the store already holds both (`BoardStore.commentSubject`).
|
||||
/// - Returns: the identities of the posted and just-deleted comments the app wrote.
|
||||
func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set<ItemID> {
|
||||
let root = Self.key(cardFolder)
|
||||
let paths = receiptPaths(under: root)
|
||||
guard !paths.isEmpty else { return [] }
|
||||
|
||||
var vouched: Set<ItemID> = []
|
||||
var consumed: [String] = []
|
||||
for path in paths {
|
||||
let relative = cardPath + path.dropFirst(root.count)
|
||||
guard let comment = CommentPath.classify(relative) else { continue }
|
||||
consumed.append(path)
|
||||
if let id = comment.id { vouched.insert(id) }
|
||||
}
|
||||
guard !consumed.isEmpty else { return [] }
|
||||
receipts.withLock { store in
|
||||
for path in consumed {
|
||||
// A move pair is one fact under two keys, and both of a comment's ends are under this
|
||||
// card — so removing the observed key and then the pair's own two ends retires it once
|
||||
// and leaves nothing dangling at the other end.
|
||||
if case let .move(from, to) = store[path]?.receipt {
|
||||
store.removeValue(forKey: from)
|
||||
store.removeValue(forKey: to)
|
||||
}
|
||||
store.removeValue(forKey: path)
|
||||
}
|
||||
}
|
||||
return vouched
|
||||
}
|
||||
|
||||
/// Every path the ledger holds a receipt for strictly *below* `folder`.
|
||||
///
|
||||
/// Only a **card** may ask this. A lane's subtree is its cards' business and the board root's is
|
||||
@@ -503,6 +566,17 @@ extension EchoLedger {
|
||||
/// - A card's remaining receipts, resolved by the snapshot's attachment listing — a name the
|
||||
/// card still lists is `.present`, anything else (a removed attachment, a loose file that
|
||||
/// was relocated out) is `.absent`.
|
||||
///
|
||||
/// **A card's `comments/` is not observable here, and is therefore not observed** (added with
|
||||
/// comments, 01-storage-format.md ▸ Enhanced schema: "the board snapshot never loads comment
|
||||
/// content"). Two snapshots say nothing whatever about a thread — not that it changed, not
|
||||
/// that it did not, not even that it exists — so a comment receipt has no observation to be
|
||||
/// checked against, and the naive reading (anything not an attachment is `.absent`) would
|
||||
/// declare every comment the app itself just wrote *unsatisfied*: the very next foreign edit to
|
||||
/// the card's own `index.md` would then classify the card foreign twice over, and the user's
|
||||
/// own title edit would classify foreign once. Comment receipts are the **card window's** to
|
||||
/// read and retire (`EchoLedger.vouchedComments(inCard:cardPath:)`), which is the one place
|
||||
/// that does re-read a thread and can therefore say what happened to it.
|
||||
func observations(present: Bool, in ledger: EchoLedger) -> [String: EchoLedger.Observation] {
|
||||
var observations: [String: EchoLedger.Observation] = [
|
||||
folder: present ? .present : .absent,
|
||||
@@ -511,7 +585,9 @@ extension EchoLedger {
|
||||
]
|
||||
guard ownsItsSubtree else { return observations }
|
||||
let attachmentPrefix = folder + "/" + BoardWriter.attachmentsFolderName + "/"
|
||||
let commentPrefix = folder + "/" + IntegrityRules.commentsFolderName + "/"
|
||||
for path in ledger.receiptPaths(under: folder) where observations[path] == nil {
|
||||
guard !path.hasPrefix(commentPrefix) else { continue }
|
||||
let name = path.hasPrefix(attachmentPrefix) ? String(path.dropFirst(attachmentPrefix.count)) : nil
|
||||
observations[path] = present && name.map(attachments.contains) == true ? .present : .absent
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user