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:
Colby McHenry
2026-08-27 00:09:47 -05:00
co-authored by Claude Opus 5
parent e7288ffa36
commit 5cecaabfc2
23 changed files with 4179 additions and 27 deletions
+268
View File
@@ -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);
}
+67
View File
@@ -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;
},
};
+340
View File
@@ -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;
}
}
+49
View File
@@ -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,
};
+511
View File
@@ -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}`;
}
+54
View File
@@ -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;
}