// The visual surface — a self-contained, interactive HTML explorer over the
// JSON the other commands emit. No server, no build step, no external resources:
// one file a teammate opens in a browser.
//
// Two ways to navigate, both feeding one detail pane:
// - Tree view — from pieces down (pattern · input · owned cells/streams),
// children named by their role.
// - Graph view — re-rootable from ANY node; click to select / re-root.
// The detail pane shows EVERY salient field for the selected entity: value
// (links clickable), schema, CFC (information-flow) labels, version history,
// resolved lineage (clickable), outgoing links, and module source. Entity ids
// are click-to-copy, and each piece/entity has a deep link into the live shell.
import { DID_PREFIX } from "@commonfabric/identity/did";
import type { SpaceDb } from "./db.ts";
import { type SpaceSummary, summarizeSpace } from "./queries.ts";
import { buildSpaceGraph, type SpaceGraph } from "./graph.ts";
import { spaceTimeline, type SpaceTimelineEntry } from "./timetravel.ts";
import { buildAllDetails, type EntityDetail } from "./detail.ts";
import type { ScanExtent } from "./model.ts";
import { visibleRevisionRows } from "./reconstruct.ts";
import {
listScopes,
type Participant,
type Scope,
type ScopeOverlay,
scopeOverlay,
spaceParticipants,
} from "./scopes.ts";
import {
contendedEntities,
type ContendedEntity,
entityConflicts,
} from "./conflicts.ts";
export interface InspectorBundle {
space: string;
generatedAt: string;
/** Base origin of the live shell, for deep links (`//`). */
liveBase: string;
summary: SpaceSummary;
details: EntityDetail[];
/** How far the entity scan behind `details` and `graph` reached. */
extent: ScanExtent;
graph: SpaceGraph;
timeline: SpaceTimelineEntry[];
/** Per-identity scopes present (space / user: / session::*). */
scopes: Scope[];
/** Per-entity scope overlays — only for cells with non-space/multi-scope state. */
overlays: ScopeOverlay[];
/** Identities that touched this space (committers + per-user/session owners). */
participants: Participant[];
/** Contested entities (≥2 writer sessions); multi-user ones carry stale reads. */
conflicts: ContendedEntity[];
}
/** Assemble everything the explorer needs from one space DB. */
export function buildInspectorBundle(
space: SpaceDb,
opts: {
branch?: string;
scope?: string;
generatedAt?: string;
liveBase?: string;
limit?: number;
} = {},
): InspectorBundle {
const branch = opts.branch ?? "";
const scope = opts.scope ?? "space";
const limit = opts.limit;
const did = (space.path.split("/").pop() ?? "").replace(/\.sqlite$/, "");
// Entities that carry per-user/session state (in a non-space scope, or in
// more than one scope) get a full scope overlay so the explorer can show the
// per-identity divergence the space-scope view hides. Discovered over the
// same branch-visible domain the tree and the scope list use — a child branch
// inherits its parent's per-user state, and a page that named a user scope
// while finding no cells in it would report the divergence as absent. Records
// rather than readable entities, so an entity deleted in one scope and live
// in another still reaches the overlay that exists to show that.
const scoped = new Map>();
for (const row of visibleRevisionRows(space, { branch })) {
const scopes = scoped.get(row.id) ?? new Set();
scopes.add(row.scope);
scoped.set(row.id, scopes);
}
const overlayIds = [...scoped.entries()]
.filter(([, scopes]) =>
scopes.size > 1 || [...scopes].some((scope) => scope !== "space")
)
.map(([id]) => id);
const overlays = overlayIds.map((id) => scopeOverlay(space, id, { branch }));
const listing = buildAllDetails(space, { branch, scope, limit });
return {
space: did,
generatedAt: opts.generatedAt ?? "",
liveBase: opts.liveBase ?? "",
summary: summarizeSpace(space),
details: listing.details,
extent: listing.extent,
graph: buildSpaceGraph(space, { branch, scope, limit }),
timeline: spaceTimeline(space, { branch, scope }),
scopes: listScopes(space, { branch }),
overlays,
participants: spaceParticipants(space, { branch }),
conflicts: buildConflicts(space, branch, scope),
};
}
/** Contested entities; attach stale-read analysis to the multi-user ones. */
function buildConflicts(
space: SpaceDb,
branch: string,
scope: string,
): ContendedEntity[] {
const STALE_READ_ANALYSIS_CAP = 60;
const contended = contendedEntities(space, { branch, scope, limit: 300 });
let analyzed = 0;
for (const c of contended) {
if (c.multiUser && analyzed < STALE_READ_ANALYSIS_CAP) {
analyzed++;
c.staleReads = entityConflicts(space, c.id, { branch, scope }).staleReads;
c.staleReadsAnalyzed = true;
}
// Beyond the cap, leave staleReadsAnalyzed falsy so the UI shows "not
// analyzed" rather than rendering an unanalyzed multi-user cell as clean.
}
return contended;
}
/** Embed JSON safely inside an HTML ` breakout) and the U+2028/U+2029 line
// terminators, which are legal in JSON strings but break a `