feat(ui): the Symbol view — callers, gutter-ported source, line-anchored callee rail (CG-44)
The core screen of `codegraph ui`: who calls a symbol on the left, its verbatim body in the middle with a port on every line that has an outgoing edge, and what it calls on the right — each callee row placed beside the line that makes the call, with a hairline connector between them. The callee rail is the part that is not a list. A row wants to sit at the centre of its first call-site line and is pushed down only when that would collide with the row above, so the rail keeps source order; the connector still runs to the real line, so the displacement is visible rather than silent. Positions come from measuring the laid-out DOM, so they are recomputed on resize, on font load and whenever a fold opens. Honesty is carried in the drawing, not in a footnote: a filled port means the resolver matched something on that line and a hollow one means it only guessed; uncertain connectors are dashed and their targets fold away behind their count; synthesized edges are dashed differently and tagged with the mechanism that made them; references that leave the index are text with a soft underline rather than links to nowhere, and they are counted. Long bodies keep their head plus a window round every call site — windowed on graph edges only, since a function calling `console.log` two hundred times would otherwise window round every line and buy nothing. Containers over 80 lines show a members outline with per-member fan-in/fan-out instead of 700 lines of braces. Two small additions to the read-only API this needed: * `/api/node` gives every outline member its own fanIn/fanOut (two batched queries for the whole outline). A class's own fan-out is nearly always zero because its methods do the calling, so without these the outline cannot say which member carries weight. * `/api/stats` gains `blastScale` — the denominator the blast bar is drawn against, so one symbol's radius reads as wide or narrow *for this repo*. It is measured across the index's 24 most-depended-on symbols (found with a new `getTopDependedOn`, distinct dependents rather than edges), memoised against the index stamp, and reported as sampled; a symbol wider than the sample becomes the scale instead of overflowing the track. Verified against a real index in a real browser: parity with the prototype on `CodeGraph.sync` (259 lines, 27 callee rows, no overlaps), `GraphTraverser` (20-member outline), a 773-line function (26 windows, 78 connectors), light and dark, hover linking in both directions, keyboard-only navigation, and reflow on resize and on fold toggles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7288ffa36
commit
5cecaabfc2
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* The viewer's side of the read-only JSON API (`src/ui-server/api/`, CG-42).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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".
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------- 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;
|
||||
contentHash: string;
|
||||
indexedAt: number;
|
||||
generated: boolean;
|
||||
totalLines: number | null;
|
||||
from?: number;
|
||||
to?: number;
|
||||
/** Absent when `drift` — a mis-sliced body is worse than no body. */
|
||||
lines?: string[];
|
||||
truncated?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WireBlastScale {
|
||||
maxDirect: number;
|
||||
maxWithinHops: number;
|
||||
hops: number;
|
||||
sampled: number;
|
||||
estimated: 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;
|
||||
}
|
||||
|
||||
export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
|
||||
return getJson<WireStats>('api/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);
|
||||
}
|
||||
|
||||
export function fetchSource(
|
||||
file: string,
|
||||
from: number,
|
||||
to: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<WireSource> {
|
||||
const params = new URLSearchParams({ file, from: String(from), to: String(to) });
|
||||
return getJson<WireSource>(`api/source?${params}`, signal);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The two pieces of Symbol-view state that more than one pane has to agree on.
|
||||
*
|
||||
* `hot` is the hover link: the callee rail, the gutter port, the call site in
|
||||
* the body and the connector between them are four renderings of ONE edge, and
|
||||
* lighting all four from whichever the pointer happens to be over is what makes
|
||||
* the screen read as a single object rather than three lists side by side.
|
||||
*
|
||||
* `railFocus` is the keyboard's place in the rails (↑/↓ move, ←/→ switch,
|
||||
* Enter follows). It is separate from `hot` on purpose: the keyboard's position
|
||||
* must survive the mouse moving across the screen, and a hover must not steal
|
||||
* the place the reader is arrowing through.
|
||||
*/
|
||||
|
||||
export type RailSide = 'left' | 'right';
|
||||
|
||||
let hotTarget = $state<string | null>(null);
|
||||
let focusedRail = $state<RailSide>('right');
|
||||
let focusedIndex = $state(-1);
|
||||
|
||||
export const hot = {
|
||||
get target(): string | null {
|
||||
return hotTarget;
|
||||
},
|
||||
/** True when `id` is the edge currently lit — the test every pane runs. */
|
||||
is(id: string | null | undefined): boolean {
|
||||
return id != null && hotTarget === id;
|
||||
},
|
||||
set(id: string | null): void {
|
||||
hotTarget = id;
|
||||
},
|
||||
/** Clear only if `id` is still the lit one — a stale mouseout must not win. */
|
||||
clear(id: string | null): void {
|
||||
if (id == null || hotTarget === id) hotTarget = null;
|
||||
},
|
||||
};
|
||||
|
||||
export const railFocus = {
|
||||
get rail(): RailSide {
|
||||
return focusedRail;
|
||||
},
|
||||
get index(): number {
|
||||
return focusedIndex;
|
||||
},
|
||||
/** True when this row is the keyboard's current position. */
|
||||
at(rail: RailSide, index: number): boolean {
|
||||
return focusedRail === rail && focusedIndex === index;
|
||||
},
|
||||
move(rail: RailSide, index: number): void {
|
||||
focusedRail = rail;
|
||||
focusedIndex = index;
|
||||
},
|
||||
/** Step within the active rail, clamped to its length. */
|
||||
step(delta: number, length: number): void {
|
||||
if (length === 0) return;
|
||||
focusedIndex = Math.max(0, Math.min(length - 1, focusedIndex + delta));
|
||||
},
|
||||
/** Switch rails, landing on the first row rather than an unrelated index. */
|
||||
switchTo(rail: RailSide): void {
|
||||
focusedRail = rail;
|
||||
if (focusedIndex < 0) focusedIndex = 0;
|
||||
},
|
||||
reset(): void {
|
||||
focusedRail = 'right';
|
||||
focusedIndex = -1;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Near-monochrome tokenising for the code block (design spec §2.2).
|
||||
*
|
||||
* The colouring is deliberately almost absent: comments and strings recede,
|
||||
* keywords carry weight rather than hue, and the ONLY colour in the body is a
|
||||
* resolved call site. That is the point of the screen — the graph's edges are
|
||||
* what the eye should find, and a six-colour syntax theme buries them.
|
||||
*
|
||||
* A hand-rolled lexer, not a highlighter library. It has one job — separate
|
||||
* comments, strings, numbers and keywords from everything else, well enough to
|
||||
* be honest across the 30-odd languages the engine indexes — and doing it here
|
||||
* keeps the viewer free of a runtime dependency and of a per-grammar download
|
||||
* on a machine that is reading its own source offline. CG-43 replaces this
|
||||
* with Shiki tokens produced server-side; `tokenize` is the seam.
|
||||
*/
|
||||
|
||||
export type TokenClass =
|
||||
| 'comment'
|
||||
| 'string'
|
||||
| 'keyword'
|
||||
| 'number'
|
||||
| 'ident'
|
||||
| 'space'
|
||||
| 'punct';
|
||||
|
||||
export interface Token {
|
||||
cls: TokenClass;
|
||||
text: string;
|
||||
/** Column of the token's first character, 0-based — how a ref finds its identifier. */
|
||||
col: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexer state that survives from one line to the next: a block comment or a
|
||||
* multi-line string opened on an earlier line. Rendering a window of a file
|
||||
* without this makes the first line after a `/*` look like code.
|
||||
*/
|
||||
export interface LexState {
|
||||
block: boolean;
|
||||
/** The delimiter that will close the open multi-line string (a backtick, `"""`, …). */
|
||||
stringEnd: string | null;
|
||||
}
|
||||
|
||||
export function newLexState(): LexState {
|
||||
return { block: false, stringEnd: null };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- dialects -- */
|
||||
|
||||
interface Dialect {
|
||||
lineComment: string[];
|
||||
blockComment: [string, string] | null;
|
||||
/** Quote characters that never span lines. */
|
||||
quotes: string[];
|
||||
/** Delimiters that MAY span lines (template literals, triple quotes, heredoc-ish). */
|
||||
multiline: string[];
|
||||
keywords: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const kw = (words: string): ReadonlySet<string> => new Set(words.split(/\s+/).filter(Boolean));
|
||||
|
||||
/**
|
||||
* Keywords shared widely enough across the C-family that listing them once is
|
||||
* both shorter and more accurate than a per-language table nobody maintains.
|
||||
*/
|
||||
const C_FAMILY = `
|
||||
abstract as async await break case catch class const constexpr continue default defer delete do
|
||||
else enum export extends extern false final finally for from func function go goto if impl implements
|
||||
import in instanceof interface internal is let match mod module mut namespace new nil null object
|
||||
operator out override package private protected public readonly record ref return sealed select self
|
||||
static struct super switch this throw throws trait true try type typedef typeof union unsafe use using
|
||||
var virtual void when where while with yield
|
||||
`;
|
||||
|
||||
const DIALECTS: Record<string, Dialect> = {
|
||||
c: {
|
||||
lineComment: ['//'],
|
||||
blockComment: ['/*', '*/'],
|
||||
quotes: ['"', "'"],
|
||||
multiline: [],
|
||||
keywords: kw(C_FAMILY),
|
||||
},
|
||||
ts: {
|
||||
lineComment: ['//'],
|
||||
blockComment: ['/*', '*/'],
|
||||
quotes: ['"', "'"],
|
||||
multiline: ['`'],
|
||||
keywords: kw(
|
||||
`${C_FAMILY} any asserts bigint boolean declare infer keyof never number readonly satisfies
|
||||
string symbol undefined unknown`
|
||||
),
|
||||
},
|
||||
hash: {
|
||||
// Python, Ruby, shell, YAML, Nix, Terraform, Perl, R, Elixir…
|
||||
lineComment: ['#'],
|
||||
blockComment: null,
|
||||
quotes: ['"', "'"],
|
||||
multiline: ['"""', "'''"],
|
||||
keywords: kw(
|
||||
`and as assert async await begin break case class def defp defmodule del do elif else elsif end
|
||||
ensure except exec finally for from global if import in is lambda let module next nil none not
|
||||
or pass raise require rescue return self struct then trait true false try unless until use when
|
||||
while with yield`
|
||||
),
|
||||
},
|
||||
sql: {
|
||||
lineComment: ['--'],
|
||||
blockComment: ['/*', '*/'],
|
||||
quotes: ["'", '"'],
|
||||
multiline: [],
|
||||
keywords: kw(
|
||||
`select insert update delete from where group by order having join left right inner outer on as
|
||||
and or not null create table index view primary key foreign references into values set limit`
|
||||
),
|
||||
},
|
||||
lisp: {
|
||||
lineComment: [';'],
|
||||
blockComment: null,
|
||||
quotes: ['"'],
|
||||
multiline: [],
|
||||
keywords: kw('def defn defmacro let fn if cond do loop recur ns require import when case'),
|
||||
},
|
||||
};
|
||||
|
||||
/** Engine `Language` values → the lexer that reads them closely enough. */
|
||||
const LANGUAGE_DIALECT: Record<string, keyof typeof DIALECTS> = {
|
||||
typescript: 'ts',
|
||||
tsx: 'ts',
|
||||
javascript: 'ts',
|
||||
jsx: 'ts',
|
||||
svelte: 'ts',
|
||||
vue: 'ts',
|
||||
astro: 'ts',
|
||||
dart: 'c',
|
||||
java: 'c',
|
||||
kotlin: 'c',
|
||||
scala: 'c',
|
||||
csharp: 'c',
|
||||
vbnet: 'hash',
|
||||
go: 'c',
|
||||
rust: 'c',
|
||||
swift: 'c',
|
||||
objc: 'c',
|
||||
c: 'c',
|
||||
cpp: 'c',
|
||||
cuda: 'c',
|
||||
metal: 'c',
|
||||
php: 'c',
|
||||
zig: 'c',
|
||||
solidity: 'c',
|
||||
glsl: 'c',
|
||||
python: 'hash',
|
||||
ruby: 'hash',
|
||||
crystal: 'hash',
|
||||
elixir: 'hash',
|
||||
perl: 'hash',
|
||||
r: 'hash',
|
||||
shell: 'hash',
|
||||
bash: 'hash',
|
||||
powershell: 'hash',
|
||||
yaml: 'hash',
|
||||
toml: 'hash',
|
||||
nix: 'hash',
|
||||
terraform: 'hash',
|
||||
hcl: 'hash',
|
||||
dockerfile: 'hash',
|
||||
makefile: 'hash',
|
||||
sql: 'sql',
|
||||
clojure: 'lisp',
|
||||
lisp: 'lisp',
|
||||
scheme: 'lisp',
|
||||
elm: 'ts',
|
||||
haskell: 'ts',
|
||||
lua: 'hash',
|
||||
erlang: 'hash',
|
||||
cobol: 'hash',
|
||||
};
|
||||
|
||||
function dialectFor(language: string | undefined): Dialect {
|
||||
const key = LANGUAGE_DIALECT[(language ?? '').toLowerCase()] ?? 'ts';
|
||||
return DIALECTS[key] as Dialect;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- lexer -- */
|
||||
|
||||
const IDENT_START = /[A-Za-z_$@]/;
|
||||
const IDENT_BODY = /[\w$]/;
|
||||
|
||||
/**
|
||||
* Split one line into tokens, carrying `state` across lines.
|
||||
*
|
||||
* Mutates `state` — a window of source is tokenised line by line in order, and
|
||||
* threading the block-comment flag through a return value would make every
|
||||
* caller responsible for a detail only this function understands.
|
||||
*/
|
||||
export function tokenize(line: string, state: LexState, language?: string): Token[] {
|
||||
const d = dialectFor(language);
|
||||
const out: Token[] = [];
|
||||
const len = line.length;
|
||||
let i = 0;
|
||||
|
||||
const push = (cls: TokenClass, from: number, to: number): void => {
|
||||
if (to > from) out.push({ cls, text: line.slice(from, to), col: from });
|
||||
};
|
||||
|
||||
while (i < len) {
|
||||
// --- continuations of something opened on an earlier line ---------------
|
||||
if (state.block && d.blockComment) {
|
||||
const close = line.indexOf(d.blockComment[1], i);
|
||||
if (close < 0) {
|
||||
push('comment', i, len);
|
||||
i = len;
|
||||
} else {
|
||||
push('comment', i, close + d.blockComment[1].length);
|
||||
i = close + d.blockComment[1].length;
|
||||
state.block = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state.stringEnd) {
|
||||
const end = findUnescaped(line, state.stringEnd, i);
|
||||
if (end < 0) {
|
||||
push('string', i, len);
|
||||
i = len;
|
||||
} else {
|
||||
push('string', i, end + state.stringEnd.length);
|
||||
i = end + state.stringEnd.length;
|
||||
state.stringEnd = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const rest = line.slice(i);
|
||||
|
||||
// --- comments -----------------------------------------------------------
|
||||
const lineMarker = d.lineComment.find((m) => rest.startsWith(m));
|
||||
if (lineMarker) {
|
||||
push('comment', i, len);
|
||||
i = len;
|
||||
continue;
|
||||
}
|
||||
if (d.blockComment && rest.startsWith(d.blockComment[0])) {
|
||||
const close = line.indexOf(d.blockComment[1], i + d.blockComment[0].length);
|
||||
if (close < 0) {
|
||||
push('comment', i, len);
|
||||
i = len;
|
||||
state.block = true;
|
||||
} else {
|
||||
push('comment', i, close + d.blockComment[1].length);
|
||||
i = close + d.blockComment[1].length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- strings ------------------------------------------------------------
|
||||
// Longest delimiter first, so `"""` never matches as `"`.
|
||||
const multi = [...d.multiline].sort((a, b) => b.length - a.length).find((m) => rest.startsWith(m));
|
||||
if (multi) {
|
||||
const end = findUnescaped(line, multi, i + multi.length);
|
||||
if (end < 0) {
|
||||
push('string', i, len);
|
||||
i = len;
|
||||
state.stringEnd = multi;
|
||||
} else {
|
||||
push('string', i, end + multi.length);
|
||||
i = end + multi.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const quote = d.quotes.find((q) => rest.startsWith(q));
|
||||
if (quote) {
|
||||
const end = findUnescaped(line, quote, i + quote.length);
|
||||
// An unterminated single-line quote is an apostrophe in prose far more
|
||||
// often than a real string, so it stops at the line rather than eating
|
||||
// the rest of the window.
|
||||
push('string', i, end < 0 ? len : end + quote.length);
|
||||
i = end < 0 ? len : end + quote.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- words, numbers, space, everything else -----------------------------
|
||||
const ch = line[i] as string;
|
||||
if (IDENT_START.test(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < len && IDENT_BODY.test(line[j] as string)) j++;
|
||||
const word = line.slice(i, j);
|
||||
push(d.keywords.has(word) ? 'keyword' : 'ident', i, j);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
let j = i + 1;
|
||||
while (j < len && /[\w.]/.test(line[j] as string)) j++;
|
||||
push('number', i, j);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < len && /\s/.test(line[j] as string)) j++;
|
||||
push('space', i, j);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
push('punct', i, i + 1);
|
||||
i++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Index of `needle` at or after `from`, skipping backslash-escaped ones. */
|
||||
function findUnescaped(line: string, needle: string, from: number): number {
|
||||
let i = from;
|
||||
while (i < line.length) {
|
||||
if (line[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(needle, i)) return i;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The CSS class for a token, or null where the default ink is right. */
|
||||
export function tokenClass(cls: TokenClass): string | null {
|
||||
switch (cls) {
|
||||
case 'comment':
|
||||
return 't-c';
|
||||
case 'string':
|
||||
return 't-s';
|
||||
case 'keyword':
|
||||
return 't-k';
|
||||
case 'number':
|
||||
return 't-n';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The project's own facts — loaded once, read everywhere.
|
||||
*
|
||||
* `/api/stats` describes the index rather than any one symbol, so every screen
|
||||
* that needs a piece of it (the top bar's counts, the Symbol view's blast
|
||||
* scale) would otherwise re-fetch the same payload. The promise is memoised,
|
||||
* not the value, so callers made before it lands still get the same request.
|
||||
*/
|
||||
|
||||
import { fetchStats, type WireStats } from './api';
|
||||
|
||||
let stats = $state<WireStats | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
function load(): Promise<void> {
|
||||
if (inflight) return inflight;
|
||||
inflight = fetchStats()
|
||||
.then((value) => {
|
||||
stats = value;
|
||||
error = null;
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
// A failure here costs a couple of numbers in the top bar and the blast
|
||||
// bar's denominator — never the screen. It is recorded, not thrown.
|
||||
error = cause instanceof Error ? cause.message : String(cause);
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export const project = {
|
||||
get stats(): WireStats | null {
|
||||
return stats;
|
||||
},
|
||||
get error(): string | null {
|
||||
return error;
|
||||
},
|
||||
/** "codegraph" — the indexed project's directory name. */
|
||||
get name(): string | null {
|
||||
return stats?.project.name ?? null;
|
||||
},
|
||||
/** "13,495 symbols · 47,433 edges · 632 files indexed". */
|
||||
get summary(): string | null {
|
||||
if (!stats) return null;
|
||||
const n = (value: number): string => value.toLocaleString();
|
||||
return `${n(stats.graph.nodes)} symbols · ${n(stats.graph.edges)} edges · ${n(stats.graph.files)} files indexed`;
|
||||
},
|
||||
ensure: load,
|
||||
};
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Everything the Symbol view derives from one `/api/node` payload, as plain
|
||||
* functions over plain data.
|
||||
*
|
||||
* None of this touches the DOM or Svelte's reactivity. The screen's hard parts
|
||||
* — which lines get a port, which callee row sits at which height, which call
|
||||
* site is a link — are all decisions about the payload, and keeping them here
|
||||
* means they can be reasoned about (and tested) without a browser.
|
||||
*
|
||||
* Design spec §3.2.
|
||||
*/
|
||||
|
||||
import type {
|
||||
WireEdge,
|
||||
WireMember,
|
||||
WireOutsideIndex,
|
||||
WireRelation,
|
||||
WireSymbolPayload,
|
||||
} from './api';
|
||||
|
||||
/* ------------------------------------------------------------- constants -- */
|
||||
|
||||
/** Bodies at or under this are shown whole (design spec §3.2). */
|
||||
export const FULL_BODY_LINES = 260;
|
||||
/** Above that, the head is shown in full before the windows begin. */
|
||||
export const HEAD_LINES = 80;
|
||||
/** Lines of context kept either side of a call site in a windowed body. */
|
||||
export const WINDOW_CONTEXT = 4;
|
||||
/** Two windows closer than this merge — a 1-line gap row costs more than it saves. */
|
||||
const WINDOW_MERGE_GAP = 2;
|
||||
/** Windows in one body. Past this the body is a listing, not a reading. */
|
||||
const MAX_WINDOWS = 30;
|
||||
/** A container bigger than this shows its outline instead of its body. */
|
||||
export const CONTAINER_BODY_LINES = 80;
|
||||
|
||||
/** Kinds that hold other symbols — they get an outline, not a 700-line body. */
|
||||
export const CONTAINER_KINDS = new Set([
|
||||
'file',
|
||||
'module',
|
||||
'namespace',
|
||||
'class',
|
||||
'struct',
|
||||
'interface',
|
||||
'trait',
|
||||
'protocol',
|
||||
'enum',
|
||||
'union',
|
||||
]);
|
||||
|
||||
/** Kinds whose outline rows are dimmed: data, not behaviour. */
|
||||
const QUIET_MEMBER_KINDS = new Set(['property', 'field', 'enum_member', 'constant', 'variable']);
|
||||
|
||||
/* ----------------------------------------------------------------- words -- */
|
||||
|
||||
/**
|
||||
* What an edge is called in a rail's meta line.
|
||||
*
|
||||
* `calls` returns '' deliberately: it is the default reading of the whole
|
||||
* screen, and labelling every row "calls" is noise that hides the rows where
|
||||
* the relationship is something else.
|
||||
*/
|
||||
export function edgeWord(edge: WireEdge): string {
|
||||
switch (edge.kind) {
|
||||
case 'calls':
|
||||
return '';
|
||||
case 'instantiates':
|
||||
return 'creates';
|
||||
case 'references':
|
||||
return edge.valueRef ? 'passes as value' : 'uses type';
|
||||
default:
|
||||
return edge.kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** The distinct edge words for a relation, in first-seen order, blanks dropped. */
|
||||
export function relationWords(relation: WireRelation): string[] {
|
||||
const words: string[] = [];
|
||||
for (const edge of relation.edges) {
|
||||
const word = edgeWord(edge);
|
||||
if (word && !words.includes(word)) words.push(word);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
/** The synthesizer that produced this relation's edge, when one did. */
|
||||
export function synthesizedBy(relation: WireRelation): string | null {
|
||||
if (!relation.synthesized) return null;
|
||||
const edge = relation.edges.find((e) => e.provenance === 'heuristic');
|
||||
return edge?.synthesizedBy ?? edge?.via ?? 'synthesized';
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
/** The trailing segment of a dotted/qualified name — what appears in the source. */
|
||||
export function lastSegment(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot < 0 ? name : name.slice(dot + 1);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- windows -- */
|
||||
|
||||
export interface SourceWindow {
|
||||
/** 1-based file line of `lines[0]`. */
|
||||
start: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface CodeBlock {
|
||||
windows: SourceWindow[];
|
||||
/** Lines skipped between window i and i+1 — the "⋯ N lines without calls" rows. */
|
||||
gapsAfter: number[];
|
||||
/** Lines dropped after the last window, if the body did not run to its end. */
|
||||
tailGap: number;
|
||||
/** The body was shown whole. */
|
||||
whole: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut a long body down to its head plus the neighbourhood of every call site.
|
||||
*
|
||||
* The rule is the one the prototype established and the screenshots pin: a
|
||||
* body of {@link FULL_BODY_LINES} or fewer is shown whole, and a longer one
|
||||
* keeps its first {@link HEAD_LINES} lines — where the signature, the guards
|
||||
* and the shape of the function live — plus ±{@link WINDOW_CONTEXT} lines
|
||||
* around each call, because a call site with no context is a name, not code.
|
||||
*
|
||||
* @param startLine 1-based first line of the symbol
|
||||
* @param lines the body's source, `lines[0]` being `startLine`
|
||||
* @param callLines every line in the body that makes an outgoing edge
|
||||
*/
|
||||
export function buildCodeBlock(
|
||||
startLine: number,
|
||||
lines: readonly string[],
|
||||
callLines: readonly number[]
|
||||
): CodeBlock {
|
||||
const endLine = startLine + lines.length - 1;
|
||||
const slice = (from: number, to: number): SourceWindow => ({
|
||||
start: from,
|
||||
lines: lines.slice(from - startLine, to - startLine + 1),
|
||||
});
|
||||
|
||||
if (lines.length <= FULL_BODY_LINES) {
|
||||
return {
|
||||
windows: lines.length > 0 ? [slice(startLine, endLine)] : [],
|
||||
gapsAfter: [],
|
||||
tailGap: 0,
|
||||
whole: true,
|
||||
};
|
||||
}
|
||||
|
||||
const headEnd = Math.min(endLine, startLine + HEAD_LINES - 1);
|
||||
const ranges: Array<[number, number]> = [[startLine, headEnd]];
|
||||
const sites = [...new Set(callLines)]
|
||||
.filter((line) => line > headEnd && line <= endLine)
|
||||
.sort((a, b) => a - b);
|
||||
for (const line of sites) {
|
||||
ranges.push([
|
||||
Math.max(startLine, line - WINDOW_CONTEXT),
|
||||
Math.min(endLine, line + WINDOW_CONTEXT),
|
||||
]);
|
||||
}
|
||||
|
||||
const merged: Array<[number, number]> = [];
|
||||
for (const range of ranges) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && range[0] <= last[1] + WINDOW_MERGE_GAP) last[1] = Math.max(last[1], range[1]);
|
||||
else merged.push([...range] as [number, number]);
|
||||
}
|
||||
|
||||
const kept = merged.slice(0, MAX_WINDOWS);
|
||||
const windows = kept.map(([from, to]) => slice(from, to));
|
||||
const gapsAfter = kept.slice(0, -1).map((range, i) => (kept[i + 1] as [number, number])[0] - range[1] - 1);
|
||||
const lastEnd = kept[kept.length - 1]?.[1] ?? endLine;
|
||||
|
||||
return { windows, gapsAfter, tailGap: Math.max(0, endLine - lastEnd), whole: false };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ refs -- */
|
||||
|
||||
/** One identifier in the body that the graph has something to say about. */
|
||||
export interface LineRef {
|
||||
/** The identifier as it appears in the source — what the token must match. */
|
||||
ident: string;
|
||||
/** 0-based column the edge was recorded at, or null when it carries none. */
|
||||
col: number | null;
|
||||
/** Target node id, or null for a reference that leaves the index. */
|
||||
targetId: string | null;
|
||||
uncertain: boolean;
|
||||
/** No node behind it — rendered as text with a soft underline, not a link. */
|
||||
outside: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which identifiers on which lines are edges, keyed by 1-based line.
|
||||
*
|
||||
* Includes the type references (`uses types …` in the header) so a line that
|
||||
* only names a type still gets its port: the port's claim is "something leaves
|
||||
* the graph from this line", and a type reference does.
|
||||
*/
|
||||
export function refsByLine(payload: WireSymbolPayload): Map<number, LineRef[]> {
|
||||
const byLine = new Map<number, LineRef[]>();
|
||||
const add = (line: number, ref: LineRef): void => {
|
||||
const bucket = byLine.get(line);
|
||||
if (bucket) bucket.push(ref);
|
||||
else byLine.set(line, [ref]);
|
||||
};
|
||||
|
||||
for (const relation of [...payload.outgoing.items, ...payload.typesUsed]) {
|
||||
for (const edge of relation.edges) {
|
||||
if (!edge.line) continue;
|
||||
const word = edgeWord(edge);
|
||||
add(edge.line, {
|
||||
ident: lastSegment(relation.node.name),
|
||||
col: typeof edge.col === 'number' ? edge.col : null,
|
||||
targetId: relation.node.id,
|
||||
uncertain: relation.uncertain,
|
||||
outside: false,
|
||||
title:
|
||||
`${word || 'calls'} ${relation.node.qualifiedName} — ${relation.node.file}:${relation.node.line}` +
|
||||
(edge.confidence != null ? ` · confidence ${edge.confidence}` : '') +
|
||||
(edge.resolvedBy ? ` · resolved by ${edge.resolvedBy}` : ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of outsideRefs(payload.outsideIndex)) add(ref.line, ref.ref);
|
||||
return byLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines a long body is windowed around.
|
||||
*
|
||||
* Only edges that reach something IN the graph count. An unresolved reference
|
||||
* still gets its port and its soft underline where it happens to be on screen,
|
||||
* but it must not open a window of its own: a function with 170 calls into
|
||||
* `console`, `Promise` and `fs` would window around nearly every line and the
|
||||
* head-plus-windows rule would buy nothing.
|
||||
*/
|
||||
export function graphCallLines(payload: WireSymbolPayload): number[] {
|
||||
const lines = new Set<number>();
|
||||
for (const relation of [...payload.outgoing.items, ...payload.typesUsed]) {
|
||||
for (const line of relation.lines) lines.add(line);
|
||||
}
|
||||
return [...lines].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* References with no node behind them, as line refs.
|
||||
*
|
||||
* The samples are raw resolver bookkeeping, so anything that is not a plain
|
||||
* identifier — a whole arrow function captured as a "name", a receiver
|
||||
* expression — is dropped rather than searched for in the line: a ref that
|
||||
* cannot match a token would silently claim the wrong one.
|
||||
*/
|
||||
function outsideRefs(outside: WireOutsideIndex): Array<{ line: number; ref: LineRef }> {
|
||||
const out: Array<{ line: number; ref: LineRef }> = [];
|
||||
for (const sample of outside.samples) {
|
||||
if (!sample.line) continue;
|
||||
const ident = lastSegment(sample.name ?? '');
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(ident)) continue;
|
||||
out.push({
|
||||
line: sample.line,
|
||||
ref: {
|
||||
ident,
|
||||
col: typeof sample.col === 'number' ? sample.col : null,
|
||||
targetId: null,
|
||||
uncertain: false,
|
||||
outside: true,
|
||||
title: `${sample.name} is not in the index — nothing here resolves it`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which token on a line each ref refers to.
|
||||
*
|
||||
* A line can name the same identifier twice (`b.render(a.render())`) and the
|
||||
* recorded column points at the start of the *expression*, not at the callee's
|
||||
* own name, so an exact column hit is the exception rather than the rule. The
|
||||
* ladder — containing token, then first token at or after the column, then any
|
||||
* unclaimed one, then the last — is what makes `this.mutex.withLock(…)` mark
|
||||
* `withLock` instead of `this`.
|
||||
*
|
||||
* @returns token index → the ref that claimed it
|
||||
*/
|
||||
export function assignRefs(
|
||||
tokens: ReadonlyArray<{ cls: string; text: string; col: number }>,
|
||||
refs: readonly LineRef[]
|
||||
): Map<number, LineRef> {
|
||||
const claimed = new Map<number, LineRef>();
|
||||
for (const ref of refs) {
|
||||
const candidates: number[] = [];
|
||||
tokens.forEach((token, index) => {
|
||||
if (token.cls === 'ident' && token.text === ref.ident) candidates.push(index);
|
||||
});
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
let pick: number | undefined;
|
||||
if (ref.col !== null) {
|
||||
const col = ref.col;
|
||||
pick = candidates.find((i) => {
|
||||
const t = tokens[i] as { text: string; col: number };
|
||||
return t.col <= col && col < t.col + t.text.length;
|
||||
});
|
||||
if (pick === undefined) pick = candidates.find((i) => (tokens[i] as { col: number }).col >= col);
|
||||
}
|
||||
if (pick === undefined) pick = candidates.find((i) => !claimed.has(i));
|
||||
if (pick === undefined) pick = candidates[candidates.length - 1];
|
||||
if (pick === undefined || claimed.has(pick)) continue;
|
||||
claimed.set(pick, ref);
|
||||
}
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ right rail -- */
|
||||
|
||||
export interface CalleeRow {
|
||||
relation: WireRelation;
|
||||
/** First call-site line — the height the row wants to sit at. */
|
||||
anchor: number | null;
|
||||
/** Distinct call-site lines; `×N` appears when there is more than one. */
|
||||
lines: number[];
|
||||
words: string[];
|
||||
via: string | null;
|
||||
}
|
||||
|
||||
export interface CalleeRailModel {
|
||||
rows: CalleeRow[];
|
||||
uncertain: CalleeRow[];
|
||||
/** Callee groups the API had to cap away. */
|
||||
hiddenGroups: number;
|
||||
outsideCalls: number;
|
||||
outsideTypeRefs: number;
|
||||
}
|
||||
|
||||
export function buildCalleeRail(payload: WireSymbolPayload): CalleeRailModel {
|
||||
const rows: CalleeRow[] = [];
|
||||
const uncertain: CalleeRow[] = [];
|
||||
|
||||
for (const relation of payload.outgoing.items) {
|
||||
const row: CalleeRow = {
|
||||
relation,
|
||||
anchor: relation.lines[0] ?? null,
|
||||
lines: relation.lines,
|
||||
words: relationWords(relation),
|
||||
via: synthesizedBy(relation),
|
||||
};
|
||||
if (relation.uncertain) uncertain.push(row);
|
||||
else rows.push(row);
|
||||
}
|
||||
|
||||
const typeRefs = payload.outsideIndex.byKind['references'] ?? 0;
|
||||
return {
|
||||
rows,
|
||||
uncertain,
|
||||
hiddenGroups: payload.outgoing.total - payload.outgoing.shown,
|
||||
outsideCalls: Math.max(0, payload.outsideIndex.total - typeRefs),
|
||||
outsideTypeRefs: typeRefs,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- left rail -- */
|
||||
|
||||
export interface CallerRow {
|
||||
relation: WireRelation;
|
||||
words: string[];
|
||||
/** Call-site lines in the CALLER's file — the `:4657` chips. */
|
||||
lines: number[];
|
||||
via: string | null;
|
||||
}
|
||||
|
||||
export interface CallerFileGroup {
|
||||
file: string;
|
||||
/** True for the focal symbol's own file, which is labelled "same file". */
|
||||
same: boolean;
|
||||
rows: CallerRow[];
|
||||
}
|
||||
|
||||
export interface CallerRailModel {
|
||||
groups: CallerFileGroup[];
|
||||
uncertain: CallerRow[];
|
||||
tests: { rows: CallerRow[]; calls: number; files: string[] };
|
||||
/** Distinct callers, including the ones folded into tests and uncertain. */
|
||||
total: number;
|
||||
hiddenGroups: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The left rail: callers grouped by file, with tests and name-only guesses
|
||||
* folded away.
|
||||
*
|
||||
* The folds are not "hide the boring ones" — they are the two cases where a
|
||||
* long list would drown the answer. Tests are usually the largest group and
|
||||
* the least surprising ("of course the test file calls it"), and an uncertain
|
||||
* caller is a guess the reader should be able to see marked as one rather than
|
||||
* mixed into the same list as a resolved call. Both carry their counts.
|
||||
*/
|
||||
export function buildCallerRail(payload: WireSymbolPayload): CallerRailModel {
|
||||
const focalFile = payload.node.file;
|
||||
const byFile = new Map<string, CallerRow[]>();
|
||||
const uncertain: CallerRow[] = [];
|
||||
const testRows: CallerRow[] = [];
|
||||
|
||||
for (const relation of payload.incoming.items) {
|
||||
const row: CallerRow = {
|
||||
relation,
|
||||
words: relationWords(relation),
|
||||
lines: relation.lines,
|
||||
via: synthesizedBy(relation),
|
||||
};
|
||||
// Uncertainty wins over test-ness: a name-only guess is a claim about the
|
||||
// edge, and burying it in the tests fold would present it as established.
|
||||
if (relation.uncertain) {
|
||||
uncertain.push(row);
|
||||
continue;
|
||||
}
|
||||
if (relation.node.test) {
|
||||
testRows.push(row);
|
||||
continue;
|
||||
}
|
||||
const bucket = byFile.get(relation.node.file);
|
||||
if (bucket) bucket.push(row);
|
||||
else byFile.set(relation.node.file, [row]);
|
||||
}
|
||||
|
||||
const groups: CallerFileGroup[] = [...byFile.entries()]
|
||||
.map(([file, rows]) => ({ file, same: file === focalFile, rows }))
|
||||
.sort((a, b) => (a.same ? -1 : b.same ? 1 : a.file.localeCompare(b.file)));
|
||||
|
||||
return {
|
||||
groups,
|
||||
uncertain,
|
||||
tests: {
|
||||
rows: testRows,
|
||||
calls: testRows.reduce((sum, row) => sum + row.relation.edgeCount, 0),
|
||||
files: [...new Set(testRows.map((row) => row.relation.node.file))].sort(),
|
||||
},
|
||||
total: payload.incoming.total,
|
||||
hiddenGroups: payload.incoming.total - payload.incoming.shown,
|
||||
};
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- connectors -- */
|
||||
|
||||
/** One hairline from a gutter port to a callee row. Geometry comes from the view. */
|
||||
export interface Connector {
|
||||
/** SVG path data — a single cubic from the port to the row. */
|
||||
d: string;
|
||||
targetId: string;
|
||||
uncertain: boolean;
|
||||
/** Synthesized rather than parsed — dynamic dispatch, drawn dashed. */
|
||||
heuristic: boolean;
|
||||
/** The edge the reader arrived by. */
|
||||
origin: boolean;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- outline -- */
|
||||
|
||||
export interface OutlineRow {
|
||||
member: WireMember;
|
||||
nested: boolean;
|
||||
dimmed: boolean;
|
||||
}
|
||||
|
||||
export function buildOutline(payload: WireSymbolPayload): OutlineRow[] {
|
||||
return payload.members.items.map((member) => ({
|
||||
member,
|
||||
nested: member.depth > 1,
|
||||
dimmed: QUIET_MEMBER_KINDS.has(member.kind),
|
||||
}));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- decisions -- */
|
||||
|
||||
/**
|
||||
* Whether this symbol's body is worth drawing at all.
|
||||
*
|
||||
* A 700-line class body is a list of members with braces between them: the
|
||||
* outline says the same thing in 20 rows and lets the reader pick one. Below
|
||||
* {@link CONTAINER_BODY_LINES} the body IS the useful view of a container, so
|
||||
* both are shown.
|
||||
*/
|
||||
export function showsBody(kind: string, lines: number): boolean {
|
||||
return !(CONTAINER_KINDS.has(kind) && lines > CONTAINER_BODY_LINES);
|
||||
}
|
||||
|
||||
/** The kind word and the modifiers that belong beside a symbol's name. */
|
||||
export function kindPhrase(node: {
|
||||
kind: string;
|
||||
async?: boolean;
|
||||
static?: boolean;
|
||||
abstract?: boolean;
|
||||
visibility?: string;
|
||||
}): string {
|
||||
const parts = [node.kind === 'type_alias' ? 'type' : node.kind.replace(/_/g, ' ')];
|
||||
if (node.async) parts.push('async');
|
||||
if (node.static) parts.push('static');
|
||||
if (node.abstract) parts.push('abstract');
|
||||
if (node.visibility && node.visibility !== 'public') parts.push(node.visibility);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** "1 caller" / "12 callers" — the counts sit next to too many nouns to inline. */
|
||||
export function plural(count: number, one: string, many = `${one}s`): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Walking the graph — the one place a symbol navigation is performed.
|
||||
*
|
||||
* Every step records its DIRECTION before it navigates, because the direction
|
||||
* is not recoverable afterwards. "I stepped down into a call" and "I stepped up
|
||||
* to a caller" produce the same pair of symbols; only the act distinguishes
|
||||
* them, and the Symbol view needs it twice over: the trail bar draws `→` or `←`
|
||||
* between hops, and the arrival rail tints the row you came from ("you came
|
||||
* from here") — which is the LEFT rail after stepping down, and the RIGHT rail
|
||||
* after stepping up.
|
||||
*
|
||||
* The trail is pushed first and travels in the URL, so a reload or a shared
|
||||
* link reproduces the walk rather than starting a fresh one at the same symbol.
|
||||
*/
|
||||
|
||||
import { navigate, symbolHref } from './router.svelte';
|
||||
import { encodeTrail, trail, type HopDirection } from './trail.svelte';
|
||||
|
||||
export interface WalkTarget {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
kind?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to a symbol, recording how you got there.
|
||||
*
|
||||
* @param dir 'down' following a call, 'up' going to a caller, 'start' for a
|
||||
* jump that is neither (search, a breadcrumb, a members outline).
|
||||
* @param line a line to highlight and scroll to in the destination.
|
||||
*/
|
||||
export function walkTo(target: WalkTarget, dir: HopDirection, line?: number): void {
|
||||
trail.push({ id: target.id, name: target.name ?? null, kind: target.kind ?? null, dir });
|
||||
const href = symbolHref(target.id, { trail: encodeTrail(trail.hops), ...(line ? { line } : {}) });
|
||||
navigate(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the reader arrived from, and which rail should show it.
|
||||
*
|
||||
* A hop marked `up` means the reader stepped from a callee to this symbol, so
|
||||
* the symbol they left is one of THIS symbol's callees — the right rail. A
|
||||
* `down` hop is the mirror. A `start` hop came from nowhere on screen.
|
||||
*/
|
||||
export function arrivedFrom(): { id: string; rail: 'left' | 'right' } | null {
|
||||
const hops = trail.hops;
|
||||
if (hops.length < 2) return null;
|
||||
const current = hops[hops.length - 1];
|
||||
const previous = hops[hops.length - 2];
|
||||
if (!current || !previous) return null;
|
||||
if (current.dir === 'down') return { id: previous.id, rail: 'left' };
|
||||
if (current.dir === 'up') return { id: previous.id, rail: 'right' };
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user