Files
lanework/Kanban/LiveStore/CommentPathShape.swift
rzen 9588f7b1f0 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
2026-07-30 21:30:22 -04:00

173 lines
8.7 KiB
Swift

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)))
}
}
// MARK: - CommentThreadChanges
/// **What happened to one card's thread between two reads** — `BoardDiff` one level down, and for the
/// same consumer: something has to say what changed before anything can say it out loud.
///
/// ### Why the thread and not the snapshot
///
/// `BoardDiff` compares two `BoardModel`s, and a `BoardModel` has never heard of a comment — that is
/// 01-storage-format.md ▸ Enhanced schema's stated exception to snapshot completeness, and it is why
/// foreign comment changes are "described by **path shape**" rather than by a diff. The path shape
/// answers *what kind* of change a path is (`CommentPath` above); this answers *which comments*
/// changed, from the only two pictures anything in the app actually holds — the thread the card
/// window was showing, and the thread it has just re-read (`CardComments.reload`).
///
/// ### Three buckets, and the one that is deliberately absent
///
/// Arrivals, edits and departures, which are the three the verb family names ("Comment on…", "Edit
/// comment on…", "Delete comment on…" — 06-history-undo.md's family per 01). **An attachment landing
/// on a comment is not a change here**: the comparison is the `index.md` document, so a file imported
/// into a comment's `attachments/` moves nothing in this value. That is deliberate rather than an
/// omission — 10-accessibility.md announces comment *arrivals*, the design's verb family is about the
/// comment's text, and a chip appearing is a visible change with no sentence written for it.
///
/// `.draft` and `comments/.trash/` never appear because they are not in a thread at all
/// (`CommentThread`'s own exclusion), so "a draft saved on another machine" and "a comment this window
/// deleted a moment ago" are both silent by construction rather than by a filter.
public struct CommentThreadChanges: Sendable, Equatable {
/// Comments in the new thread that were not in the old one.
public var arrived: [ItemID] = []
/// Comments in both, whose `index.md` differs.
public var edited: [ItemID] = []
/// Comments in the old thread that are not in the new one.
public var deleted: [ItemID] = []
public init(arrived: [ItemID] = [], edited: [ItemID] = [], deleted: [ItemID] = []) {
self.arrived = arrived
self.edited = edited
self.deleted = deleted
}
public var isEmpty: Bool {
arrived.isEmpty && edited.isEmpty && deleted.isEmpty
}
/// The comparison. Order follows the **new** thread for the two buckets it can (the display order
/// the loader produced) and the old thread for departures, so a caller naming "the" arrival names
/// the one a reader would reach first.
public static func between(_ old: CommentThread, _ new: CommentThread) -> CommentThreadChanges {
let older = Dictionary(old.comments.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let newer = Set(new.comments.map(\.id))
var changes = CommentThreadChanges()
for comment in new.comments {
guard let was = older[comment.id] else {
changes.arrived.append(comment.id)
continue
}
if was.document != comment.document {
changes.edited.append(comment.id)
}
}
for comment in old.comments where !newer.contains(comment.id) {
changes.deleted.append(comment.id)
}
return changes
}
/// This value with everything the `EchoLedger` vouched for removed — **the app's own writes never
/// announce as foreign** (10-accessibility.md ▸ Live board announcements), applied per comment.
///
/// It is a narrowing rather than a gate for `EchoVerdicts.foreign`'s reason: a reload can carry an
/// app-mediated edit *and* a foreign arrival at once (an agent files a comment in the same debounce
/// window as the user's inline save), and the honest sentence describes the half nobody vouched
/// for rather than all of it or none of it.
public func excluding(_ vouched: Set<ItemID>) -> CommentThreadChanges {
guard !vouched.isEmpty else { return self }
return CommentThreadChanges(
arrived: arrived.filter { !vouched.contains($0) },
edited: edited.filter { !vouched.contains($0) },
deleted: deleted.filter { !vouched.contains($0) }
)
}
}