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
+357
View File
@@ -0,0 +1,357 @@
<!--
Called by — the left rail (design spec §3.2).
Grouped by file, the symbol's own file first as "same file", because the
first question about a caller is "is this local, or does it come from
somewhere else in the repo". The call-site chips (`:4657`) are the useful
part: clicking one opens the caller already scrolled to the line that makes
the call, which is the step a reader would otherwise do by hand.
This rail draws no connectors. It scrolls independently of the code, so a
line drawn to a caller row would point at the wrong place the moment either
side moved.
-->
<script lang="ts">
import KindGlyph from '../KindGlyph.svelte';
import { fileHref } from '../../lib/router.svelte';
import { hot, railFocus } from '../../lib/focus.svelte';
import { basename, plural, type CallerRailModel, type CallerRow } from '../../lib/symbol-model';
import type { WireNodeRef } from '../../lib/api';
interface Props {
model: CallerRailModel;
/** The symbol this one was reached from, when it is a caller. */
originId: string | null;
exported: boolean;
/** Follow a caller, optionally landing on one of its call sites. */
onstepUp: (node: WireNodeRef, line?: number) => void;
}
let { model, originId, exported, onstepUp }: Props = $props();
/** A file node's own "symbol" is the file's top level; say so. */
function rowName(node: WireNodeRef): string {
return node.kind === 'file' ? `${basename(node.file)} (top level)` : node.name;
}
function rowTitle(row: CallerRow): string {
return `${row.relation.node.qualifiedName} — ${row.relation.node.file}:${row.relation.node.line}`;
}
/**
* A row's place in the flat order the keyboard walks (file groups in order,
* folds excluded — arrowing into collapsed content would move a selection
* nobody can see). Computed from the group offsets so the rail can stay a
* nested render while the keyboard sees one list.
*/
function indexOf(groupIndex: number, rowIndex: number): number {
let base = 0;
for (let i = 0; i < groupIndex; i++) base += model.groups[i]?.rows.length ?? 0;
return base + rowIndex;
}
</script>
<div class="rail-h">
<span>Called by <span class="n">{model.total}</span></span>
<span class="hint">← step up</span>
</div>
{#if model.total === 0}
<div class="note">
Nothing in the graph calls or references this symbol{exported
? ' — it is exported, so callers may live outside the index (or it is an entry point).'
: '.'}
</div>
{/if}
{#each model.groups as group, groupIndex (group.file)}
<div class="filegroup">
<div class="fpath">
<a href={fileHref(group.file)} title={group.file}>{group.same ? 'same file' : group.file}</a>
<b>{group.rows.length}</b>
</div>
{#each group.rows as row, rowIndex (row.relation.node.id)}
{@const node = row.relation.node}
{@const isOrigin = node.id === originId}
<div
class="row"
class:origin={isOrigin}
class:sel={railFocus.at('left', indexOf(groupIndex, rowIndex))}
role="button"
tabindex="0"
title={rowTitle(row)}
onclick={() => onstepUp(node)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onstepUp(node);
}
}}
onmouseenter={() => hot.set(node.id)}
onmouseleave={() => hot.clear(node.id)}
>
<KindGlyph kind={node.kind} />
<div>
<div class="nm">{rowName(node)}</div>
<div class="meta">
{#if row.words.length > 0}<span class="kindlbl">{row.words.join(', ')}</span>{/if}
{#each row.lines as line (line)}
<button
type="button"
class="chip"
title={`Open ${node.name} at line ${line}`}
onclick={(e) => {
e.stopPropagation();
onstepUp(node, line);
}}>:{line}</button
>
{/each}
{#if row.via}<span class="kindlbl">via {row.via}</span>{/if}
{#if isOrigin}<span class="kindlbl">you came from here</span>{/if}
</div>
</div>
</div>
{/each}
</div>
{/each}
{#if model.tests.rows.length > 0}
<details class="fold">
<summary>
Tests <span class="dim"
>· {plural(model.tests.calls, 'call')} from {plural(model.tests.files.length, 'file')}</span
>
</summary>
<div class="body">
{#each model.tests.rows as row (row.relation.node.id)}
{@const node = row.relation.node}
<div
class="row"
role="button"
tabindex="0"
title={rowTitle(row)}
onclick={() => onstepUp(node)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onstepUp(node);
}
}}
onmouseenter={() => hot.set(node.id)}
onmouseleave={() => hot.clear(node.id)}
>
<KindGlyph kind={node.kind} />
<div>
<div class="nm">{rowName(node)}</div>
<div class="meta"><span class="kindlbl">{node.file}</span></div>
</div>
</div>
{/each}
</div>
</details>
{/if}
{#if model.uncertain.length > 0}
<details class="fold">
<summary>
Uncertain <span class="dim"
>· {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'},
confidence &lt; 0.6</span
>
</summary>
<div class="body">
{#each model.uncertain as row (row.relation.node.id)}
{@const node = row.relation.node}
<div
class="row uncertain"
role="button"
tabindex="0"
title={rowTitle(row)}
onclick={() => onstepUp(node)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onstepUp(node);
}
}}
onmouseenter={() => hot.set(node.id)}
onmouseleave={() => hot.clear(node.id)}
>
<KindGlyph kind={node.kind} />
<div>
<div class="nm">{rowName(node)}</div>
<div class="meta">
<span class="kindlbl">{basename(node.file)}</span>
{#if row.relation.confidence !== null}
<span class="kindlbl">{row.relation.confidence}</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
</details>
{/if}
{#if model.hiddenGroups > 0}
<div class="note">
+{model.hiddenGroups} more caller{model.hiddenGroups === 1 ? '' : 's'} not shown — this symbol
has more than the rail lists.
</div>
{/if}
<style>
.rail-h {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 12px 14px 8px;
border-bottom: 1px solid var(--rule-soft);
background: var(--paper);
font-weight: 600;
font-size: 13px;
}
.rail-h .n {
color: var(--ink-3);
font-weight: 400;
}
.rail-h .hint {
color: var(--ink-3);
font-weight: 400;
font-size: 11.5px;
}
.filegroup {
padding: 10px 14px 4px;
}
.fpath {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
color: var(--ink-3);
font: 11px var(--mono);
}
.fpath a:hover {
color: var(--ink);
text-decoration: underline;
}
.fpath b {
color: var(--ink-2);
font-weight: 500;
}
.row {
position: relative;
display: grid;
grid-template-columns: 16px 1fr;
gap: 8px;
align-items: start;
margin: 0 -6px;
padding: 5px 6px 5px 4px;
border: 1px solid transparent;
cursor: pointer;
}
.row:hover {
background: var(--press);
}
.row.sel {
border-color: var(--ink);
}
.row.origin {
border-color: var(--accent-line);
background: var(--accent-soft);
}
.nm {
overflow: hidden;
color: var(--ink);
font: 12.5px var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.row.uncertain .nm {
color: var(--ink-2);
text-decoration: underline dotted var(--ink-4);
text-underline-offset: 3px;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
align-items: baseline;
margin-top: 1px;
color: var(--ink-3);
font-size: 11px;
}
.kindlbl {
color: var(--ink-3);
}
.chip {
padding: 0 4px;
border: 1px solid var(--rule-soft);
background: var(--paper);
color: var(--ink-2);
font: 11px var(--mono);
}
.chip:hover {
border-color: var(--ink);
color: var(--ink);
}
.fold {
padding: 8px 14px;
}
.fold > summary {
display: flex;
gap: 6px;
align-items: baseline;
color: var(--ink-2);
cursor: pointer;
font-size: 12px;
list-style: none;
}
.fold > summary::-webkit-details-marker {
display: none;
}
.fold > summary::before {
content: '+';
width: 10px;
color: var(--ink-3);
font-family: var(--mono);
}
.fold[open] > summary::before {
content: '−';
}
.fold .body {
padding: 6px 0 0 16px;
}
.note {
padding: 8px 14px;
color: var(--ink-3);
font-size: 11.5px;
line-height: 1.4;
}
</style>