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
+23 -4
View File
@@ -26,6 +26,7 @@ import { isTestFile } from '../../search/query-utils';
import { notFound } from './respond';
import { findIndexedFile, hasDriftedOnDisk } from './source';
import {
BLAST_DEPTH,
CALLER_EDGE_KINDS,
CONTAINER_KINDS,
HUB_THRESHOLD,
@@ -46,15 +47,23 @@ import {
type WireNodeRef,
} from './wire';
/** Depth the blast-radius summary walks. Matches `codegraph_explore`'s claim. */
const BLAST_DEPTH = 3;
/** A member row in the focal symbol's outline, with its place in the tree. */
export interface WireMember extends WireNodeRef {
/** The container this member belongs to — the focal node, or one of its children. */
parentId: string;
/** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
depth: number;
/**
* Edges in and out of this member — the outline's `← in → out` columns.
*
* A container's own fan-out is usually zero (a class calls nothing; its
* methods do), so without these an outline of a 700-line class says nothing
* about which member is load-bearing and which is a getter. Edge counts, not
* distinct counterparts: the column is a weight, and it sits beside a
* signature rather than beside a caller list it could contradict.
*/
fanIn: number;
fanOut: number;
}
export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
@@ -234,11 +243,21 @@ function buildMembers(
const all = [...direct, ...nested].sort(
(a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
);
const shown = all.slice(0, MAX_OUTLINE_NODES);
// Two queries for the whole outline, not two per row: a file with 400
// symbols would otherwise be 800 lookups behind one screen.
const memberIds = shown.map((entry) => entry.node.id);
const fanIn = cg.getFanIn(memberIds);
const fanOut = cg.getFanOut(memberIds);
return {
items: all.slice(0, MAX_OUTLINE_NODES).map((entry) => ({
items: shown.map((entry) => ({
...toNodeRef(entry.node),
parentId: entry.parentId,
depth: entry.depth,
fanIn: fanIn.get(entry.node.id) ?? 0,
fanOut: fanOut.get(entry.node.id) ?? 0,
})),
total: all.length,
};