The phone reads the thread — comments arrive on the card's read view, posting and editing behind transactional sheets
The comment thread renders read-only under the card's body (author line, Markdown body through CardBodyView, read-only paperclip rows), read outside the snapshot and re-read on every walk landing via BoardSession.snapshotGeneration. Add Comment posts through the Mac composer's own draft-then-rename bracket — seeding from the card's single synced draft so a thought started on the Mac finishes here — and each row's context menu opens the same sheet in edit mode. Both commit on their trailing button or not at all: the phone's transactional model, dirty-Cancel confirmation and swipe-dismiss disabled while dirty included. UI-tested end to end with disk assertions. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -55,6 +55,13 @@ final class BoardSession {
|
||||
/// Cleared by the next walk that succeeds.
|
||||
private(set) var lastError: BoardSessionError?
|
||||
|
||||
/// Bumped every time a walk lands a snapshot. Comment threads live *outside* the snapshot —
|
||||
/// the walk stays O(cards) and never opens comment content, the Mac's own arrangement — so a
|
||||
/// screen rendering a thread keys its re-read on this: it moves after the screen's own write
|
||||
/// (whose `perform` awaits the reload) and after a foreign change the metadata query relayed,
|
||||
/// which are exactly the two moments a thread on screen can have gone stale.
|
||||
private(set) var snapshotGeneration = 0
|
||||
|
||||
/// The previous walk's parsed documents, offered to the next one (`BoardLoader.ParseMemo`). Pure
|
||||
/// optimization — it cannot change what a walk answers, only how many files it opens — and it is
|
||||
/// what keeps the reload after every single write from re-parsing the whole board on a phone.
|
||||
@@ -189,6 +196,7 @@ final class BoardSession {
|
||||
parseMemo = result.memo
|
||||
lastError = nil
|
||||
phase = .ready
|
||||
snapshotGeneration += 1
|
||||
cancelRetry()
|
||||
|
||||
case let .failed(error):
|
||||
@@ -212,6 +220,66 @@ final class BoardSession {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The comment thread
|
||||
|
||||
/// Reads one card's comment thread — the phone's counterpart to the Mac card window reading
|
||||
/// its own thread: window-scoped, outside the board snapshot, coordinated like every other
|
||||
/// read on the phone.
|
||||
///
|
||||
/// Total, like the read it wraps: `CommentThread.load` never refuses, and a coordinator
|
||||
/// refusal answers `.empty` after a log line rather than surfacing — a thread that momentarily
|
||||
/// will not read renders as "no comments" for one pass and re-reads on the next
|
||||
/// `snapshotGeneration` bump, not an error state the screen has to draw. The defects the read
|
||||
/// reports are dropped here: the phone has no heal engine to hand them to, and healing from
|
||||
/// two apps at once would be two writers racing over one defect.
|
||||
func loadCommentThread(laneID: ItemID, cardID: ItemID) async -> CommentThread {
|
||||
let root = rootURL
|
||||
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentThread, CoordinationFailure> in
|
||||
CoordinatedFileAccess.read(itemAt: root) { resolved in
|
||||
CommentThread.load(
|
||||
inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved),
|
||||
path: "\(laneID.rawValue)/\(cardID.rawValue)"
|
||||
)
|
||||
}
|
||||
}.value
|
||||
switch outcome {
|
||||
case let .success(thread):
|
||||
return thread
|
||||
case let .failure(failure):
|
||||
Self.logger.error("comment thread read failed: \(failure.description, privacy: .public)")
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the card's single draft — what seeds the composer. The draft is a folder inside the
|
||||
/// card, so it syncs like everything else: a comment started on the Mac is offered here to
|
||||
/// finish, exactly as designed ("the card's single draft", 01-storage-format.md § Enhanced
|
||||
/// schema). `nil` is "nothing to restore" — no draft, an unreadable one, or a coordinator
|
||||
/// refusal, none of which the composer can do anything about beyond starting empty.
|
||||
func loadCommentDraft(laneID: ItemID, cardID: ItemID) async -> CommentDraft? {
|
||||
let root = rootURL
|
||||
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentDraft?, CoordinationFailure> in
|
||||
CoordinatedFileAccess.read(itemAt: root) { resolved in
|
||||
CommentThread.loadDraft(inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved))
|
||||
}
|
||||
}.value
|
||||
switch outcome {
|
||||
case let .success(draft):
|
||||
return draft
|
||||
case let .failure(failure):
|
||||
Self.logger.error("comment draft read failed: \(failure.description, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `<root>/<lane>/<card>` — the derivation every comment call anchors on, spelled once and
|
||||
/// `nonisolated` so the detached reads above can use it against the coordinator-resolved root.
|
||||
nonisolated static func cardFolder(laneID: ItemID, cardID: ItemID, inRoot root: URL) -> URL {
|
||||
root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// Runs a closure of `BoardWriter` calls against this board and reloads.
|
||||
|
||||
Reference in New Issue
Block a user