Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptDisplayRows.swift
T

83 lines
4.1 KiB
Swift

import Foundation
import NucleicProtocol
/// One row of the transcript as *displayed*: a projected item, or a run of adjacent file-lock
/// notes gathered into a single row.
///
/// This layer exists for the Orchestra supervisor case. `AppStore` emits waiting/acquired/released
/// notes for every worker's edit attributed to the supervisor (the lock *holder*), but a supervisor
/// edits nothing itself, so those notes have no edit card for the projection to fold them onto and
/// each one arrives as its own standalone `.note`. A fan-out over a few dozen files therefore
/// renders as a few dozen near-identical rows — each with the transcript's full 14pt inter-row gap
/// — burying the conversation between them.
///
/// Gathering them here rather than in `TranscriptProjection` is deliberate: the projection is the
/// canonical, cross-platform fold, bound by the incremental projector's
/// `fold(prefix) ++ fold(tail) == fold(whole)` invariant and by the exhaustive `TranscriptItem.Kind`
/// switch that feeds the Windows renderer through `TranscriptProjectionABI`. Grouping purely for
/// readability belongs on the far side of both. Pure model code (Foundation + NucleicProtocol, no
/// SwiftUI) so it builds into the host-testable `NucleicRemoteProjection` target.
struct TranscriptDisplayRow: Identifiable, Equatable {
enum Kind: Equatable {
/// A projected row, passed through untouched.
case item(TranscriptItem)
/// Adjacent lock-lifecycle notes shown as one row — literal lines while the trail is
/// short, a collapsed `LockDigest` once it isn't. Kept in stream order, so the folded
/// view sees exactly what the individual rows would have said.
case lockRun([NoteLock])
}
let id: String
let seq: UInt64
let kind: Kind
}
enum TranscriptDisplay {
/// Gathers each maximal run of adjacent, path-carrying lock notes into one row, passing
/// everything else through unchanged.
///
/// Runs of one are gathered too: a lone lock moment then renders through the same
/// `LockLineRow` the folded card lines use, so every lock in the transcript reads the same way
/// whether it landed on an edit card or on its own.
///
/// One deliberate exclusion — a lock note carrying no structured `NoteLock` (the re-ground and
/// update-conflict notes) is prose the user may have to act on. It never joins a run, *and* it
/// breaks the run around it rather than being stepped over to splice the two halves together.
static func rows(_ items: [TranscriptItem]) -> [TranscriptDisplayRow] {
var rows: [TranscriptDisplayRow] = []
rows.reserveCapacity(items.count)
var run: [TranscriptItem] = []
func flushRun() {
defer { run.removeAll(keepingCapacity: true) }
guard let first = run.first else { return }
// Keyed on the run's first row, so a growing run extends the same row instead of
// being replaced by a differently-named one — which would drop the reader's expansion
// and jog the scroll anchor as a live fan-out streams its locks in.
rows.append(.init(id: "lockrun-\(first.id)", seq: first.seq,
kind: .lockRun(run.compactMap(structuredLock))))
}
for item in items {
if structuredLock(item) != nil {
run.append(item)
} else {
flushRun()
rows.append(.init(id: item.id, seq: item.seq, kind: .item(item)))
}
}
flushRun()
return rows
}
/// The structured lock a note carries, or nil for anything that isn't a foldable lock moment.
/// A lock note with no paths yields nothing to gather, so it stays an ordinary row; so does a
/// process-stall alert, which is a live control surface (Kill / Keep waiting), not a log line.
private static func structuredLock(_ item: TranscriptItem) -> NoteLock? {
guard case .note(_, _, let lockEvent, let lock, let processStall) = item.kind,
lockEvent, processStall == nil, let lock, !lock.paths.isEmpty else { return nil }
return lock
}
}