feat(ui): the viewer's screens as @colbymchenry/codegraph-ui, behind one adapter (CG-61)
`ui/src` now builds two ways from one tree: the static app `codegraph ui` serves, and — via `svelte-package` — a Svelte library the Pro app imports. A forked component would be a second answer to the same question about the same graph, so there is no fork. Everything a screen knows arrives through a `GraphAdapter`: eleven methods answering the wire shapes verbatim, with `createHttpAdapter()` (the loopback JSON API) as the default and a host's in-process engine reads as the point. `lib/api.ts` became a one-line-per-call facade over it, which is why no call site in the views changed. The payload types moved to `lib/wire.ts` — no imports, no runtime — so a host can depend on the vocabulary alone. Two more seams and one guard: - `lib/navigation.ts` holds the href builders behind a `NavigationDriver`, so a host addresses its own URL space. The app's half — the hash parser and the live route, which attach window listeners at module scope — stays in `router.svelte.ts` and is pruned out of the package: rendering a Symbol view must not install a hash router in somebody else's application. - `lib/theme.css` carries the design tokens and maps Svelte Flow's `--xy-*` variables onto them, so a host never sees library defaults. Dark now also answers to a bare `[data-theme]`, which is how `<CodegraphUi theme>` themes a container rather than the document. - `scripts/check-ui-package.mjs` prunes the app's shell, resolves the extensionless specifiers svelte-package leaves behind, and asserts that nothing but `lib/adapter.js` reaches the network. The search box, its keyboard and its panel are one component now (`SearchPalette`), because splitting them is what breaks a palette. `__tests__/ui-package.test.ts` mounts the three screens from the package entry against a mock adapter in jsdom; it runs as a second vitest project so the `browser` resolve condition it needs cannot reach the engine's suites. Versioned with the engine. Prepared, not published: `private: true` is the guard and `pack-npm.sh` only packs a tarball under CODEGRAPH_PACK_UI=1.
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* The data seam: everything these components know about a project arrives
|
||||
* through one {@link GraphAdapter} (task CG-61).
|
||||
*
|
||||
* The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
|
||||
* the read-only JSON API over loopback. A host that already holds the graph —
|
||||
* CodeGraph Pro, which opens the index in-process — implements the same eleven
|
||||
* methods against its own reads and never makes an HTTP request. The components
|
||||
* cannot tell the difference, which is the whole point: one implementation of
|
||||
* the Symbol view, the Flow strip and the Map, drawn from whichever side of the
|
||||
* wire the caller happens to be on.
|
||||
*
|
||||
* ## The shapes are the contract, not the transport
|
||||
*
|
||||
* Every method answers a `Wire*` type from `./wire`, verbatim — the same object
|
||||
* `src/ui-server/api/` serialises. An adapter is therefore allowed to be a
|
||||
* `fetch`, a function call, a cache, or a fixture in a test; what it is not
|
||||
* allowed to do is invent a shape. `./wire` has no imports and no runtime, so a
|
||||
* host can depend on the vocabulary without depending on the viewer.
|
||||
*
|
||||
* ## One adapter per page
|
||||
*
|
||||
* The current adapter is module-level state, not Svelte context. Two reasons:
|
||||
* the pure model modules (`symbol-model`, `flow-model`, the palette store) are
|
||||
* plain TypeScript and cannot read a component's context, and a reader is
|
||||
* looking at one project at a time — the screens are a reading of *a* graph.
|
||||
* A host calls {@link setGraphAdapter} once before it renders, or wraps its
|
||||
* tree in `<CodegraphUi>`, which does it during initialisation.
|
||||
*/
|
||||
|
||||
import type {
|
||||
WireEntryPoints,
|
||||
WireFilePayload,
|
||||
WireFileCodePayload,
|
||||
WireFlowPayload,
|
||||
WireMapPayload,
|
||||
WireNodeRefs,
|
||||
WireRoutes,
|
||||
WireSearch,
|
||||
WireSource,
|
||||
WireStats,
|
||||
WireSymbolPayload,
|
||||
} from './wire';
|
||||
|
||||
/* ---------------------------------------------------------------- errors -- */
|
||||
|
||||
/**
|
||||
* An error the answering side described.
|
||||
*
|
||||
* The JSON API answers JSON for *every* outcome, including refusals, so a
|
||||
* non-2xx still carries a sentence worth showing — this is what puts the
|
||||
* server's own words on the screen instead of "Failed to fetch". An in-process
|
||||
* adapter should throw the same thing for the same reason: the screens read
|
||||
* `code` (`'not-found'` has its own empty state) and print `guidance`.
|
||||
*/
|
||||
export class ApiFailure extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly guidance: string | null;
|
||||
|
||||
constructor(status: number, code: string, message: string, guidance: string | null) {
|
||||
super(message);
|
||||
this.name = 'ApiFailure';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.guidance = guidance;
|
||||
}
|
||||
}
|
||||
|
||||
/** What `fail()` in `src/ui-server/api/respond.ts` sends. */
|
||||
interface ApiErrorBody {
|
||||
error?: string;
|
||||
code?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- requests -- */
|
||||
|
||||
export interface SourceRequest {
|
||||
file: string;
|
||||
/** 1-based, inclusive. */
|
||||
from: number;
|
||||
/** 1-based, inclusive. Omitted means "to the end of the file". */
|
||||
to?: number;
|
||||
/**
|
||||
* What to answer when the file has changed since it was indexed. The default
|
||||
* omits the slice — an indexed range over rewritten bytes can show a
|
||||
* different symbol's code under the right name. `'current'` asks for the
|
||||
* file's current lines instead, and the answer says `showing: 'current'` so
|
||||
* the caller can switch every line-anchored marking off over it.
|
||||
*/
|
||||
ondrift?: 'current';
|
||||
}
|
||||
|
||||
/**
|
||||
* A flow question. Exactly one of the three shapes is asked at a time:
|
||||
* `{ from, to }` ("how does X reach Y"), `{ symbols }` (`codegraph_explore`'s
|
||||
* own question, verbatim) or `{ trail }` (the hops the reader walked, as
|
||||
* `<dir><id>` strings).
|
||||
*/
|
||||
export interface FlowRequest {
|
||||
from?: string;
|
||||
to?: string;
|
||||
symbols?: string;
|
||||
trail?: readonly string[];
|
||||
}
|
||||
|
||||
export interface MapRequest {
|
||||
/** The subtree to aggregate — a monorepo's package. Null lets the graph pick. */
|
||||
root?: string | null;
|
||||
/** How many path segments under the root name a module. */
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface EntryPointsRequest {
|
||||
limit?: number;
|
||||
routes?: number;
|
||||
}
|
||||
|
||||
export interface RoutesRequest {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- live -- */
|
||||
|
||||
/**
|
||||
* The live channel's events, as the viewer's `live.svelte.ts` consumes them.
|
||||
*
|
||||
* An adapter that has no way to know the graph moved simply omits
|
||||
* {@link GraphAdapter.events}; the screens then render once and stay put, which
|
||||
* is the correct behaviour for a host that re-mounts them itself.
|
||||
*/
|
||||
export interface LiveHandlers {
|
||||
hello(event: unknown): void;
|
||||
changed(event: unknown): void;
|
||||
index(event: unknown): void;
|
||||
degraded(event: unknown): void;
|
||||
/** The connection dropped. The caller owns the backoff — never retry here. */
|
||||
error(): void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- adapter -- */
|
||||
|
||||
/**
|
||||
* Everything the components ask of a project.
|
||||
*
|
||||
* Seven of these are the reading surface named in the task — `search`, `node`,
|
||||
* `source`, `file`, `flow`, `map`, `routes` — and the other four are what the
|
||||
* screens around them need: `stats` (the blast bar's denominator and the top
|
||||
* bar's counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode`
|
||||
* (the whole-file view) and `entryPoints` (where a reader starts).
|
||||
*/
|
||||
export interface GraphAdapter {
|
||||
/** The index's own facts: counts, thresholds, the blast scale. */
|
||||
stats(signal?: AbortSignal): Promise<WireStats>;
|
||||
search(query: string, opts?: SearchRequest, signal?: AbortSignal): Promise<WireSearch>;
|
||||
/** One symbol with its rails, outline, tests, blast radius and drift verdict. */
|
||||
node(id: string, signal?: AbortSignal): Promise<WireSymbolPayload>;
|
||||
/** Names and locations for ids the caller already holds (the trail). */
|
||||
nodes(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs>;
|
||||
/** A slice of an indexed file, classified for highlighting. */
|
||||
source(request: SourceRequest, signal?: AbortSignal): Promise<WireSource>;
|
||||
/** One file: outline, import rails, dependencies, drift. */
|
||||
file(path: string, signal?: AbortSignal): Promise<WireFilePayload>;
|
||||
/** Everything the graph says about the LINES of one file (ports and arcs). */
|
||||
fileCode(path: string, signal?: AbortSignal): Promise<WireFileCodePayload>;
|
||||
/** The call path between symbols — `resolveNamedSymbolFlow`'s own answer. */
|
||||
flow(request: FlowRequest, signal?: AbortSignal): Promise<WireFlowPayload>;
|
||||
/** The repository at module granularity, layered. */
|
||||
map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
|
||||
/** The URL → handler map. */
|
||||
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
|
||||
/** Where a reader starts: routes, files that run something, tests, hubs. */
|
||||
entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
|
||||
/**
|
||||
* Subscribe to index/disk changes. Optional — a host without a live channel
|
||||
* omits it and nothing polls. Returns a function that closes the stream.
|
||||
*/
|
||||
events?(handlers: LiveHandlers): () => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ http impl -- */
|
||||
|
||||
export interface HttpAdapterOptions {
|
||||
/**
|
||||
* Where the API lives, ending in a slash. The default is relative (`''`),
|
||||
* which is what the CLI serves: the viewer is mounted at `/` and asks for
|
||||
* `api/stats`, so it survives being mounted under a sub-path.
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/** Injectable for tests and for a host that wraps `fetch` with auth. */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Ids and paths carry ':' and '/', so encode per segment and rejoin. */
|
||||
function encodePath(value: string): string {
|
||||
return value.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function query(params: URLSearchParams): string {
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The default adapter: the read-only JSON API `codegraph ui` serves.
|
||||
*
|
||||
* Every failure it can describe comes back as an {@link ApiFailure} carrying
|
||||
* the server's own sentence. The one it cannot describe — the server was
|
||||
* stopped while the tab stayed open — is given a sentence here, because a
|
||||
* network-level `TypeError` says nothing a reader can act on.
|
||||
*/
|
||||
export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapter {
|
||||
const base = options.baseUrl ?? '';
|
||||
const doFetch = options.fetch ?? ((...args: Parameters<typeof globalThis.fetch>) =>
|
||||
globalThis.fetch(...args));
|
||||
|
||||
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await doFetch(`${base}${path}`, {
|
||||
signal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
} catch (cause) {
|
||||
if (signal?.aborted) throw cause;
|
||||
throw new ApiFailure(
|
||||
0,
|
||||
'unreachable',
|
||||
'The codegraph ui server is not answering.',
|
||||
'It may have been stopped — restart it with `codegraph ui` and reload this page.'
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok) {
|
||||
const failure = (body as ApiErrorBody | null) ?? {};
|
||||
throw new ApiFailure(
|
||||
response.status,
|
||||
failure.code ?? 'error',
|
||||
failure.error ?? `The server answered ${response.status}.`,
|
||||
failure.hint ?? null
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
return {
|
||||
stats: (signal) => getJson<WireStats>('api/stats', signal),
|
||||
|
||||
search(text, opts = {}, signal) {
|
||||
const params = new URLSearchParams({ q: text });
|
||||
if (opts.limit) params.set('limit', String(opts.limit));
|
||||
return getJson<WireSearch>(`api/search${query(params)}`, signal);
|
||||
},
|
||||
|
||||
node: (id, signal) => getJson<WireSymbolPayload>(`api/node/${encodePath(id)}`, signal),
|
||||
|
||||
nodes(ids, signal) {
|
||||
const params = new URLSearchParams();
|
||||
// Repeated `id` params, never one comma-joined list: a node id can be a
|
||||
// file path and a file path can contain a comma.
|
||||
for (const id of ids) params.append('id', id);
|
||||
return getJson<WireNodeRefs>(`api/nodes${query(params)}`, signal);
|
||||
},
|
||||
|
||||
source(request, signal) {
|
||||
const params = new URLSearchParams({
|
||||
file: request.file,
|
||||
from: String(request.from),
|
||||
});
|
||||
// Absent `to` means "to the end of the file"; sending 0 for that would be
|
||||
// out of range, not a synonym.
|
||||
if (request.to !== undefined && request.to > 0) params.set('to', String(request.to));
|
||||
if (request.ondrift) params.set('ondrift', request.ondrift);
|
||||
return getJson<WireSource>(`api/source${query(params)}`, signal);
|
||||
},
|
||||
|
||||
file: (path, signal) => getJson<WireFilePayload>(`api/file/${encodePath(path)}`, signal),
|
||||
|
||||
fileCode: (path, signal) =>
|
||||
getJson<WireFileCodePayload>(`api/filecode/${encodePath(path)}`, signal),
|
||||
|
||||
flow(request, signal) {
|
||||
const params = new URLSearchParams();
|
||||
if (request.from) params.set('from', request.from);
|
||||
if (request.to) params.set('to', request.to);
|
||||
if (request.symbols) params.set('symbols', request.symbols);
|
||||
for (const hop of request.trail ?? []) params.append('hop', hop);
|
||||
return getJson<WireFlowPayload>(`api/flow${query(params)}`, signal);
|
||||
},
|
||||
|
||||
map(request = {}, signal) {
|
||||
const params = new URLSearchParams();
|
||||
if (request.root !== undefined && request.root !== null) params.set('root', request.root);
|
||||
if (request.depth) params.set('depth', String(request.depth));
|
||||
return getJson<WireMapPayload>(`api/map${query(params)}`, signal);
|
||||
},
|
||||
|
||||
routes(request = {}, signal) {
|
||||
const params = new URLSearchParams();
|
||||
if (request.limit) params.set('limit', String(request.limit));
|
||||
return getJson<WireRoutes>(`api/routes${query(params)}`, signal);
|
||||
},
|
||||
|
||||
entryPoints(request = {}, signal) {
|
||||
const params = new URLSearchParams();
|
||||
if (request.limit) params.set('limit', String(request.limit));
|
||||
if (request.routes) params.set('routes', String(request.routes));
|
||||
return getJson<WireEntryPoints>(`api/entrypoints${query(params)}`, signal);
|
||||
},
|
||||
|
||||
events(handlers) {
|
||||
if (typeof EventSource === 'undefined') return () => {};
|
||||
const stream = new EventSource(`${base}api/events`);
|
||||
stream.addEventListener('hello', (event) => handlers.hello(parse(event)));
|
||||
stream.addEventListener('changed', (event) => handlers.changed(parse(event)));
|
||||
stream.addEventListener('index', (event) => handlers.index(parse(event)));
|
||||
stream.addEventListener('degraded', (event) => handlers.degraded(parse(event)));
|
||||
// The backoff belongs to the caller, not here: an adapter that retried on
|
||||
// its own would race the one that already does and double the requests.
|
||||
stream.addEventListener('error', () => handlers.error());
|
||||
return () => stream.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parse(event: Event): unknown {
|
||||
const data = (event as MessageEvent<string>).data;
|
||||
if (typeof data !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- registry -- */
|
||||
|
||||
let current: GraphAdapter | null = null;
|
||||
|
||||
/**
|
||||
* Install the adapter every screen reads through.
|
||||
*
|
||||
* Call once, before anything renders. Passing `null` restores the default HTTP
|
||||
* adapter, which is what the standalone viewer runs on.
|
||||
*/
|
||||
export function setGraphAdapter(adapter: GraphAdapter | null): void {
|
||||
current = adapter;
|
||||
}
|
||||
|
||||
/** The installed adapter, defaulting to the HTTP one on first use. */
|
||||
export function getGraphAdapter(): GraphAdapter {
|
||||
if (current === null) current = createHttpAdapter();
|
||||
return current;
|
||||
}
|
||||
+55
-644
@@ -1,619 +1,51 @@
|
||||
/**
|
||||
* The viewer's side of the read-only JSON API (`src/ui-server/api/`, CG-42).
|
||||
* The screens' side of the graph API.
|
||||
*
|
||||
* The types below mirror the server's wire shapes rather than re-deriving
|
||||
* them: the API is versioned with the binary that serves it, so a field the
|
||||
* server stopped sending should break the type-check here, not surface as
|
||||
* `undefined` in a rail three screens later.
|
||||
* Every function here is one call on the installed {@link GraphAdapter}
|
||||
* (`adapter.ts`). Nothing in this file knows about HTTP: the standalone viewer
|
||||
* runs on `createHttpAdapter`, and a host that already holds the index — the
|
||||
* Pro app — installs its own and these same functions read from it.
|
||||
*
|
||||
* One rule for every call: the API answers JSON for *every* outcome, including
|
||||
* refusals. So a non-2xx still has a body worth reading, and `ApiFailure`
|
||||
* carries the server's own sentence instead of "Failed to fetch".
|
||||
* The wire types live in `./wire` (types only, no runtime) and are re-exported
|
||||
* here so that a screen can keep asking one module for both the call and the
|
||||
* shape it answers with.
|
||||
*/
|
||||
|
||||
import type { WireHighlight } from './highlight';
|
||||
import { getGraphAdapter } from './adapter';
|
||||
import type {
|
||||
WireEntryPoints,
|
||||
WireFilePayload,
|
||||
WireFileCodePayload,
|
||||
WireFlowPayload,
|
||||
WireMapPayload,
|
||||
WireNodeRefs,
|
||||
WireRoutes,
|
||||
WireSearch,
|
||||
WireSource,
|
||||
WireStats,
|
||||
WireSymbolPayload,
|
||||
} from './wire';
|
||||
|
||||
/* ---------------------------------------------------------------- shapes -- */
|
||||
|
||||
export type NodeKind = string;
|
||||
export type EdgeKind = string;
|
||||
|
||||
export interface WireNodeRef {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
name: string;
|
||||
qualifiedName: string;
|
||||
/** Project-relative, forward slashes on every platform. */
|
||||
file: string;
|
||||
line: number;
|
||||
endLine: number;
|
||||
language: string;
|
||||
signature?: string;
|
||||
exported?: boolean;
|
||||
/** Lives in a file that looks like test or fixture code. */
|
||||
test: boolean;
|
||||
}
|
||||
|
||||
export interface WireNodeDetail extends WireNodeRef {
|
||||
startColumn: number;
|
||||
endColumn: number;
|
||||
docstring?: string;
|
||||
visibility?: string;
|
||||
async?: boolean;
|
||||
static?: boolean;
|
||||
abstract?: boolean;
|
||||
decorators?: string[];
|
||||
typeParameters?: string[];
|
||||
returnType?: string;
|
||||
lines: number;
|
||||
}
|
||||
|
||||
export interface WireMember extends WireNodeRef {
|
||||
parentId: string;
|
||||
/** 1 = a direct member; 2 = a member of a member (a method inside a file's class). */
|
||||
depth: number;
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
}
|
||||
|
||||
export interface WireEdge {
|
||||
kind: EdgeKind;
|
||||
line?: number;
|
||||
col?: number;
|
||||
confidence?: number;
|
||||
resolvedBy?: string;
|
||||
provenance?: string;
|
||||
synthesizedBy?: string;
|
||||
via?: string;
|
||||
registeredAt?: string;
|
||||
valueRef?: boolean;
|
||||
}
|
||||
|
||||
/** Every edge between the focal symbol and ONE other symbol, as a single row. */
|
||||
export interface WireRelation {
|
||||
node: WireNodeRef;
|
||||
edgeKinds: EdgeKind[];
|
||||
edges: WireEdge[];
|
||||
edgeCount: number;
|
||||
/** Distinct call-site lines, ascending — what the gutter ports anchor to. */
|
||||
lines: number[];
|
||||
confidence: number | null;
|
||||
uncertain: boolean;
|
||||
synthesized: boolean;
|
||||
fanIn?: number;
|
||||
hub?: boolean;
|
||||
}
|
||||
|
||||
export interface WireList<T> {
|
||||
total: number;
|
||||
shown: number;
|
||||
truncated: boolean;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface WireTestSummary {
|
||||
reached: boolean;
|
||||
hops: number | null;
|
||||
fileCount: number;
|
||||
files: string[];
|
||||
/** False weakens the claim to "no test calls this directly" — see the server. */
|
||||
exhaustive: boolean;
|
||||
hopsSearched: number;
|
||||
}
|
||||
|
||||
export interface WireOutsideIndex {
|
||||
total: number;
|
||||
byKind: Record<string, number>;
|
||||
samples: Array<{ name: string; kind: string; line?: number; col?: number }>;
|
||||
}
|
||||
|
||||
export interface WireBlastSummary {
|
||||
direct: number;
|
||||
withinHops: number;
|
||||
hops: number;
|
||||
files: number;
|
||||
testFiles: number;
|
||||
routes: number;
|
||||
topFiles: Array<{ file: string; symbols: number; test: boolean }>;
|
||||
}
|
||||
|
||||
export interface WireSymbolPayload {
|
||||
node: WireNodeDetail;
|
||||
/** Outermost first: file, then module/class, then the symbol's own parent. */
|
||||
ancestors: WireNodeRef[];
|
||||
members: WireList<WireMember>;
|
||||
incoming: WireList<WireRelation>;
|
||||
outgoing: WireList<WireRelation>;
|
||||
typesUsed: WireRelation[];
|
||||
counts: {
|
||||
callers: number;
|
||||
callees: number;
|
||||
typesUsed: number;
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
members: number;
|
||||
hub: boolean;
|
||||
};
|
||||
tests: WireTestSummary;
|
||||
outsideIndex: WireOutsideIndex;
|
||||
blast: WireBlastSummary | null;
|
||||
/** The file changed on disk since the index — line ranges may be shifted. */
|
||||
drift: boolean;
|
||||
}
|
||||
|
||||
export interface WireSource {
|
||||
file: string;
|
||||
language: string;
|
||||
drift: boolean;
|
||||
/**
|
||||
* Which numbering `lines` belong to. `'indexed'` — the file matches the
|
||||
* index. `'current'` — it drifted and we asked for the bytes anyway
|
||||
* (`ondrift: 'current'`), so nothing the graph holds about this file lines up
|
||||
* with them. `'none'` — it drifted and no slice came back.
|
||||
*/
|
||||
showing: 'indexed' | 'current' | 'none';
|
||||
contentHash: string;
|
||||
indexedAt: number;
|
||||
generated: boolean;
|
||||
totalLines: number | null;
|
||||
from?: number;
|
||||
to?: number;
|
||||
/** Absent when the file drifted and `ondrift` was left at its default. */
|
||||
lines?: string[];
|
||||
truncated?: boolean;
|
||||
reason?: string;
|
||||
/**
|
||||
* The same lines, classified by the server's tree-sitter parse — one entry
|
||||
* per line, each a list of `[classId, text]` pairs indexed into `classes`.
|
||||
* Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
|
||||
* covers the file. See `lib/highlight.ts`.
|
||||
*/
|
||||
highlight?: WireHighlight;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- file view -- */
|
||||
|
||||
/** A row in the file outline — a symbol, its nesting and its edge counts. */
|
||||
export interface WireOutlineEntry extends WireNodeRef {
|
||||
/** Containing symbol within this file, or null for a top-level one. */
|
||||
parentId: string | null;
|
||||
/** Nesting depth from the top level of the file, starting at 0. */
|
||||
depth: number;
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
}
|
||||
|
||||
/** One file at the far end of an import rail, with the symbols the edges name. */
|
||||
export interface WireImportRow {
|
||||
file: string;
|
||||
test: boolean;
|
||||
symbols: Array<{ id: string; name: string; kind: string; line: number }>;
|
||||
symbolCount: number;
|
||||
}
|
||||
|
||||
export interface WireFilePayload {
|
||||
file: {
|
||||
path: string;
|
||||
language: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
indexedAt: number;
|
||||
contentHash: string;
|
||||
nodeCount: number;
|
||||
generated: boolean;
|
||||
test: boolean;
|
||||
errors: string[];
|
||||
/** The file node's own id, so the viewer can open the file AS a symbol. */
|
||||
id: string | null;
|
||||
};
|
||||
/** Calls made outside every definition — module-level code. */
|
||||
topLevel: { calls: number };
|
||||
/** The file changed on disk since it was indexed; the outline's lines shifted. */
|
||||
drift: boolean;
|
||||
outline: WireList<WireOutlineEntry>;
|
||||
/** `imports` edges only — a subset of `dependencies`, with symbol names. */
|
||||
imports: WireList<WireImportRow>;
|
||||
importedBy: WireList<WireImportRow>;
|
||||
/** Import statements that resolved to nothing indexed: packages, builtins. */
|
||||
unresolvedImports: Array<{ name: string; line: number }>;
|
||||
/** Every file this one reaches by any cross-file edge — `getFileDependencies`. */
|
||||
dependencies: string[];
|
||||
/** Every file that reaches into this one — `getFileDependents`. */
|
||||
dependents: string[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ whole-file source view -- */
|
||||
|
||||
/** A reference the resolver never landed: a gutter port with no destination. */
|
||||
export interface WireFileOutsideRef {
|
||||
line: number;
|
||||
col: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
|
||||
export interface WireFileCall {
|
||||
/** The symbol making the calls — the file node itself for top-level code. */
|
||||
ownerId: string;
|
||||
ownerLine: number;
|
||||
relation: WireRelation;
|
||||
}
|
||||
|
||||
export interface WireFileCodePayload {
|
||||
file: {
|
||||
path: string;
|
||||
language: string;
|
||||
size: number;
|
||||
indexedAt: number;
|
||||
contentHash: string;
|
||||
generated: boolean;
|
||||
test: boolean;
|
||||
errors: string[];
|
||||
id: string | null;
|
||||
/** Lines on disk now — the height of the scrolling document. */
|
||||
totalLines: number | null;
|
||||
};
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
outline: WireList<WireOutlineEntry>;
|
||||
calls: WireList<WireFileCall>;
|
||||
outside: WireList<WireFileOutsideRef>;
|
||||
/** Calls landing on a definition in this same file — the arc diagram's total. */
|
||||
intraFileCalls: number;
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
export interface WireBlastScale {
|
||||
maxDirect: number;
|
||||
maxWithinHops: number;
|
||||
hops: number;
|
||||
sampled: number;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- search palette -- */
|
||||
|
||||
/** How a result's text matched the query — the server's primary sort key. */
|
||||
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
|
||||
|
||||
export interface WireSearchResult extends WireNodeRef {
|
||||
matchKind: MatchKind;
|
||||
}
|
||||
|
||||
export interface WireSearchGroup {
|
||||
kind: NodeKind;
|
||||
count: number;
|
||||
items: WireSearchResult[];
|
||||
}
|
||||
|
||||
export interface WireSearch {
|
||||
query: string;
|
||||
/** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
|
||||
text: string;
|
||||
filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
|
||||
results: WireList<WireSearchResult>;
|
||||
/** Kind buckets in ranked order — flattening them reproduces the ranking. */
|
||||
groups: WireSearchGroup[];
|
||||
}
|
||||
|
||||
export interface WireNodeRefs {
|
||||
items: WireNodeRef[];
|
||||
/** Ids that name nothing in this index — a stale link, not an error. */
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- entry points -- */
|
||||
|
||||
export interface WireEntryRoute {
|
||||
/** The route node's name, verbatim: "POST /v1/users/{id}". */
|
||||
url: string;
|
||||
/** The verb, when the name leads with one. Null for a file-routed page. */
|
||||
method: string | null;
|
||||
/** The URL without the verb — the same string as `url` when there is none. */
|
||||
path: string;
|
||||
handler: string;
|
||||
handlerKind: string;
|
||||
/** Where the request is SERVED. */
|
||||
file: string;
|
||||
line: number;
|
||||
handlerId: string | null;
|
||||
/** Where the URL is REGISTERED — the router file, which is how routes group. */
|
||||
routeFile: string;
|
||||
routeLine: number;
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
export interface WireEntryFile extends WireNodeRef {
|
||||
/** Calls and instantiations made at the top level of the file. */
|
||||
calls: number;
|
||||
/** Distinct other files this one's symbols reach. */
|
||||
reaches: number;
|
||||
/** Other files reaching into this one. Zero means nothing imports it. */
|
||||
dependents: number;
|
||||
}
|
||||
|
||||
export interface WireEntryHub extends WireNodeRef {
|
||||
dependents: number;
|
||||
}
|
||||
|
||||
export interface WireEntryTest extends WireNodeRef {
|
||||
/** Distinct other files this test reaches — what it exercises. */
|
||||
reaches: number;
|
||||
/** References behind that reach. */
|
||||
refs: number;
|
||||
}
|
||||
|
||||
export interface WireEntryPoints {
|
||||
/** Frameworks the resolver detected — named in the Routes header. */
|
||||
frameworks: string[];
|
||||
routes: {
|
||||
routed: boolean;
|
||||
/** Every `route` node in the graph, resolved handler or not. */
|
||||
routeCount: number;
|
||||
items: WireList<WireEntryRoute>;
|
||||
};
|
||||
/** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
|
||||
files: WireList<WireEntryFile>;
|
||||
tests: WireList<WireEntryTest>;
|
||||
hubs: WireList<WireEntryHub>;
|
||||
index: { lastIndexedAt: number | null; files: number };
|
||||
timing: { elapsedMs: number; cached: boolean };
|
||||
}
|
||||
|
||||
export interface WireStats {
|
||||
project: { root: string; name: string };
|
||||
index: {
|
||||
state: string | null;
|
||||
lastIndexedAt: number | null;
|
||||
stale: boolean;
|
||||
version: string | null;
|
||||
extractionVersion: number | null;
|
||||
backend: string;
|
||||
journalMode: string;
|
||||
pendingReferences: number;
|
||||
generatedFiles: number;
|
||||
watching: boolean;
|
||||
watcherDegraded: boolean;
|
||||
};
|
||||
graph: {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
files: number;
|
||||
nodesByKind: Record<string, number>;
|
||||
edgesByKind: Record<string, number>;
|
||||
filesByLanguage: Record<string, number>;
|
||||
dbSizeBytes: number;
|
||||
walSizeBytes: number;
|
||||
};
|
||||
frameworks: string[];
|
||||
thresholds: { hub: number; uncertainBelow: number };
|
||||
blastScale: WireBlastScale;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- fetch -- */
|
||||
|
||||
/** An error the server described. `guidance` is its "what to do instead" line. */
|
||||
export class ApiFailure extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly guidance: string | null;
|
||||
|
||||
constructor(status: number, code: string, message: string, guidance: string | null) {
|
||||
super(message);
|
||||
this.name = 'ApiFailure';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.guidance = guidance;
|
||||
}
|
||||
}
|
||||
|
||||
/** What `fail()` in `src/ui-server/api/respond.ts` sends. */
|
||||
interface ApiErrorBody {
|
||||
error?: string;
|
||||
code?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(path, { signal, headers: { accept: 'application/json' } });
|
||||
} catch (cause) {
|
||||
if (signal?.aborted) throw cause;
|
||||
// The one failure the server cannot describe, because it never heard the
|
||||
// request: `codegraph ui` was stopped while the tab stayed open.
|
||||
throw new ApiFailure(
|
||||
0,
|
||||
'unreachable',
|
||||
'The codegraph ui server is not answering.',
|
||||
'It may have been stopped — restart it with `codegraph ui` and reload this page.'
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok) {
|
||||
const failure = (body as ApiErrorBody | null) ?? {};
|
||||
throw new ApiFailure(
|
||||
response.status,
|
||||
failure.code ?? 'error',
|
||||
failure.error ?? `The server answered ${response.status}.`,
|
||||
failure.hint ?? null
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- flow strip -- */
|
||||
|
||||
export interface WireFlowEdge extends WireEdge {
|
||||
/** The link's label: "calls", "via callback · registered at file:line". */
|
||||
label: string;
|
||||
/** This hop reads callee → caller — the reader stepped UP into it. */
|
||||
upward: boolean;
|
||||
/** Confidence below 0.6: the link is dashed `2 3`. */
|
||||
uncertain: boolean;
|
||||
/** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
|
||||
synthesized: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowSource {
|
||||
file: string;
|
||||
language: string;
|
||||
from: number;
|
||||
to: number;
|
||||
/** Absent when `drift` — a mis-sliced window is worse than an empty card. */
|
||||
lines?: string[];
|
||||
highlight?: WireHighlight;
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The call site a card is opened at — the identifier drawn as an accent link. */
|
||||
export interface WireFlowCallRef {
|
||||
line: number;
|
||||
col: number | null;
|
||||
name: string;
|
||||
targetId: string;
|
||||
/** The link points back at the previous card, not on to the next one. */
|
||||
backwards: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowHop {
|
||||
node: WireNodeRef;
|
||||
/** The edge from the PREVIOUS hop into this one; null on the first. */
|
||||
edge: WireFlowEdge | null;
|
||||
callRef: WireFlowCallRef | null;
|
||||
source: WireFlowSource | null;
|
||||
}
|
||||
|
||||
/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
|
||||
export interface WireBoundaryCandidate {
|
||||
node: WireNodeRef;
|
||||
display: string;
|
||||
named: boolean;
|
||||
}
|
||||
|
||||
/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
|
||||
export interface WireBoundarySite {
|
||||
form: string;
|
||||
label: string;
|
||||
snippet: string;
|
||||
line: number;
|
||||
key: string | null;
|
||||
keyIsType: boolean;
|
||||
moreSites: number;
|
||||
candidates: WireBoundaryCandidate[];
|
||||
candidateNote: string | null;
|
||||
}
|
||||
|
||||
export interface WireFlowContinuation {
|
||||
node: WireNodeRef;
|
||||
line: number | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/** Where the graph stops — the strip's end cap (design spec §3.5). */
|
||||
export interface WireFlowBoundary {
|
||||
node: WireNodeRef;
|
||||
sites: WireBoundarySite[];
|
||||
uncertain: WireList<WireFlowContinuation>;
|
||||
further: WireList<WireFlowContinuation>;
|
||||
missed: WireNodeRef[];
|
||||
}
|
||||
|
||||
export interface WireFlow {
|
||||
id: string;
|
||||
/** "execute → rowToFileRecord", for the header's flow picker. */
|
||||
label: string;
|
||||
hops: WireFlowHop[];
|
||||
/** Null on a flow that reaches everything it was asked about. */
|
||||
boundary: WireFlowBoundary | null;
|
||||
/** One card at the dispatch site, not a path: the answer ran out here. */
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowAmbiguity {
|
||||
token: string;
|
||||
chosen: WireNodeRef | null;
|
||||
others: WireNodeRef[];
|
||||
}
|
||||
|
||||
export interface WireFlowPayload {
|
||||
query: {
|
||||
kind: 'directed' | 'symbols' | 'trail';
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
symbols: string[];
|
||||
};
|
||||
flows: WireFlow[];
|
||||
ambiguous: WireFlowAmbiguity[];
|
||||
/** Tokens that named nothing in this index. */
|
||||
unresolved: string[];
|
||||
/** Why there is no flow, when there is none. */
|
||||
reason: string | null;
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- the map -- */
|
||||
|
||||
export interface WireMapModule {
|
||||
/** Directory path, the `(root files)` bucket, or a façade file's own path. */
|
||||
id: string;
|
||||
label: string;
|
||||
files: number;
|
||||
symbols: number;
|
||||
languages: Array<{ language: string; files: number }>;
|
||||
/** More than half its files are tests. */
|
||||
test: boolean;
|
||||
/** A single file kept out of the root bucket because it is the façade. */
|
||||
facade: boolean;
|
||||
/** Its files, capped — the side panel's list when the module is selected. */
|
||||
fileList: { total: number; shown: number; truncated: boolean; items: string[] };
|
||||
}
|
||||
|
||||
export interface WireMapLink {
|
||||
source: string;
|
||||
target: string;
|
||||
/** Every confident cross-module edge behind this link. */
|
||||
count: number;
|
||||
/**
|
||||
* The subset resolved through an import, a qualified name, an inheritance
|
||||
* clause or a typed receiver — what the layering trusts.
|
||||
*/
|
||||
declared: number;
|
||||
byKind: Array<{ kind: EdgeKind; count: number }>;
|
||||
topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
|
||||
}
|
||||
|
||||
export interface WireMapCycle {
|
||||
size: number;
|
||||
files: string[];
|
||||
modules: string[];
|
||||
}
|
||||
|
||||
export interface WireMapPayload {
|
||||
root: string;
|
||||
depth: number;
|
||||
roots: Array<{ root: string; label: string; files: number }>;
|
||||
modules: WireMapModule[];
|
||||
links: WireMapLink[];
|
||||
cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
|
||||
excluded: { uncertainEdges: number; confidenceBelow: number };
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number; cached: boolean };
|
||||
}
|
||||
export * from './wire';
|
||||
export { ApiFailure } from './adapter';
|
||||
export type {
|
||||
GraphAdapter,
|
||||
EntryPointsRequest,
|
||||
FlowRequest,
|
||||
HttpAdapterOptions,
|
||||
LiveHandlers,
|
||||
MapRequest,
|
||||
RoutesRequest,
|
||||
SearchRequest,
|
||||
SourceRequest,
|
||||
} from './adapter';
|
||||
|
||||
export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
|
||||
return getJson<WireStats>('api/stats', signal);
|
||||
return getGraphAdapter().stats(signal);
|
||||
}
|
||||
|
||||
export function fetchSymbol(id: string, signal?: AbortSignal): Promise<WireSymbolPayload> {
|
||||
// Ids carry ':' and '/' (`method:<hash>`, `file:src/mcp/tools.ts`); encode
|
||||
// per segment so the path stays readable and still round-trips.
|
||||
const encoded = id.split('/').map(encodeURIComponent).join('/');
|
||||
return getJson<WireSymbolPayload>(`api/node/${encoded}`, signal);
|
||||
return getGraphAdapter().node(id, signal);
|
||||
}
|
||||
|
||||
export function fetchSearch(
|
||||
@@ -621,34 +53,31 @@ export function fetchSearch(
|
||||
opts: { limit?: number } = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<WireSearch> {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (opts.limit) params.set('limit', String(opts.limit));
|
||||
return getJson<WireSearch>(`api/search?${params}`, signal);
|
||||
return getGraphAdapter().search(query, opts, signal);
|
||||
}
|
||||
|
||||
/** Names and locations for ids you already have — what the trail redraws with. */
|
||||
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
|
||||
const params = new URLSearchParams();
|
||||
for (const id of ids) params.append('id', id);
|
||||
return getJson<WireNodeRefs>(`api/nodes?${params}`, signal);
|
||||
return getGraphAdapter().nodes(ids, signal);
|
||||
}
|
||||
|
||||
export function fetchEntryPoints(
|
||||
opts: { limit?: number; routes?: number } = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<WireEntryPoints> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit) params.set('limit', String(opts.limit));
|
||||
if (opts.routes) params.set('routes', String(opts.routes));
|
||||
const query = params.toString();
|
||||
return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
|
||||
return getGraphAdapter().entryPoints(opts, signal);
|
||||
}
|
||||
|
||||
/** The URL → handler map. The palette reads routes through `fetchEntryPoints`. */
|
||||
export function fetchRoutes(
|
||||
opts: { limit?: number } = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<WireRoutes> {
|
||||
return getGraphAdapter().routes(opts, signal);
|
||||
}
|
||||
|
||||
export function fetchFile(path: string, signal?: AbortSignal): Promise<WireFilePayload> {
|
||||
// Paths carry '/'; encode per segment so `api/file/src/mcp/tools.ts` stays
|
||||
// readable and a segment with a reserved character still round-trips.
|
||||
const encoded = path.split('/').map(encodeURIComponent).join('/');
|
||||
return getJson<WireFilePayload>(`api/file/${encoded}`, signal);
|
||||
return getGraphAdapter().file(path, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -661,8 +90,7 @@ export function fetchFileCode(
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WireFileCodePayload> {
|
||||
const encoded = path.split('/').map(encodeURIComponent).join('/');
|
||||
return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
|
||||
return getGraphAdapter().fileCode(path, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -683,29 +111,19 @@ export function fetchSource(
|
||||
signal?: AbortSignal,
|
||||
ondrift?: 'current'
|
||||
): Promise<WireSource> {
|
||||
const params = new URLSearchParams({ file, from: String(from) });
|
||||
// `to` is 1-based on the wire and absent means "to the end of the file" —
|
||||
// sending 0 for that would be out of range, not a synonym.
|
||||
if (to > 0) params.set('to', String(to));
|
||||
if (ondrift) params.set('ondrift', ondrift);
|
||||
return getJson<WireSource>(`api/source?${params}`, signal);
|
||||
return getGraphAdapter().source({ file, from, to, ondrift }, signal);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The module map. `root` selects the subtree (a monorepo's package); `depth`
|
||||
* is how many path segments under it name a module. Omitting `root` lets the
|
||||
* server pick the repository's source directory.
|
||||
* adapter pick the repository's source directory.
|
||||
*/
|
||||
export function fetchMap(
|
||||
opts: { root?: string | null; depth?: number } = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<WireMapPayload> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
|
||||
if (opts.depth) params.set('depth', String(opts.depth));
|
||||
const query = params.toString();
|
||||
return getJson<WireMapPayload>(`api/map${query ? `?${query}` : ''}`, signal);
|
||||
return getGraphAdapter().map(opts, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -713,18 +131,11 @@ export function fetchMap(
|
||||
*
|
||||
* - `{ from, to }` — "how does X reach Y", from the search box.
|
||||
* - `{ symbols }` — `codegraph_explore`'s own question, verbatim.
|
||||
* - `{ trail }` — the hops the reader walked, as `<dir><id>` strings. Each one
|
||||
* is its own parameter, because a node id can be a file path and a file path
|
||||
* can contain a comma.
|
||||
* - `{ trail }` — the hops the reader walked, as `<dir><id>` strings.
|
||||
*/
|
||||
export function fetchFlow(
|
||||
spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] },
|
||||
signal?: AbortSignal
|
||||
): Promise<WireFlowPayload> {
|
||||
const params = new URLSearchParams();
|
||||
if (spec.from) params.set('from', spec.from);
|
||||
if (spec.to) params.set('to', spec.to);
|
||||
if (spec.symbols) params.set('symbols', spec.symbols);
|
||||
for (const hop of spec.trail ?? []) params.append('hop', hop);
|
||||
return getJson<WireFlowPayload>(`api/flow?${params}`, signal);
|
||||
return getGraphAdapter().flow(spec, signal);
|
||||
}
|
||||
|
||||
+119
-64
@@ -16,13 +16,17 @@
|
||||
*
|
||||
* ## Nothing polls, and nothing loops
|
||||
*
|
||||
* `EventSource` is the transport, but its own reconnect is not: left alone it
|
||||
* retries forever at a fixed interval, so a viewer left open against a stopped
|
||||
* The transport is `GraphAdapter.events` — an `EventSource` on `/api/events`
|
||||
* under `codegraph ui`, whatever a host already has under a host — but the
|
||||
* reconnect is NOT the transport's. Left to itself an `EventSource` retries
|
||||
* forever at a fixed interval, so a viewer left open against a stopped
|
||||
* `codegraph ui` becomes a request every three seconds until the tab is closed.
|
||||
* So each `error` closes the stream and schedules ONE reconnect on a backoff
|
||||
* that ends: after {@link MAX_ATTEMPTS} consecutive failures the connection
|
||||
* gives up and says so, and only a deliberate signal — the tab coming back to
|
||||
* the foreground, or the window regaining focus — starts it again.
|
||||
* the foreground, or the window regaining focus — starts it again. An adapter
|
||||
* with no `events` at all leaves every counter at zero and nothing connects;
|
||||
* a host can still move them by hand with `live.signal`.
|
||||
*
|
||||
* The same rule covers the server's own bad day: a `degraded` event means live
|
||||
* watching has stopped for good on that side. The client records it and shows
|
||||
@@ -31,6 +35,7 @@
|
||||
*/
|
||||
|
||||
import { untrack } from 'svelte';
|
||||
import { getGraphAdapter } from './adapter';
|
||||
|
||||
/* ----------------------------------------------------------- wire shapes -- */
|
||||
|
||||
@@ -58,6 +63,21 @@ export interface LiveChanged {
|
||||
at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host passes to `live.signal` — every field optional, because the
|
||||
* counters are what the screens read and the detail is only there for the
|
||||
* disk case, where "which files" decides whether a drift banner appears.
|
||||
*/
|
||||
export interface LiveSignalDetail {
|
||||
files?: string[];
|
||||
total?: number;
|
||||
truncated?: boolean;
|
||||
/** The change could not be described file by file — assume any file is hit. */
|
||||
scan?: boolean;
|
||||
index?: LiveIndexRevision;
|
||||
at?: number;
|
||||
}
|
||||
|
||||
export interface LiveIndexEvent {
|
||||
type: 'index';
|
||||
index: LiveIndexRevision;
|
||||
@@ -86,10 +106,13 @@ let diskTick = $state(0);
|
||||
let lastIndex = $state<LiveIndexEvent | null>(null);
|
||||
let lastChanged = $state<LiveChanged | null>(null);
|
||||
|
||||
let source: EventSource | null = null;
|
||||
/** Closes the current subscription, or null when there is none open. */
|
||||
let close: (() => void) | null = null;
|
||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
||||
let attempts = 0;
|
||||
let started = false;
|
||||
/** The installed adapter has no live channel. Nothing to connect, ever. */
|
||||
let unsupported = false;
|
||||
|
||||
/**
|
||||
* Ticks that arrived while the tab was in the background.
|
||||
@@ -138,71 +161,66 @@ function flushDeferred(): void {
|
||||
/* ------------------------------------------------------------ connection -- */
|
||||
|
||||
function open(): void {
|
||||
if (source || typeof EventSource === 'undefined') return;
|
||||
if (close !== null) return;
|
||||
if (retry !== null) {
|
||||
clearTimeout(retry);
|
||||
retry = null;
|
||||
}
|
||||
stopped = false;
|
||||
|
||||
const es = new EventSource('api/events');
|
||||
source = es;
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
connected = true;
|
||||
});
|
||||
|
||||
es.addEventListener('hello', (event) => {
|
||||
const hello = parse<LiveHello>(event);
|
||||
if (!hello) return;
|
||||
// A hello is the only proof the stream is really working: `open` fires on
|
||||
// the response headers, and a server that answered and then died would
|
||||
// otherwise reset the backoff it should have been paying.
|
||||
attempts = 0;
|
||||
connected = true;
|
||||
watching = hello.watching;
|
||||
degraded = hello.degraded;
|
||||
});
|
||||
|
||||
es.addEventListener('changed', (event) => {
|
||||
const changed = parse<LiveChanged>(event);
|
||||
if (changed) bumpDisk(changed);
|
||||
});
|
||||
|
||||
es.addEventListener('index', (event) => {
|
||||
const moved = parse<LiveIndexEvent>(event);
|
||||
if (moved) bumpIndex(moved);
|
||||
});
|
||||
|
||||
es.addEventListener('degraded', (event) => {
|
||||
const note = parse<{ reason: string }>(event);
|
||||
if (note) degraded = note.reason;
|
||||
});
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
connected = false;
|
||||
es.close();
|
||||
if (source === es) source = null;
|
||||
attempts += 1;
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
// Out of attempts. Nothing on a timer from here — the tab coming back to
|
||||
// the foreground is the only thing that tries again.
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
|
||||
retry = setTimeout(open, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function parse<T>(event: Event): T | null {
|
||||
const data = (event as MessageEvent<string>).data;
|
||||
if (typeof data !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(data) as T;
|
||||
} catch {
|
||||
return null;
|
||||
// The transport belongs to the adapter, not to this module: `codegraph ui`
|
||||
// answers it with an EventSource on `/api/events`, and a host that already
|
||||
// knows when its index moved answers it with whatever it already has. An
|
||||
// adapter with no live channel simply omits `events` — and then nothing here
|
||||
// ever runs, which is the correct behaviour and not a degraded one.
|
||||
const subscribe = getGraphAdapter().events;
|
||||
if (!subscribe) {
|
||||
unsupported = true;
|
||||
return;
|
||||
}
|
||||
|
||||
close = subscribe({
|
||||
hello(event) {
|
||||
const hello = event as LiveHello | null;
|
||||
if (!hello) return;
|
||||
// A hello is the only proof the stream is really working: a connection
|
||||
// opens on the response headers, and a server that answered and then
|
||||
// died would otherwise reset the backoff it should have been paying.
|
||||
attempts = 0;
|
||||
connected = true;
|
||||
watching = hello.watching;
|
||||
degraded = hello.degraded;
|
||||
},
|
||||
changed(event) {
|
||||
const changed = event as LiveChanged | null;
|
||||
if (changed) bumpDisk(changed);
|
||||
},
|
||||
index(event) {
|
||||
const moved = event as LiveIndexEvent | null;
|
||||
if (moved) bumpIndex(moved);
|
||||
},
|
||||
degraded(event) {
|
||||
const note = event as { reason: string } | null;
|
||||
if (note) degraded = note.reason;
|
||||
},
|
||||
error() {
|
||||
connected = false;
|
||||
// Take the closer before calling it: a transport that calls `error`
|
||||
// again from inside its own teardown must not re-enter this.
|
||||
const closer = close;
|
||||
close = null;
|
||||
closer?.();
|
||||
attempts += 1;
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
// Out of attempts. Nothing on a timer from here — the tab coming back
|
||||
// to the foreground is the only thing that tries again.
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
|
||||
retry = setTimeout(open, delay);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Connect, once, for the life of the page. */
|
||||
@@ -227,8 +245,8 @@ function start(): void {
|
||||
open();
|
||||
});
|
||||
window.addEventListener('pagehide', () => {
|
||||
source?.close();
|
||||
source = null;
|
||||
close?.();
|
||||
close = null;
|
||||
});
|
||||
|
||||
open();
|
||||
@@ -263,7 +281,44 @@ export const live = {
|
||||
get lastChanged(): LiveChanged | null {
|
||||
return lastChanged;
|
||||
},
|
||||
/** The installed adapter has no live channel — this page never was live. */
|
||||
get unsupported(): boolean {
|
||||
return unsupported;
|
||||
},
|
||||
start,
|
||||
|
||||
/**
|
||||
* Move a counter from outside.
|
||||
*
|
||||
* A host that learns about a sync through its own machinery — a websocket, a
|
||||
* webhook, a store it already owns — calls this instead of implementing
|
||||
* `GraphAdapter.events`, and every mounted screen refetches exactly as it
|
||||
* does under `codegraph ui`. It is the same code path the stream uses, so
|
||||
* there is no second way for a screen to go stale.
|
||||
*/
|
||||
signal(kind: 'index' | 'disk', detail: LiveSignalDetail = {}): void {
|
||||
if (kind === 'index') {
|
||||
bumpIndex({
|
||||
type: 'index',
|
||||
index: detail.index ?? { lastIndexedAt: null, files: 0 },
|
||||
files: detail.files ?? [],
|
||||
total: detail.total ?? detail.files?.length ?? 0,
|
||||
truncated: detail.truncated ?? false,
|
||||
at: detail.at ?? 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
bumpDisk({
|
||||
type: 'changed',
|
||||
files: detail.files ?? [],
|
||||
total: detail.total ?? detail.files?.length ?? 0,
|
||||
truncated: detail.truncated ?? false,
|
||||
// No named files and no scan flag would mean "nothing changed", which is
|
||||
// not what a caller asking for a disk tick means.
|
||||
scan: detail.scan ?? (detail.files === undefined || detail.files.length === 0),
|
||||
at: detail.at ?? 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* The navigation seam: where a click on a symbol, a file, a flow or the map
|
||||
* takes the reader (task CG-61).
|
||||
*
|
||||
* The standalone viewer is a hash app — `#/s/<id>`, `#/file/<path>`, `#/map` —
|
||||
* and that is the default driver below. A host embedding these components has
|
||||
* its own router and its own URL space (a review page, a PR, a workspace), so
|
||||
* it installs a {@link NavigationDriver} and every rail row, breadcrumb, chip
|
||||
* and card in the package addresses *its* app instead.
|
||||
*
|
||||
* Two reasons the components go through href builders rather than through a
|
||||
* single `onNavigate` callback:
|
||||
*
|
||||
* - **A row is a link.** Middle-click, cmd-click and "copy link address" are
|
||||
* how people read code, and they only work if the `<a>` really carries an
|
||||
* href. A callback-only design turns every row into a `<div>` with an
|
||||
* onclick, which is a worse screen.
|
||||
* - **The trail travels in the address.** The walk is part of the URL, so
|
||||
* building one is a thing the components must be able to do, not just ask
|
||||
* for.
|
||||
*
|
||||
* The live route (`router.svelte.ts`) is the *app's* half and is deliberately
|
||||
* not imported here: it attaches `hashchange`/`popstate` listeners at module
|
||||
* scope, which a host must never inherit just by rendering a Symbol view.
|
||||
*/
|
||||
|
||||
export interface SymbolHrefOptions {
|
||||
/** A line to highlight and scroll to in the destination. */
|
||||
line?: number;
|
||||
/** The encoded trail, so a reload or a shared link reproduces the walk. */
|
||||
trail?: string;
|
||||
}
|
||||
|
||||
export interface FileHrefOptions {
|
||||
line?: number;
|
||||
/** The whole-file source view rather than the outline. */
|
||||
source?: boolean;
|
||||
}
|
||||
|
||||
export interface MapHrefOptions {
|
||||
root?: string | null;
|
||||
depth?: number;
|
||||
tests?: boolean;
|
||||
}
|
||||
|
||||
export interface FlowHrefOptions {
|
||||
from?: string;
|
||||
to?: string;
|
||||
symbols?: string;
|
||||
trail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the components send the reader.
|
||||
*
|
||||
* Implement all of it: a half-implemented driver produces a screen where some
|
||||
* rows navigate the host and others silently jump to a hash the host does not
|
||||
* serve.
|
||||
*/
|
||||
export interface NavigationDriver {
|
||||
symbolHref(id: string, opts?: SymbolHrefOptions): string;
|
||||
fileHref(path: string, opts?: FileHrefOptions): string;
|
||||
mapHref(opts?: MapHrefOptions): string;
|
||||
flowHref(opts?: FlowHrefOptions): string;
|
||||
entryHref(): string;
|
||||
/** Go to an href this driver built. */
|
||||
navigate(href: string, opts?: { replace?: boolean }): void;
|
||||
/** Back one entry in the host's history. */
|
||||
back(): void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- the hash driver -- */
|
||||
|
||||
/**
|
||||
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
|
||||
* `<kind>:<relative/path>`, so they can contain both ':' and '/'. Encoding per
|
||||
* slash-separated segment keeps the URL readable (`#/file/src/mcp/tools.ts`)
|
||||
* and still round-trips a segment that itself contains a reserved character.
|
||||
*/
|
||||
function encodePath(value: string): string {
|
||||
return value.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function query(params: URLSearchParams): string {
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : '';
|
||||
}
|
||||
|
||||
/** The `codegraph ui` address space: the hash is the route. */
|
||||
export const hashNavigation: NavigationDriver = {
|
||||
symbolHref(id, opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.trail) params.set('t', opts.trail);
|
||||
if (opts.line) params.set('hl', String(opts.line));
|
||||
return `#/s/${encodePath(id)}${query(params)}`;
|
||||
},
|
||||
|
||||
fileHref(path, opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
// `src` before `hl` so the two file URLs a reader shares differ in their
|
||||
// first character after the path, not somewhere in the middle.
|
||||
if (opts.source) params.set('src', '1');
|
||||
if (opts.line) params.set('hl', String(opts.line));
|
||||
return `#/file/${encodePath(path)}${query(params)}`;
|
||||
},
|
||||
|
||||
mapHref(opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
|
||||
if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
|
||||
if (opts.tests) params.set('tests', '1');
|
||||
return `#/map${query(params)}`;
|
||||
},
|
||||
|
||||
flowHref(opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.from) params.set('from', opts.from);
|
||||
if (opts.to) params.set('to', opts.to);
|
||||
if (opts.symbols) params.set('symbols', opts.symbols);
|
||||
// `t`, not `trail`: the trail already travels under that name everywhere
|
||||
// else, and a flow read from one is the same walk under a different lens.
|
||||
if (opts.trail) params.set('t', opts.trail);
|
||||
return `#/flow${query(params)}`;
|
||||
},
|
||||
|
||||
entryHref() {
|
||||
return '#/entry';
|
||||
},
|
||||
|
||||
navigate(href, opts = {}) {
|
||||
const target = href.startsWith('#') ? href : `#${href}`;
|
||||
if (opts.replace) {
|
||||
history.replaceState(history.state, '', target);
|
||||
onHashWritten();
|
||||
return;
|
||||
}
|
||||
if (location.hash === target) return;
|
||||
location.hash = target;
|
||||
// hashchange fires asynchronously; the sync is idempotent, so calling it
|
||||
// now keeps a navigate() immediately followed by a read consistent.
|
||||
onHashWritten();
|
||||
},
|
||||
|
||||
back() {
|
||||
history.back();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The live route's re-read hook, registered by `router.svelte.ts`.
|
||||
*
|
||||
* The driver has to tell the route store that the hash moved, and the store
|
||||
* has to attach window listeners — but a component importing the driver must
|
||||
* not drag those listeners in. So the dependency runs this way round: the store
|
||||
* registers itself with the driver, and a page that never loads the store gets
|
||||
* a driver that simply writes the hash.
|
||||
*/
|
||||
let onHashWritten: () => void = () => {};
|
||||
|
||||
export function registerHashSync(sync: () => void): void {
|
||||
onHashWritten = sync;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- registry -- */
|
||||
|
||||
let driver: NavigationDriver = hashNavigation;
|
||||
|
||||
/**
|
||||
* Install the driver every link in the package is built with.
|
||||
*
|
||||
* Call once, before anything renders. Passing `null` restores the hash driver.
|
||||
*/
|
||||
export function setNavigationDriver(next: NavigationDriver | null): void {
|
||||
driver = next ?? hashNavigation;
|
||||
}
|
||||
|
||||
export function getNavigationDriver(): NavigationDriver {
|
||||
return driver;
|
||||
}
|
||||
|
||||
/* --------------------------- what the components actually call ----------- */
|
||||
|
||||
export function symbolHref(id: string, opts: SymbolHrefOptions = {}): string {
|
||||
return driver.symbolHref(id, opts);
|
||||
}
|
||||
|
||||
export function fileHref(path: string, opts: FileHrefOptions = {}): string {
|
||||
return driver.fileHref(path, opts);
|
||||
}
|
||||
|
||||
export function mapHref(opts: MapHrefOptions = {}): string {
|
||||
return driver.mapHref(opts);
|
||||
}
|
||||
|
||||
export function flowHref(opts: FlowHrefOptions = {}): string {
|
||||
return driver.flowHref(opts);
|
||||
}
|
||||
|
||||
export function entryHref(): string {
|
||||
return driver.entryHref();
|
||||
}
|
||||
|
||||
export function navigate(href: string, opts: { replace?: boolean } = {}): void {
|
||||
driver.navigate(href, opts);
|
||||
}
|
||||
|
||||
export function back(): void {
|
||||
driver.back();
|
||||
}
|
||||
+33
-74
@@ -18,8 +18,37 @@
|
||||
* encoded *per slash-separated segment* and rejoined on the way out: the URL
|
||||
* stays readable (`#/file/src/mcp/tools.ts`) and still round-trips a segment
|
||||
* that itself contains a reserved character.
|
||||
*
|
||||
* This module is the APP's half — it parses the hash and holds the live route,
|
||||
* and it attaches window listeners to do it. The href builders and `navigate`
|
||||
* live in `./navigation`, behind a driver a host can replace, and the shared
|
||||
* components import them from there: rendering a Symbol view inside somebody
|
||||
* else's app must not install a hash router in it. They are re-exported below
|
||||
* so this file stays the app's one-stop import.
|
||||
*/
|
||||
|
||||
import { registerHashSync } from './navigation';
|
||||
|
||||
export {
|
||||
back,
|
||||
entryHref,
|
||||
fileHref,
|
||||
flowHref,
|
||||
getNavigationDriver,
|
||||
hashNavigation,
|
||||
mapHref,
|
||||
navigate,
|
||||
setNavigationDriver,
|
||||
symbolHref,
|
||||
} from './navigation';
|
||||
export type {
|
||||
FileHrefOptions,
|
||||
FlowHrefOptions,
|
||||
MapHrefOptions,
|
||||
NavigationDriver,
|
||||
SymbolHrefOptions,
|
||||
} from './navigation';
|
||||
|
||||
export type Route =
|
||||
| { view: 'home' }
|
||||
| { view: 'symbol'; id: string; line: number | null }
|
||||
@@ -63,10 +92,6 @@ function decodeSegment(segment: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function encodePath(value: string): string {
|
||||
return value.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function parseLine(params: URLSearchParams): number | null {
|
||||
const raw = params.get('hl');
|
||||
if (raw === null) return null;
|
||||
@@ -120,58 +145,6 @@ export function parseHash(hash: string): RouterLocation {
|
||||
return { route, params, raw };
|
||||
}
|
||||
|
||||
/* ---------- href builders (the only place hashes are assembled) ---------- */
|
||||
|
||||
export function symbolHref(id: string, opts: { line?: number; trail?: string } = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.trail) params.set('t', opts.trail);
|
||||
if (opts.line) params.set('hl', String(opts.line));
|
||||
const query = params.toString();
|
||||
return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function fileHref(
|
||||
path: string,
|
||||
opts: { line?: number; source?: boolean } = {}
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
// `src` before `hl` so the two file URLs a reader shares differ in their
|
||||
// first character after the path, not somewhere in the middle.
|
||||
if (opts.source) params.set('src', '1');
|
||||
if (opts.line) params.set('hl', String(opts.line));
|
||||
const query = params.toString();
|
||||
return `#/file/${encodePath(path)}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function mapHref(
|
||||
opts: { root?: string | null; depth?: number; tests?: boolean } = {}
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
|
||||
if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
|
||||
if (opts.tests) params.set('tests', '1');
|
||||
const query = params.toString();
|
||||
return `#/map${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function entryHref(): string {
|
||||
return '#/entry';
|
||||
}
|
||||
|
||||
export function flowHref(
|
||||
opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.from) params.set('from', opts.from);
|
||||
if (opts.to) params.set('to', opts.to);
|
||||
if (opts.symbols) params.set('symbols', opts.symbols);
|
||||
// `t`, not `trail`: the trail already travels under that name everywhere
|
||||
// else, and a flow read from one is the same walk under a different lens.
|
||||
if (opts.trail) params.set('t', opts.trail);
|
||||
const query = params.toString();
|
||||
return `#/flow${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
/* ---------- the live route ---------- */
|
||||
|
||||
const initial = parseHash(typeof location === 'undefined' ? '' : location.hash);
|
||||
@@ -187,6 +160,10 @@ if (typeof window !== 'undefined') {
|
||||
// popstate too: `navigate(…, { replace: true })` and history.back() across
|
||||
// a replaced entry both move the hash without firing hashchange.
|
||||
window.addEventListener('popstate', sync);
|
||||
// The hash driver writes `location.hash` directly; this is how it tells the
|
||||
// route store to re-read. Registered here rather than imported there, so a
|
||||
// host that never loads this module gets no window listeners at all.
|
||||
registerHashSync(sync);
|
||||
}
|
||||
|
||||
export const router = {
|
||||
@@ -200,21 +177,3 @@ export const router = {
|
||||
return current.params;
|
||||
},
|
||||
};
|
||||
|
||||
export function navigate(href: string, opts: { replace?: boolean } = {}): void {
|
||||
const target = href.startsWith('#') ? href : `#${href}`;
|
||||
if (opts.replace) {
|
||||
history.replaceState(history.state, '', target);
|
||||
sync();
|
||||
return;
|
||||
}
|
||||
if (location.hash === target) return;
|
||||
location.hash = target;
|
||||
// hashchange fires asynchronously; sync() is idempotent, so calling it now
|
||||
// keeps a navigate() immediately followed by a read consistent.
|
||||
sync();
|
||||
}
|
||||
|
||||
export function back(): void {
|
||||
history.back();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/* =====================================================================
|
||||
@colbymchenry/codegraph-ui — design tokens
|
||||
|
||||
The engine's paper/ink editorial system, as specified in
|
||||
docs/design/codegraph-ui-design-spec.md §2.2: flat, hairline rules,
|
||||
square corners everywhere, no shadows, no gradients, sentence case,
|
||||
one oxblood accent, one amber (the "untested" badge).
|
||||
|
||||
This file is the package's whole theming surface. Import it once and
|
||||
override any variable on a narrower selector — the components read
|
||||
nothing else. What is NOT themable is geometry: 34px rail rows, the
|
||||
300/320px rails, the 20px code line. Those are measured against each
|
||||
other by the Symbol view's layout pass, and a host that moves one of
|
||||
them moves a callee row away from the line it points at.
|
||||
|
||||
Colour and type only. Every token is defined once on the bare :root
|
||||
below and only REDEFINED in the two dark blocks, so a host that sets
|
||||
`--accent` on its own container gets it in both schemes.
|
||||
===================================================================== */
|
||||
|
||||
/* ---------- tokens: light / paper (the bare :root set) ---------- */
|
||||
:root {
|
||||
--paper: #f7f6f2;
|
||||
--paper-2: #f1efe8;
|
||||
--press: #e8e6dd;
|
||||
--press-2: #dedbd0;
|
||||
--ink: #16150f;
|
||||
--ink-2: #56544a;
|
||||
--ink-3: #87847a;
|
||||
--ink-4: #b4b1a5;
|
||||
--rule: #16150f;
|
||||
--rule-soft: #d6d3c8;
|
||||
--rule-faint: #e6e3d9;
|
||||
--accent: #7a2230;
|
||||
--accent-ink: #5e1a25;
|
||||
--accent-soft: #f0e3e5;
|
||||
--accent-line: #d9b3b9;
|
||||
--amber: #8a5a0b;
|
||||
--amber-soft: #f3e9d2;
|
||||
|
||||
/* The one code colour that is not a plain re-use of the ink ramp.
|
||||
The spec asks for comments at --ink-3; measured against --paper that
|
||||
is 3.46:1 and against the hot-line tint --accent-soft it is 3.00:1,
|
||||
both under the 4.5:1 an AA reading of 12.5px body text needs. This is
|
||||
the smallest step DOWN the same warm-grey ramp that clears 4.5:1 on
|
||||
all three backgrounds a code line can have (paper 5.23, paper-2 4.92,
|
||||
accent-soft 4.53) while staying quieter than --ink-2, which strings
|
||||
and numbers use — so the recession order the spec describes is
|
||||
unchanged, only legible. Dark needed the mirror step UP (4.51 on
|
||||
accent-soft, where --ink-3 was 4.10). */
|
||||
--code-comment: #6a675d;
|
||||
|
||||
--sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--code-size: 12.5px;
|
||||
--code-lh: 20px;
|
||||
|
||||
/* App-shell geometry, shared by the grid and by anything that has to
|
||||
offset itself under the bars (sticky rail headers, SVG overlays). */
|
||||
--topbar-h: 48px;
|
||||
--trailbar-h: 34px;
|
||||
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* ---------- tokens: dark / ink ----------
|
||||
Every colour is defined on the bare :root above; these blocks only
|
||||
redefine. `:not([data-theme="light"])` lets an explicit light choice
|
||||
win over the OS preference. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--paper: #16150f;
|
||||
--paper-2: #1c1a14;
|
||||
--press: #23211a;
|
||||
--press-2: #2c2a22;
|
||||
--ink: #f3f1ea;
|
||||
--ink-2: #b8b5a8;
|
||||
--ink-3: #87847a;
|
||||
--ink-4: #5d5b52;
|
||||
--rule: #f3f1ea;
|
||||
--rule-soft: #34322a;
|
||||
--rule-faint: #26241d;
|
||||
--accent: #d48b96;
|
||||
--accent-ink: #e5a5ae;
|
||||
--accent-soft: #33201f;
|
||||
--accent-line: #6b3a42;
|
||||
--amber: #d9a94a;
|
||||
--amber-soft: #2e2716;
|
||||
--code-comment: #8e8b81;
|
||||
}
|
||||
}
|
||||
|
||||
/* An explicit choice, wherever it is made. `:root[data-theme]` is the viewer's
|
||||
own switch; the bare attribute selector is what `<CodegraphUi theme="dark">`
|
||||
sets on its wrapper — custom properties inherit, so redefining them on a
|
||||
container re-themes that subtree without touching :root. A host can therefore
|
||||
put a light reader inside a dark application, and the reverse. */
|
||||
:root[data-theme='dark'],
|
||||
[data-theme='dark'] {
|
||||
--paper: #16150f;
|
||||
--paper-2: #1c1a14;
|
||||
--press: #23211a;
|
||||
--press-2: #2c2a22;
|
||||
--ink: #f3f1ea;
|
||||
--ink-2: #b8b5a8;
|
||||
--ink-3: #87847a;
|
||||
--ink-4: #5d5b52;
|
||||
--rule: #f3f1ea;
|
||||
--rule-soft: #34322a;
|
||||
--rule-faint: #26241d;
|
||||
--accent: #d48b96;
|
||||
--accent-ink: #e5a5ae;
|
||||
--accent-soft: #33201f;
|
||||
--accent-line: #6b3a42;
|
||||
--amber: #d9a94a;
|
||||
--amber-soft: #2e2716;
|
||||
--code-comment: #8e8b81;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* The light values again, for a container that asks for light inside a page
|
||||
the OS is painting dark. `:root[data-theme='light']` needs no block — the
|
||||
media query above already excludes it. */
|
||||
[data-theme='light'] {
|
||||
--paper: #f7f6f2;
|
||||
--paper-2: #f1efe8;
|
||||
--press: #e8e6dd;
|
||||
--press-2: #dedbd0;
|
||||
--ink: #16150f;
|
||||
--ink-2: #56544a;
|
||||
--ink-3: #87847a;
|
||||
--ink-4: #b4b1a5;
|
||||
--rule: #16150f;
|
||||
--rule-soft: #d6d3c8;
|
||||
--rule-faint: #e6e3d9;
|
||||
--accent: #7a2230;
|
||||
--accent-ink: #5e1a25;
|
||||
--accent-soft: #f0e3e5;
|
||||
--accent-line: #d9b3b9;
|
||||
--amber: #8a5a0b;
|
||||
--amber-soft: #f3e9d2;
|
||||
--code-comment: #6a675d;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---------- Svelte Flow ----------
|
||||
The Map and the Flow strip draw on @xyflow/svelte, which ships its own
|
||||
blue-on-white palette in `--xy-*` variables. Mapping them onto the ink
|
||||
ramp here — rather than in each canvas — is what stops a host from
|
||||
seeing library defaults in the gaps our custom node and edge
|
||||
components do not paint: the pane behind the cards, the controls, the
|
||||
minimap, the selection ring, the attribution.
|
||||
|
||||
Set on :root so it reaches the portalled panes too. A host that wants
|
||||
the library's own look overrides these after importing this file. */
|
||||
:root {
|
||||
--xy-background-color: var(--paper);
|
||||
--xy-background-pattern-color: var(--rule-faint);
|
||||
|
||||
--xy-edge-stroke: var(--ink-3);
|
||||
--xy-edge-stroke-selected: var(--accent);
|
||||
--xy-edge-stroke-width: 1;
|
||||
--xy-connectionline-stroke: var(--ink-3);
|
||||
|
||||
--xy-node-color: var(--ink);
|
||||
--xy-node-background-color: var(--paper);
|
||||
--xy-node-border: 1px solid var(--rule-soft);
|
||||
--xy-node-boxshadow-hover: none;
|
||||
--xy-node-boxshadow-selected: none;
|
||||
--xy-selection-background-color: var(--accent-soft);
|
||||
--xy-selection-border: 1px solid var(--accent-line);
|
||||
|
||||
--xy-handle-background-color: transparent;
|
||||
--xy-handle-border-color: transparent;
|
||||
|
||||
--xy-controls-button-background-color: var(--paper);
|
||||
--xy-controls-button-background-color-hover: var(--press);
|
||||
--xy-controls-button-color: var(--ink-2);
|
||||
--xy-controls-button-color-hover: var(--ink);
|
||||
--xy-controls-button-border-color: var(--rule-soft);
|
||||
--xy-controls-box-shadow: none;
|
||||
|
||||
--xy-minimap-background-color: var(--paper-2);
|
||||
--xy-minimap-mask-background-color: var(--paper);
|
||||
--xy-minimap-node-background-color: var(--rule-soft);
|
||||
--xy-minimap-node-stroke-color: var(--ink-3);
|
||||
|
||||
--xy-attribution-background-color: transparent;
|
||||
|
||||
--xy-resize-background-color: var(--accent);
|
||||
--xy-error-color: var(--accent);
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
* link reproduces the walk rather than starting a fresh one at the same symbol.
|
||||
*/
|
||||
|
||||
import { fileHref, navigate, symbolHref } from './router.svelte';
|
||||
import { fileHref, navigate, symbolHref } from './navigation';
|
||||
import { encodeTrail, trail, type HopDirection } from './trail.svelte';
|
||||
import type { EntryTarget } from './entry-model';
|
||||
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* The wire shapes of the graph API — types only, no runtime.
|
||||
*
|
||||
* These mirror the server's payloads (`src/ui-server/api/`, CG-42) rather than
|
||||
* re-deriving them: the API is versioned with the binary that serves it, so a
|
||||
* field the server stopped sending should break the type-check here, not
|
||||
* surface as `undefined` in a rail three screens later.
|
||||
*
|
||||
* They are also the vocabulary of {@link GraphAdapter} (`adapter.ts`): a host
|
||||
* embedding these components answers in exactly these shapes, whether it is
|
||||
* reading them over HTTP from `codegraph ui` or building them in-process from
|
||||
* its own engine. Keeping them in a file with no imports and no side effects is
|
||||
* what lets a host depend on the vocabulary without pulling in the transport.
|
||||
*/
|
||||
|
||||
import type { WireHighlight } from './highlight';
|
||||
|
||||
/* ---------------------------------------------------------------- shapes -- */
|
||||
|
||||
export type NodeKind = string;
|
||||
export type EdgeKind = string;
|
||||
|
||||
export interface WireNodeRef {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
name: string;
|
||||
qualifiedName: string;
|
||||
/** Project-relative, forward slashes on every platform. */
|
||||
file: string;
|
||||
line: number;
|
||||
endLine: number;
|
||||
language: string;
|
||||
signature?: string;
|
||||
exported?: boolean;
|
||||
/** Lives in a file that looks like test or fixture code. */
|
||||
test: boolean;
|
||||
}
|
||||
|
||||
export interface WireNodeDetail extends WireNodeRef {
|
||||
startColumn: number;
|
||||
endColumn: number;
|
||||
docstring?: string;
|
||||
visibility?: string;
|
||||
async?: boolean;
|
||||
static?: boolean;
|
||||
abstract?: boolean;
|
||||
decorators?: string[];
|
||||
typeParameters?: string[];
|
||||
returnType?: string;
|
||||
lines: number;
|
||||
}
|
||||
|
||||
export interface WireMember extends WireNodeRef {
|
||||
parentId: string;
|
||||
/** 1 = a direct member; 2 = a member of a member (a method inside a file's class). */
|
||||
depth: number;
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
}
|
||||
|
||||
export interface WireEdge {
|
||||
kind: EdgeKind;
|
||||
line?: number;
|
||||
col?: number;
|
||||
confidence?: number;
|
||||
resolvedBy?: string;
|
||||
provenance?: string;
|
||||
synthesizedBy?: string;
|
||||
via?: string;
|
||||
registeredAt?: string;
|
||||
valueRef?: boolean;
|
||||
}
|
||||
|
||||
/** Every edge between the focal symbol and ONE other symbol, as a single row. */
|
||||
export interface WireRelation {
|
||||
node: WireNodeRef;
|
||||
edgeKinds: EdgeKind[];
|
||||
edges: WireEdge[];
|
||||
edgeCount: number;
|
||||
/** Distinct call-site lines, ascending — what the gutter ports anchor to. */
|
||||
lines: number[];
|
||||
confidence: number | null;
|
||||
uncertain: boolean;
|
||||
synthesized: boolean;
|
||||
fanIn?: number;
|
||||
hub?: boolean;
|
||||
}
|
||||
|
||||
export interface WireList<T> {
|
||||
total: number;
|
||||
shown: number;
|
||||
truncated: boolean;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface WireTestSummary {
|
||||
reached: boolean;
|
||||
hops: number | null;
|
||||
fileCount: number;
|
||||
files: string[];
|
||||
/** False weakens the claim to "no test calls this directly" — see the server. */
|
||||
exhaustive: boolean;
|
||||
hopsSearched: number;
|
||||
}
|
||||
|
||||
export interface WireOutsideIndex {
|
||||
total: number;
|
||||
byKind: Record<string, number>;
|
||||
samples: Array<{ name: string; kind: string; line?: number; col?: number }>;
|
||||
}
|
||||
|
||||
export interface WireBlastSummary {
|
||||
direct: number;
|
||||
withinHops: number;
|
||||
hops: number;
|
||||
files: number;
|
||||
testFiles: number;
|
||||
routes: number;
|
||||
topFiles: Array<{ file: string; symbols: number; test: boolean }>;
|
||||
}
|
||||
|
||||
export interface WireSymbolPayload {
|
||||
node: WireNodeDetail;
|
||||
/** Outermost first: file, then module/class, then the symbol's own parent. */
|
||||
ancestors: WireNodeRef[];
|
||||
members: WireList<WireMember>;
|
||||
incoming: WireList<WireRelation>;
|
||||
outgoing: WireList<WireRelation>;
|
||||
typesUsed: WireRelation[];
|
||||
counts: {
|
||||
callers: number;
|
||||
callees: number;
|
||||
typesUsed: number;
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
members: number;
|
||||
hub: boolean;
|
||||
};
|
||||
tests: WireTestSummary;
|
||||
outsideIndex: WireOutsideIndex;
|
||||
blast: WireBlastSummary | null;
|
||||
/** The file changed on disk since the index — line ranges may be shifted. */
|
||||
drift: boolean;
|
||||
}
|
||||
|
||||
export interface WireSource {
|
||||
file: string;
|
||||
language: string;
|
||||
drift: boolean;
|
||||
/**
|
||||
* Which numbering `lines` belong to. `'indexed'` — the file matches the
|
||||
* index. `'current'` — it drifted and we asked for the bytes anyway
|
||||
* (`ondrift: 'current'`), so nothing the graph holds about this file lines up
|
||||
* with them. `'none'` — it drifted and no slice came back.
|
||||
*/
|
||||
showing: 'indexed' | 'current' | 'none';
|
||||
contentHash: string;
|
||||
indexedAt: number;
|
||||
generated: boolean;
|
||||
totalLines: number | null;
|
||||
from?: number;
|
||||
to?: number;
|
||||
/** Absent when the file drifted and `ondrift` was left at its default. */
|
||||
lines?: string[];
|
||||
truncated?: boolean;
|
||||
reason?: string;
|
||||
/**
|
||||
* The same lines, classified by the server's tree-sitter parse — one entry
|
||||
* per line, each a list of `[classId, text]` pairs indexed into `classes`.
|
||||
* Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
|
||||
* covers the file. See `lib/highlight.ts`.
|
||||
*/
|
||||
highlight?: WireHighlight;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- file view -- */
|
||||
|
||||
/** A row in the file outline — a symbol, its nesting and its edge counts. */
|
||||
export interface WireOutlineEntry extends WireNodeRef {
|
||||
/** Containing symbol within this file, or null for a top-level one. */
|
||||
parentId: string | null;
|
||||
/** Nesting depth from the top level of the file, starting at 0. */
|
||||
depth: number;
|
||||
fanIn: number;
|
||||
fanOut: number;
|
||||
}
|
||||
|
||||
/** One file at the far end of an import rail, with the symbols the edges name. */
|
||||
export interface WireImportRow {
|
||||
file: string;
|
||||
test: boolean;
|
||||
symbols: Array<{ id: string; name: string; kind: string; line: number }>;
|
||||
symbolCount: number;
|
||||
}
|
||||
|
||||
export interface WireFilePayload {
|
||||
file: {
|
||||
path: string;
|
||||
language: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
indexedAt: number;
|
||||
contentHash: string;
|
||||
nodeCount: number;
|
||||
generated: boolean;
|
||||
test: boolean;
|
||||
errors: string[];
|
||||
/** The file node's own id, so the viewer can open the file AS a symbol. */
|
||||
id: string | null;
|
||||
};
|
||||
/** Calls made outside every definition — module-level code. */
|
||||
topLevel: { calls: number };
|
||||
/** The file changed on disk since it was indexed; the outline's lines shifted. */
|
||||
drift: boolean;
|
||||
outline: WireList<WireOutlineEntry>;
|
||||
/** `imports` edges only — a subset of `dependencies`, with symbol names. */
|
||||
imports: WireList<WireImportRow>;
|
||||
importedBy: WireList<WireImportRow>;
|
||||
/** Import statements that resolved to nothing indexed: packages, builtins. */
|
||||
unresolvedImports: Array<{ name: string; line: number }>;
|
||||
/** Every file this one reaches by any cross-file edge — `getFileDependencies`. */
|
||||
dependencies: string[];
|
||||
/** Every file that reaches into this one — `getFileDependents`. */
|
||||
dependents: string[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ whole-file source view -- */
|
||||
|
||||
/** A reference the resolver never landed: a gutter port with no destination. */
|
||||
export interface WireFileOutsideRef {
|
||||
line: number;
|
||||
col: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
|
||||
export interface WireFileCall {
|
||||
/** The symbol making the calls — the file node itself for top-level code. */
|
||||
ownerId: string;
|
||||
ownerLine: number;
|
||||
relation: WireRelation;
|
||||
}
|
||||
|
||||
export interface WireFileCodePayload {
|
||||
file: {
|
||||
path: string;
|
||||
language: string;
|
||||
size: number;
|
||||
indexedAt: number;
|
||||
contentHash: string;
|
||||
generated: boolean;
|
||||
test: boolean;
|
||||
errors: string[];
|
||||
id: string | null;
|
||||
/** Lines on disk now — the height of the scrolling document. */
|
||||
totalLines: number | null;
|
||||
};
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
outline: WireList<WireOutlineEntry>;
|
||||
calls: WireList<WireFileCall>;
|
||||
outside: WireList<WireFileOutsideRef>;
|
||||
/** Calls landing on a definition in this same file — the arc diagram's total. */
|
||||
intraFileCalls: number;
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
export interface WireBlastScale {
|
||||
maxDirect: number;
|
||||
maxWithinHops: number;
|
||||
hops: number;
|
||||
sampled: number;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- search palette -- */
|
||||
|
||||
/** How a result's text matched the query — the server's primary sort key. */
|
||||
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
|
||||
|
||||
export interface WireSearchResult extends WireNodeRef {
|
||||
matchKind: MatchKind;
|
||||
}
|
||||
|
||||
export interface WireSearchGroup {
|
||||
kind: NodeKind;
|
||||
count: number;
|
||||
items: WireSearchResult[];
|
||||
}
|
||||
|
||||
export interface WireSearch {
|
||||
query: string;
|
||||
/** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
|
||||
text: string;
|
||||
filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
|
||||
results: WireList<WireSearchResult>;
|
||||
/** Kind buckets in ranked order — flattening them reproduces the ranking. */
|
||||
groups: WireSearchGroup[];
|
||||
}
|
||||
|
||||
export interface WireNodeRefs {
|
||||
items: WireNodeRef[];
|
||||
/** Ids that name nothing in this index — a stale link, not an error. */
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- routes -- */
|
||||
|
||||
/** One row of the URL -> handler map (`/api/routes`). */
|
||||
export interface WireRoute {
|
||||
/** The route node's name, verbatim: "POST /v1/users/{id}". */
|
||||
url: string;
|
||||
/** The verb, when the name leads with one. Null for a file-routed page. */
|
||||
method: string | null;
|
||||
/** The URL without the verb — the same string as `url` when there is none. */
|
||||
path: string;
|
||||
handler: string;
|
||||
handlerKind: string;
|
||||
/** Where the request is SERVED. */
|
||||
file: string;
|
||||
line: number;
|
||||
handlerId: string | null;
|
||||
/** Where the URL is REGISTERED — the router file, which is how routes group. */
|
||||
routeFile: string;
|
||||
routeLine: number;
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
export interface WireRoutes {
|
||||
routed: boolean;
|
||||
/** Every URL the index holds, whether or not its handler resolved. */
|
||||
routeCount: number;
|
||||
/** Rows in `entries` — the ones whose handler the manifest could name. */
|
||||
shown: number;
|
||||
truncated: boolean;
|
||||
topHandlerFile: string | null;
|
||||
topHandlerFileCount: number;
|
||||
entries: WireRoute[];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- entry points -- */
|
||||
|
||||
export interface WireEntryRoute {
|
||||
/** The route node's name, verbatim: "POST /v1/users/{id}". */
|
||||
url: string;
|
||||
/** The verb, when the name leads with one. Null for a file-routed page. */
|
||||
method: string | null;
|
||||
/** The URL without the verb — the same string as `url` when there is none. */
|
||||
path: string;
|
||||
handler: string;
|
||||
handlerKind: string;
|
||||
/** Where the request is SERVED. */
|
||||
file: string;
|
||||
line: number;
|
||||
handlerId: string | null;
|
||||
/** Where the URL is REGISTERED — the router file, which is how routes group. */
|
||||
routeFile: string;
|
||||
routeLine: number;
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
export interface WireEntryFile extends WireNodeRef {
|
||||
/** Calls and instantiations made at the top level of the file. */
|
||||
calls: number;
|
||||
/** Distinct other files this one's symbols reach. */
|
||||
reaches: number;
|
||||
/** Other files reaching into this one. Zero means nothing imports it. */
|
||||
dependents: number;
|
||||
}
|
||||
|
||||
export interface WireEntryHub extends WireNodeRef {
|
||||
dependents: number;
|
||||
}
|
||||
|
||||
export interface WireEntryTest extends WireNodeRef {
|
||||
/** Distinct other files this test reaches — what it exercises. */
|
||||
reaches: number;
|
||||
/** References behind that reach. */
|
||||
refs: number;
|
||||
}
|
||||
|
||||
export interface WireEntryPoints {
|
||||
/** Frameworks the resolver detected — named in the Routes header. */
|
||||
frameworks: string[];
|
||||
routes: {
|
||||
routed: boolean;
|
||||
/** Every `route` node in the graph, resolved handler or not. */
|
||||
routeCount: number;
|
||||
items: WireList<WireEntryRoute>;
|
||||
};
|
||||
/** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
|
||||
files: WireList<WireEntryFile>;
|
||||
tests: WireList<WireEntryTest>;
|
||||
hubs: WireList<WireEntryHub>;
|
||||
index: { lastIndexedAt: number | null; files: number };
|
||||
timing: { elapsedMs: number; cached: boolean };
|
||||
}
|
||||
|
||||
export interface WireStats {
|
||||
project: { root: string; name: string };
|
||||
index: {
|
||||
state: string | null;
|
||||
lastIndexedAt: number | null;
|
||||
stale: boolean;
|
||||
version: string | null;
|
||||
extractionVersion: number | null;
|
||||
backend: string;
|
||||
journalMode: string;
|
||||
pendingReferences: number;
|
||||
generatedFiles: number;
|
||||
watching: boolean;
|
||||
watcherDegraded: boolean;
|
||||
};
|
||||
graph: {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
files: number;
|
||||
nodesByKind: Record<string, number>;
|
||||
edgesByKind: Record<string, number>;
|
||||
filesByLanguage: Record<string, number>;
|
||||
dbSizeBytes: number;
|
||||
walSizeBytes: number;
|
||||
};
|
||||
frameworks: string[];
|
||||
thresholds: { hub: number; uncertainBelow: number };
|
||||
blastScale: WireBlastScale;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- flow strip -- */
|
||||
|
||||
export interface WireFlowEdge extends WireEdge {
|
||||
/** The link's label: "calls", "via callback · registered at file:line". */
|
||||
label: string;
|
||||
/** This hop reads callee → caller — the reader stepped UP into it. */
|
||||
upward: boolean;
|
||||
/** Confidence below 0.6: the link is dashed `2 3`. */
|
||||
uncertain: boolean;
|
||||
/** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
|
||||
synthesized: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowSource {
|
||||
file: string;
|
||||
language: string;
|
||||
from: number;
|
||||
to: number;
|
||||
/** Absent when `drift` — a mis-sliced window is worse than an empty card. */
|
||||
lines?: string[];
|
||||
highlight?: WireHighlight;
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The call site a card is opened at — the identifier drawn as an accent link. */
|
||||
export interface WireFlowCallRef {
|
||||
line: number;
|
||||
col: number | null;
|
||||
name: string;
|
||||
targetId: string;
|
||||
/** The link points back at the previous card, not on to the next one. */
|
||||
backwards: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowHop {
|
||||
node: WireNodeRef;
|
||||
/** The edge from the PREVIOUS hop into this one; null on the first. */
|
||||
edge: WireFlowEdge | null;
|
||||
callRef: WireFlowCallRef | null;
|
||||
source: WireFlowSource | null;
|
||||
}
|
||||
|
||||
/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
|
||||
export interface WireBoundaryCandidate {
|
||||
node: WireNodeRef;
|
||||
display: string;
|
||||
named: boolean;
|
||||
}
|
||||
|
||||
/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
|
||||
export interface WireBoundarySite {
|
||||
form: string;
|
||||
label: string;
|
||||
snippet: string;
|
||||
line: number;
|
||||
key: string | null;
|
||||
keyIsType: boolean;
|
||||
moreSites: number;
|
||||
candidates: WireBoundaryCandidate[];
|
||||
candidateNote: string | null;
|
||||
}
|
||||
|
||||
export interface WireFlowContinuation {
|
||||
node: WireNodeRef;
|
||||
line: number | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/** Where the graph stops — the strip's end cap (design spec §3.5). */
|
||||
export interface WireFlowBoundary {
|
||||
node: WireNodeRef;
|
||||
sites: WireBoundarySite[];
|
||||
uncertain: WireList<WireFlowContinuation>;
|
||||
further: WireList<WireFlowContinuation>;
|
||||
missed: WireNodeRef[];
|
||||
}
|
||||
|
||||
export interface WireFlow {
|
||||
id: string;
|
||||
/** "execute → rowToFileRecord", for the header's flow picker. */
|
||||
label: string;
|
||||
hops: WireFlowHop[];
|
||||
/** Null on a flow that reaches everything it was asked about. */
|
||||
boundary: WireFlowBoundary | null;
|
||||
/** One card at the dispatch site, not a path: the answer ran out here. */
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowAmbiguity {
|
||||
token: string;
|
||||
chosen: WireNodeRef | null;
|
||||
others: WireNodeRef[];
|
||||
}
|
||||
|
||||
export interface WireFlowPayload {
|
||||
query: {
|
||||
kind: 'directed' | 'symbols' | 'trail';
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
symbols: string[];
|
||||
};
|
||||
flows: WireFlow[];
|
||||
ambiguous: WireFlowAmbiguity[];
|
||||
/** Tokens that named nothing in this index. */
|
||||
unresolved: string[];
|
||||
/** Why there is no flow, when there is none. */
|
||||
reason: string | null;
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- the map -- */
|
||||
|
||||
export interface WireMapModule {
|
||||
/** Directory path, the `(root files)` bucket, or a façade file's own path. */
|
||||
id: string;
|
||||
label: string;
|
||||
files: number;
|
||||
symbols: number;
|
||||
languages: Array<{ language: string; files: number }>;
|
||||
/** More than half its files are tests. */
|
||||
test: boolean;
|
||||
/** A single file kept out of the root bucket because it is the façade. */
|
||||
facade: boolean;
|
||||
/** Its files, capped — the side panel's list when the module is selected. */
|
||||
fileList: { total: number; shown: number; truncated: boolean; items: string[] };
|
||||
}
|
||||
|
||||
export interface WireMapLink {
|
||||
source: string;
|
||||
target: string;
|
||||
/** Every confident cross-module edge behind this link. */
|
||||
count: number;
|
||||
/**
|
||||
* The subset resolved through an import, a qualified name, an inheritance
|
||||
* clause or a typed receiver — what the layering trusts.
|
||||
*/
|
||||
declared: number;
|
||||
byKind: Array<{ kind: EdgeKind; count: number }>;
|
||||
topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
|
||||
}
|
||||
|
||||
export interface WireMapCycle {
|
||||
size: number;
|
||||
files: string[];
|
||||
modules: string[];
|
||||
}
|
||||
|
||||
export interface WireMapPayload {
|
||||
root: string;
|
||||
depth: number;
|
||||
roots: Array<{ root: string; label: string; files: number }>;
|
||||
modules: WireMapModule[];
|
||||
links: WireMapLink[];
|
||||
cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
|
||||
excluded: { uncertainEdges: number; confidenceBelow: number };
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number; cached: boolean };
|
||||
}
|
||||
Reference in New Issue
Block a user