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
+9 -6
View File
@@ -10,13 +10,16 @@
import NotFoundView from './views/NotFoundView.svelte';
import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
import { trail } from './lib/trail.svelte';
import { project } from './lib/project.svelte';
// Filled by the project stats call once the JSON API exists (CG-42); the
// top bar renders nothing rather than a placeholder until then.
let project = $state<string | null>(null);
let stats = $state<string | null>(null);
let query = $state('');
// One `/api/stats` for the whole app: the top bar's counts and the Symbol
// view's blast-radius denominator come out of the same payload.
$effect(() => {
void project.ensure();
});
let topbar: TopBar | null = $state(null);
let route = $derived(router.route);
@@ -81,7 +84,7 @@
<svelte:window {onkeydown} />
<TopBar bind:this={topbar} bind:query {project} {stats} />
<TopBar bind:this={topbar} bind:query project={project.name} stats={project.summary} />
<TrailBar />
<main>
{#if route.view === 'symbol'}
@@ -95,7 +98,7 @@
{:else if route.view === 'unknown'}
<NotFoundView path={route.path} />
{:else}
<HomeView {project} />
<HomeView project={project.name} />
{/if}
</main>
+11 -2
View File
@@ -31,8 +31,17 @@
{:else}
{#each hops as hop, i (hop.id)}
{#if i > 0}
<span class="hop-arrow" class:up={hop.dir === 'up'} aria-hidden="true">
{hop.dir === 'up' ? '←' : '→'}
<span
class="hop-arrow"
class:up={hop.dir === 'up'}
title={hop.dir === 'up'
? 'stepped up to a caller'
: hop.dir === 'down'
? 'stepped down into a call'
: 'jumped here'}
aria-hidden="true"
>
{hop.dir === 'up' ? '←' : hop.dir === 'down' ? '→' : '·'}
</span>
{/if}
<button
+202
View File
@@ -0,0 +1,202 @@
<!--
What would need re-checking if this symbol changed (design spec §3.2).
The bar exists because the numbers alone do not answer the question a reader
actually has, which is comparative: is 19 dependents a lot? So both fills are
drawn against the widest radius in the index (`/api/stats` → `blastScale`),
and the legend says so rather than letting a full-width bar imply "everything".
The scale is sampled, not exhaustive — measuring every symbol's radius means a
traversal per symbol. When the symbol on screen is wider than the sample found,
it becomes the scale instead of overflowing it: a bar that runs past its track
is a drawing bug, and clamping silently would be a lie about the comparison.
-->
<script lang="ts">
import { fileHref } from '../../lib/router.svelte';
import { plural } from '../../lib/symbol-model';
import type { WireBlastScale, WireBlastSummary } from '../../lib/api';
interface Props {
blast: WireBlastSummary;
scale: WireBlastScale | null;
/** Calls from test files — the tests that would catch a regression. */
testCalls: number;
testFiles: number;
}
let { blast, scale, testCalls, testFiles }: Props = $props();
let maxDirect = $derived(Math.max(1, scale?.maxDirect ?? 0, blast.direct));
let maxWithin = $derived(Math.max(1, scale?.maxWithinHops ?? 0, blast.withinHops));
const share = (value: number, max: number): number =>
value <= 0 ? 0 : Math.max(0.5, Math.min(100, (100 * value) / max));
</script>
<div class="blast">
<div class="bh">
<b>Blast radius</b>
<span class="stat"><strong>{blast.direct}</strong> direct dependent{blast.direct === 1 ? '' : 's'}</span>
<span class="stat"><strong>{blast.withinHops}</strong> within {blast.hops} hops</span>
<span class="stat"><strong>{blast.files}</strong> file{blast.files === 1 ? '' : 's'}</span>
<span class="stat"><strong>{blast.testFiles}</strong> test file{blast.testFiles === 1 ? '' : 's'}</span>
{#if blast.routes > 0}
<span class="stat"><strong>{blast.routes}</strong> route{blast.routes === 1 ? '' : 's'}</span>
{/if}
</div>
<div
class="bar"
title={`Scaled to the widest radius in the index: ${maxWithin} symbols within ${blast.hops} hops.`}
>
<i style:width={`${share(blast.withinHops, maxWithin)}%`}></i>
<i class="direct" style:width={`${share(blast.direct, maxDirect)}%`}></i>
</div>
<div class="legend">
dark: direct dependents · light: within {blast.hops} hops — scaled to the widest radius in the
index{#if scale?.estimated}{' '}<span class="dim"
>(measured across its {scale.sampled} most-depended-on symbols)</span
>{/if}
</div>
{#if blast.topFiles.length > 0}
<details>
<summary>What would need re-checking if this changed</summary>
<div class="body">
{#each blast.topFiles as entry (entry.file)}
<div class="fp">
<a href={fileHref(entry.file)} class:test={entry.test}>{entry.file}</a>
<b>{entry.symbols}</b>
</div>
{/each}
{#if blast.files > blast.topFiles.length}
<div class="fp dim">+{blast.files - blast.topFiles.length} more files</div>
{/if}
{#if testCalls > 0}
<div class="note">
plus {plural(testCalls, 'call')} from {plural(testFiles, 'test file')} — the tests that
would catch a regression.
</div>
{/if}
</div>
</details>
{/if}
</div>
<style>
.blast {
margin-top: 22px;
padding-top: 10px;
border-top: 1px solid var(--rule);
}
.bh {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 14px;
}
.bh b {
font-weight: 600;
}
.stat {
color: var(--ink-2);
font-size: 12.5px;
}
.stat strong {
color: var(--ink);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.bar {
position: relative;
max-width: 420px;
height: 6px;
margin-top: 8px;
background: var(--press);
}
.bar i {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: var(--ink-2);
}
/* Drawn second so the shorter, darker "direct" share sits over the lighter
"within N hops" one rather than beside it — they are nested quantities. */
.bar i.direct {
background: var(--ink);
}
.legend {
margin-top: 4px;
color: var(--ink-3);
font-size: 11.5px;
}
details {
margin-top: 8px;
}
summary {
color: var(--ink-2);
cursor: pointer;
font-size: 12px;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::before {
content: '+ ';
color: var(--ink-3);
font-family: var(--mono);
}
details[open] summary::before {
content: '− ';
}
.body {
padding-top: 6px;
}
.fp {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 2px 0;
color: var(--ink-2);
font: 11px var(--mono);
}
.fp a:hover {
color: var(--ink);
text-decoration: underline;
}
.fp a.test {
color: var(--ink-3);
}
.fp b {
color: var(--ink);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.note {
padding-top: 6px;
color: var(--ink-3);
font-size: 11.5px;
}
</style>
+280
View File
@@ -0,0 +1,280 @@
<!--
Calls — the right rail (design spec §3.2).
Every row is absolutely positioned beside the line that makes the call, which
is the whole idea of the screen: the callee list is not a list, it is an
annotation of the body. Rows keep source order and are pushed down when two
call sites are closer together than a row is tall, so the sequence still reads
top to bottom even where the geometry cannot be exact.
The tops are computed by the view, which is the only thing that can measure
where a line ended up. This component draws what it is told.
-->
<script lang="ts">
import KindGlyph from '../KindGlyph.svelte';
import { hot, railFocus } from '../../lib/focus.svelte';
import { plural, type CalleeRailModel, type CalleeRow } from '../../lib/symbol-model';
import type { WireNodeRef } from '../../lib/api';
interface Props {
model: CalleeRailModel;
/** Top offset in px for each row in `model.rows`, same order. */
tops: number[];
foldTop: number;
noteTop: number;
/** The focal symbol's file — a callee in it reads "same file", not a path. */
focalFile: string;
/** The symbol this one was reached from, when it is a callee. */
originId: string | null;
/** Empty-rail wording depends on why it is empty. */
emptyReason: string;
onstepDown: (node: WireNodeRef) => void;
}
let { model, tops, foldTop, noteTop, focalFile, originId, emptyReason, onstepDown }: Props =
$props();
function rowTitle(row: CalleeRow): string {
return `${row.relation.node.qualifiedName} — ${row.relation.node.file}:${row.relation.node.line}`;
}
</script>
<div class="rail-h" data-rail-header>
<span>Calls <span class="n">{model.rows.length}</span></span>
<span class="hint">step down →</span>
</div>
{#each model.rows as row, i (row.relation.node.id)}
{@const node = row.relation.node}
<div
class="rrow"
class:origin={node.id === originId}
class:hot={hot.is(node.id)}
class:sel={railFocus.at('right', i)}
style:top={`${tops[i] ?? 0}px`}
data-target={node.id}
role="button"
tabindex="0"
title={rowTitle(row)}
onclick={() => onstepDown(node)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onstepDown(node);
}
}}
onmouseenter={() => hot.set(node.id)}
onmouseleave={() => hot.clear(node.id)}
>
<KindGlyph kind={node.kind} />
<div class="body">
<div class="nm">
{node.name}{#if row.lines.length > 1}<span class="dim"> ×{row.lines.length}</span>{/if}
</div>
<div class="meta">
<span>{node.file === focalFile ? 'same file' : node.file}</span>
{#if row.words.length > 0}<span>{row.words.join(', ')}</span>{/if}
{#if row.relation.hub}<span class="tag">hub · {row.relation.fanIn}</span>{/if}
{#if row.via}<span class="tag" title="A synthesized edge — dynamic dispatch the parser cannot see"
>via {row.via}</span
>{/if}
</div>
</div>
</div>
{/each}
{#if model.uncertain.length > 0}
<details class="rfold" data-rail-fold style:top={`${foldTop}px`}>
<summary>
Uncertain <span class="dim"
>· {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'},
confidence &lt; 0.6</span
>
</summary>
<div class="fold-body">
{#each model.uncertain as row (row.relation.node.id)}
{@const node = row.relation.node}
<div
class="rrow static uncertain"
class:hot={hot.is(node.id)}
data-target={node.id}
role="button"
tabindex="0"
title={rowTitle(row)}
onclick={() => onstepDown(node)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onstepDown(node);
}
}}
onmouseenter={() => hot.set(node.id)}
onmouseleave={() => hot.clear(node.id)}
>
<KindGlyph kind={node.kind} />
<div class="body">
<div class="nm">{node.name}</div>
<div class="meta">
<span>{node.file}</span>
{#if row.relation.confidence !== null}<span>{row.relation.confidence}</span>{/if}
</div>
</div>
</div>
{/each}
</div>
</details>
{/if}
{#if model.rows.length === 0 && model.uncertain.length === 0}
<div class="rnote" style:top="60px">{emptyReason}</div>
{:else if model.outsideCalls > 0 || model.outsideTypeRefs > 0 || model.hiddenGroups > 0}
<div class="rnote" style:top={`${noteTop}px`}>
{#if model.outsideCalls > 0}
+{plural(model.outsideCalls, 'more call')} into symbols outside the index{#if model.outsideTypeRefs > 0}{' '}·
{plural(model.outsideTypeRefs, 'type reference')}{/if}.
{:else if model.outsideTypeRefs > 0}
{plural(model.outsideTypeRefs, 'type reference')} into symbols outside the index.
{/if}
{#if model.hiddenGroups > 0}
<br />+{model.hiddenGroups} more callee{model.hiddenGroups === 1 ? '' : 's'} not shown.
{/if}
</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;
}
.rrow {
position: absolute;
right: 12px;
left: 14px;
display: grid;
grid-template-columns: 16px 1fr;
gap: 8px;
align-items: center;
height: 34px;
padding: 0 6px;
border: 1px solid transparent;
cursor: pointer;
}
/* Inside the uncertain fold the rows are a list again — nothing to line up
with, because an unresolved edge has no trustworthy call site. */
.rrow.static {
position: static;
height: auto;
padding: 4px 6px;
}
.rrow:hover {
background: var(--press);
}
.rrow.sel {
border-color: var(--ink);
}
.rrow.hot {
border-color: var(--accent-line);
background: var(--accent-soft);
}
.rrow.origin {
background: var(--accent-soft);
}
.body {
min-width: 0;
}
.nm {
overflow: hidden;
font: 12.5px var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.rrow.uncertain .nm {
color: var(--ink-2);
text-decoration: underline dotted var(--ink-4);
text-underline-offset: 3px;
}
.meta {
display: flex;
gap: 8px;
overflow: hidden;
color: var(--ink-3);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
flex: 0 0 auto;
padding: 0 4px;
border: 1px solid var(--rule-soft);
color: var(--ink-3);
font-size: 10.5px;
}
.rfold {
position: absolute;
right: 12px;
left: 14px;
}
.rfold summary {
padding: 6px;
color: var(--ink-2);
cursor: pointer;
font-size: 12px;
list-style: none;
}
.rfold summary::-webkit-details-marker {
display: none;
}
.rfold summary::before {
content: '+ ';
color: var(--ink-3);
font-family: var(--mono);
}
.rfold[open] summary::before {
content: '− ';
}
.rnote {
position: absolute;
right: 12px;
left: 20px;
color: var(--ink-3);
font-size: 11.5px;
line-height: 1.4;
}
</style>
+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>
@@ -0,0 +1,76 @@
<!--
The hairlines from a gutter port to its callee row (design spec §3.2).
One curve per CALL SITE, not per row: a helper called from three lines gets
three connectors into one row, which is the honest drawing — the row is the
symbol, the curves are the calls.
Line style carries the claim. Solid means the resolver matched it; dashed
`2 3` means it is a name-only guess; dashed `6 3` in a lighter ink means the
edge was synthesized rather than parsed (dynamic dispatch), so the reader can
see at a glance which parts of the picture the parser actually saw.
-->
<script lang="ts">
import { hot } from '../../lib/focus.svelte';
import type { Connector } from '../../lib/symbol-model';
interface Props {
connectors: Connector[];
width: number;
height: number;
}
let { connectors, width, height }: Props = $props();
</script>
<svg
class="overlay"
{width}
{height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden="true"
focusable="false"
>
{#each connectors as connector, i (`${connector.targetId}:${i}`)}
<path
d={connector.d}
class:uncertain={connector.uncertain}
class:heur={connector.heuristic}
class:origin={connector.origin}
class:hot={hot.is(connector.targetId)}
/>
{/each}
</svg>
<style>
.overlay {
position: absolute;
inset: 0;
overflow: visible;
pointer-events: none;
}
path {
fill: none;
stroke: var(--ink-4);
stroke-width: 1;
}
path.uncertain {
stroke-dasharray: 2 3;
}
path.heur {
stroke: var(--ink-3);
stroke-dasharray: 6 3;
}
path.origin {
stroke: var(--accent);
}
path.hot {
stroke: var(--accent);
stroke-width: 1.5;
}
</style>
@@ -0,0 +1,124 @@
<!--
A container's members in source order, with the two numbers that say which
one to open (design spec §3.2).
This replaces the body for anything over 80 lines, and the `← in → out`
columns are why it is a better view than the body rather than a poorer one:
a class's own fan-out is nearly always zero because a class calls nothing —
its methods do — so scrolling 700 lines of braces tells you less about where
the weight sits than twenty rows with their edge counts.
-->
<script lang="ts">
import KindGlyph from '../KindGlyph.svelte';
import type { WireNodeRef } from '../../lib/api';
import type { OutlineRow } from '../../lib/symbol-model';
interface Props {
rows: OutlineRow[];
total: number;
truncated: boolean;
onopen: (node: WireNodeRef) => void;
}
let { rows, total, truncated, onopen }: Props = $props();
</script>
<div class="subh">
<span>Members</span>
<span class="n">{total}</span>
</div>
<div class="outline">
{#each rows as row (row.member.id)}
<button
type="button"
class="orow"
class:nested={row.nested}
class:dimmed={row.dimmed}
onclick={() => onopen(row.member)}
title={`${row.member.qualifiedName} — ${row.member.file}:${row.member.line}`}
>
<KindGlyph kind={row.member.kind} />
<span class="nm">{row.member.name}</span>
<span class="sig">{row.member.signature ?? ''}</span>
<span class="cnt">
{#if row.member.fanIn}← {row.member.fanIn}{/if}{#if row.member.fanIn && row.member.fanOut}&nbsp;
{/if}{#if row.member.fanOut}→ {row.member.fanOut}{/if}
</span>
</button>
{/each}
</div>
{#if truncated}
<div class="note">
Showing {rows.length} of {total} members — open the file to see the rest.
</div>
{/if}
<style>
.subh {
display: flex;
align-items: baseline;
gap: 8px;
margin: 18px 0 4px;
font-weight: 600;
font-size: 13px;
}
.subh .n {
color: var(--ink-3);
font-weight: 400;
}
.outline {
border-top: 1px solid var(--rule);
}
.orow {
display: grid;
grid-template-columns: 16px minmax(160px, auto) 1fr auto;
gap: 10px;
align-items: baseline;
width: 100%;
padding: 6px 4px;
border-bottom: 1px solid var(--rule-faint);
text-align: left;
}
.orow:hover {
background: var(--press);
}
.orow.nested {
padding-left: 22px;
}
.nm {
font: 12.5px var(--mono);
}
.orow.dimmed .nm {
color: var(--ink-3);
}
.sig {
overflow: hidden;
color: var(--ink-3);
font: 11.5px var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.cnt {
color: var(--ink-3);
font: 11px var(--mono);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.note {
padding: 8px 0;
color: var(--ink-3);
font-size: 11.5px;
}
</style>
+271
View File
@@ -0,0 +1,271 @@
<!--
The verbatim body, with a gutter port on every line that has an outgoing
edge and an accent link on every call site (design spec §3.2).
Two things make this more than a <pre>:
* The lexer state is threaded across lines AND across the gaps between
windows, so the first line after a skipped block is not mis-read as the
inside of a comment that closed 200 lines ago.
* Each ref is matched to an actual token rather than to a column, because the
recorded column points at the start of the calling expression — see
`assignRefs`.
-->
<script lang="ts">
import { newLexState, tokenClass, tokenize, type Token } from '../../lib/highlight';
import { assignRefs, type CodeBlock, type LineRef } from '../../lib/symbol-model';
import { hot } from '../../lib/focus.svelte';
interface Props {
block: CodeBlock;
language: string;
refs: Map<number, LineRef[]>;
/** The line the definition's own name sits on — it is set in bold there. */
defLine: number;
defName: string;
/** Line from `?hl=` — tinted and scrolled to. */
highlight: number | null;
onfollow: (ref: LineRef) => void;
}
let { block, language, refs, defLine, defName, highlight, onfollow }: Props = $props();
interface Part {
text: string;
cls: string | null;
ref: LineRef | null;
def: boolean;
}
interface RenderedLine {
n: number;
parts: Part[];
/** 'sure' = at least one resolved edge here; 'unsure' = only guesses. */
port: 'sure' | 'unsure' | null;
/** Targets named on this line, so a hovered rail row can light it. */
targets: string[];
}
interface Chunk {
/** Lines skipped before this window; 0 for the first. */
gapBefore: number;
lines: RenderedLine[];
}
let chunks = $derived.by<Chunk[]>(() => {
const state = newLexState();
return block.windows.map((window, windowIndex) => ({
gapBefore: windowIndex === 0 ? 0 : (block.gapsAfter[windowIndex - 1] ?? 0),
lines: window.lines.map((text, offset) => {
const n = window.start + offset;
const tokens = tokenize(text, state, language);
const lineRefs = refs.get(n) ?? [];
const claimed = assignRefs(tokens, lineRefs);
return {
n,
parts: toParts(tokens, claimed, n === defLine ? defName : null),
port: portFor(lineRefs),
targets: [...new Set(lineRefs.map((r) => r.targetId).filter((id): id is string => !!id))],
};
}),
}));
});
function toParts(tokens: Token[], claimed: Map<number, LineRef>, definition: string | null): Part[] {
return tokens.map((token, index) => {
const ref = claimed.get(index) ?? null;
return {
text: token.text,
cls: ref ? null : tokenClass(token.cls),
ref,
def: !ref && definition !== null && token.cls === 'ident' && token.text === definition,
};
});
}
/**
* A filled port means the graph resolved something on this line; a hollow one
* means it only guessed. A line with no outgoing edge has no port at all —
* absence is the signal, so an empty gutter must stay empty.
*/
function portFor(lineRefs: readonly LineRef[]): 'sure' | 'unsure' | null {
if (lineRefs.length === 0) return null;
return lineRefs.some((r) => !r.uncertain && !r.outside) ? 'sure' : 'unsure';
}
function isHot(line: RenderedLine): boolean {
return line.n === highlight || line.targets.some((id) => hot.is(id));
}
</script>
<div class="code">
{#each chunks as chunk (chunk.lines[0]?.n ?? -1)}
{#if chunk.gapBefore > 0}
<div class="gap">⋯ {chunk.gapBefore} lines without calls</div>
{/if}
{#each chunk.lines as line (line.n)}
<div class="ln" class:hot={isHot(line)} data-line={line.n}>
<span class="no">{line.n}</span>
<span class="tx"
>{#each line.parts as part, i (i)}{#if part.ref && !part.ref.outside}{@const ref = part.ref}<span
class="ref"
class:uncertain={ref.uncertain}
class:hot={hot.is(ref.targetId)}
role="link"
tabindex="0"
title={ref.title}
onclick={() => onfollow(ref)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onfollow(ref);
}
}}
onmouseenter={() => hot.set(ref.targetId)}
onmouseleave={() => hot.clear(ref.targetId)}>{part.text}</span
>{:else if part.ref}<span class="ref stub" title={part.ref.title}>{part.text}</span
>{:else if part.def}<span class="t-def">{part.text}</span
>{:else if part.cls}<span class={part.cls}>{part.text}</span
>{:else}{part.text}{/if}{/each}</span
>
<span class="port">
{#if line.port}<i class:sure={line.port === 'sure'}></i>{/if}
</span>
</div>
{/each}
{/each}
{#if block.tailGap > 0}
<div class="gap">⋯ {block.tailGap} more lines</div>
{/if}
</div>
<style>
.code {
margin-top: 16px;
padding-top: 6px;
border-top: 1px solid var(--rule);
font: var(--code-size) / var(--code-lh) var(--mono);
}
/* 44px gutter | source | 18px port cell. The port lives in its own column
so a long line scrolling sideways never slides under it. */
.ln {
position: relative;
display: grid;
grid-template-columns: 44px 1fr 18px;
align-items: stretch;
}
.ln:hover {
background: var(--paper-2);
}
.ln.hot {
background: var(--accent-soft);
}
.no {
padding-right: 12px;
color: var(--ink-4);
font-size: 11px;
text-align: right;
user-select: none;
}
.tx {
white-space: pre;
overflow-x: auto;
scrollbar-width: none;
}
.tx::-webkit-scrollbar {
display: none;
}
.port {
position: relative;
}
.port i {
position: absolute;
top: 7px;
right: 4px;
width: 6px;
height: 6px;
border: 1px solid var(--ink-3);
background: var(--paper);
}
.port i.sure {
background: var(--ink-3);
}
.ln.hot .port i {
border-color: var(--accent);
background: var(--accent);
}
.gap {
margin: 2px 0;
padding: 2px 0 2px 44px;
border-top: 1px dashed var(--rule-soft);
border-bottom: 1px dashed var(--rule-soft);
color: var(--ink-4);
font-size: 11px;
}
/* ---- token classes (near-monochrome by design, spec §2.2) ---- */
.t-c {
color: var(--ink-3);
}
.t-s {
color: var(--ink-2);
}
.t-k {
font-weight: 500;
}
.t-n {
color: var(--ink-2);
}
.t-def {
font-weight: 600;
}
/* The only colour in the body: a call site the graph resolved. */
.ref {
color: var(--accent);
cursor: pointer;
text-decoration: underline;
text-decoration-color: var(--accent-line);
text-underline-offset: 3px;
}
.ref:hover,
.ref.hot {
background: var(--accent-soft);
text-decoration-color: var(--accent);
}
.ref.uncertain {
color: var(--ink-2);
text-decoration-style: dotted;
text-decoration-color: var(--ink-4);
}
/* Outside the index: there is nothing to open, so it does not offer to. */
.ref.stub {
color: var(--ink-2);
cursor: default;
text-decoration-color: var(--rule-soft);
}
.ref.stub:hover {
background: none;
text-decoration-color: var(--rule-soft);
}
</style>
@@ -0,0 +1,286 @@
<!--
The focus card: what this symbol is, where it lives, and the three claims
worth making before the body (design spec §3.2).
The badges are the honesty layer. "exported" and "hub · N callers" are facts
about reach; the test badge is the one that changes behaviour — an amber
"No test reaches this within 3 caller hops" is the difference between editing
freely and editing carefully, so it is stated in the header rather than left
to be inferred from an empty rail.
-->
<script lang="ts">
import KindGlyph from '../KindGlyph.svelte';
import { fileHref } from '../../lib/router.svelte';
import { kindPhrase, plural } from '../../lib/symbol-model';
import type {
WireNodeDetail,
WireNodeRef,
WireRelation,
WireSymbolPayload,
} from '../../lib/api';
interface Props {
payload: WireSymbolPayload;
onopen: (node: WireNodeRef) => void;
}
let { payload, onopen }: Props = $props();
let node = $derived<WireNodeDetail>(payload.node);
let tests = $derived(payload.tests);
/** `extends`/`implements` this symbol declares, and the ones declared on it. */
let supertypes = $derived(
payload.outgoing.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
);
let subtypes = $derived(
payload.incoming.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
);
const TYPE_CHIP_LIMIT = 12;
let typeChips = $derived(payload.typesUsed.slice(0, TYPE_CHIP_LIMIT));
function relationWord(relation: WireRelation): string {
return relation.edgeKinds.includes('implements') ? 'implements' : 'extends';
}
/**
* The test claim, worded to exactly what was checked. An interrupted search
* (`exhaustive: false`) only ever established that no test calls the symbol
* directly, so the badge must not widen that to three hops.
*/
let testBadge = $derived.by(() => {
if (tests.reached) {
return {
warn: false,
text: `Reached by tests · ${plural(tests.fileCount, 'file')} within ${tests.hopsSearched} hop${tests.hopsSearched === 1 ? '' : 's'}`,
title: tests.files.join(', '),
};
}
return {
warn: true,
text: tests.exhaustive
? `No test reaches this within ${tests.hopsSearched} caller hops`
: 'No test calls this directly',
title: tests.exhaustive
? 'No test file reaches this symbol within the caller hops searched.'
: 'The caller search ran out of budget — only direct callers were checked.',
};
});
</script>
<div class="card-h">
<KindGlyph kind={node.kind} titled />
<h1>{node.name}</h1>
<span class="kindword">{kindPhrase(node)}</span>
<span class="loc mono">
<a href={fileHref(node.file, { line: node.line })}>{node.file}</a>:{node.line}–{node.endLine}
· {plural(node.lines, 'line')}
</span>
</div>
{#if payload.ancestors.length > 0}
<div class="parents mono">
in {#each payload.ancestors as ancestor, i (ancestor.id)}{#if i > 0}<span class="sep"> › </span
>{/if}<button type="button" onclick={() => onopen(ancestor)}>{ancestor.name}</button
>{/each}
</div>
{/if}
<div class="badges">
{#if node.exported}<span class="badge">exported</span>{/if}
{#if payload.counts.hub}
<span class="badge hub" title="Changing this reaches a lot of the repo">
hub · {plural(payload.counts.callers, 'caller')}
</span>
{/if}
{#if payload.drift}
<span class="badge warn" title="The line ranges below come from the last index sync">
<span class="sw"></span>changed on disk after the last index sync
</span>
{/if}
<span class="badge" class:warn={testBadge.warn} title={testBadge.title}>
<span class="sw"></span>{testBadge.text}
</span>
</div>
{#if node.signature}
<div class="sig">{node.name}{node.signature}</div>
{/if}
{#if node.docstring}
<div class="doc">{node.docstring}</div>
{/if}
{#if supertypes.length > 0 || subtypes.length > 0 || typeChips.length > 0}
<div class="rel">
{#if supertypes.length > 0}
<span>
{#each supertypes as relation (relation.node.id)}
{relationWord(relation)}
<button type="button" class="chip" onclick={() => onopen(relation.node)}>
{relation.node.name}
</button>
{/each}
</span>
{/if}
{#if subtypes.length > 0}
<span>
{subtypes[0]?.edgeKinds.includes('implements') ? 'implemented by' : 'extended by'}
{#each subtypes as relation (relation.node.id)}
<button type="button" class="chip" onclick={() => onopen(relation.node)}>
{relation.node.name}
</button>
{/each}
</span>
{/if}
{#if typeChips.length > 0}
<span>
uses types
{#each typeChips as relation (relation.node.id)}
<button type="button" class="chip" onclick={() => onopen(relation.node)}>
{relation.node.name}
</button>
{/each}
{#if payload.typesUsed.length > TYPE_CHIP_LIMIT}
<span class="dim">+{payload.typesUsed.length - TYPE_CHIP_LIMIT}</span>
{/if}
</span>
{/if}
</div>
{/if}
<style>
.card-h {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 12px;
}
.card-h h1 {
margin: 0;
font: 600 20px/1.2 var(--mono);
letter-spacing: -0.01em;
}
.kindword {
color: var(--ink-3);
font-size: 12.5px;
}
.loc {
color: var(--ink-2);
font-size: 11.5px;
}
.loc a:hover {
text-decoration: underline;
}
.parents {
margin-top: 6px;
color: var(--ink-3);
font-size: 11.5px;
}
.parents button {
color: inherit;
font: inherit;
}
.parents button:hover {
color: var(--ink);
text-decoration: underline;
}
.parents .sep {
color: var(--ink-4);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border: 1px solid var(--rule-soft);
background: var(--paper);
color: var(--ink-2);
font-size: 11.5px;
}
/* Amber is used here and nowhere else in the app. */
.badge.warn {
border-color: var(--amber);
background: var(--amber-soft);
color: var(--amber);
}
.badge.hub {
border-color: var(--ink);
}
.sw {
display: inline-block;
width: 8px;
height: 8px;
border: 1px solid currentColor;
}
.badge.warn .sw {
background: currentColor;
}
.sig {
margin-top: 10px;
color: var(--ink-2);
font: 12px var(--mono);
white-space: pre-wrap;
word-break: break-word;
}
.doc {
margin-top: 8px;
max-width: 70ch;
color: var(--ink-2);
font-size: 12.5px;
white-space: pre-wrap;
}
.rel {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
margin-top: 10px;
color: var(--ink-3);
font-size: 12px;
}
.rel > span {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
}
.chip {
padding: 1px 6px;
border: 1px solid var(--rule-soft);
background: var(--paper);
color: var(--ink-2);
font: 11.5px var(--mono);
}
.chip:hover {
border-color: var(--ink);
color: var(--ink);
}
</style>
+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;
}
+529 -14
View File
@@ -1,32 +1,547 @@
<!--
Placeholder for the core screen: callers | verbatim source with gutter
ports | line-anchored callee rail (design spec §3.2, task CG-44). It needs
the read-only JSON API (CG-42), so until that lands this states the target
rather than faking a reader.
The Symbol view: callers | verbatim source with gutter ports | line-anchored
callee rail (design spec §3.2, task CG-44).
The geometry is the point of the screen, and it is the one thing that cannot
be derived from the payload: where a callee row belongs depends on where its
call-site line ended up, which depends on the font, the window width, whether
a fold is open. So this component measures — after every render, on every
resize — and hands the rail and the overlay their coordinates. Everything
else it does is plumbing around that.
Two scroll containers, deliberately. The left rail scrolls alone; the centre
and the right rail scroll together inside the stage, because a callee row
that drifts away from its line is worse than no rail at all.
-->
<script lang="ts">
import { tick, untrack } from 'svelte';
import CalleeRail from '../components/symbol/CalleeRail.svelte';
import CallersRail from '../components/symbol/CallersRail.svelte';
import Connectors from '../components/symbol/Connectors.svelte';
import BlastStrip from '../components/symbol/BlastStrip.svelte';
import MembersOutline from '../components/symbol/MembersOutline.svelte';
import SourceBlock from '../components/symbol/SourceBlock.svelte';
import SymbolHeader from '../components/symbol/SymbolHeader.svelte';
import { ApiFailure, fetchSource, fetchSymbol, type WireNodeRef, type WireSource, type WireSymbolPayload } from '../lib/api';
import { hot, railFocus } from '../lib/focus.svelte';
import { project } from '../lib/project.svelte';
import {
buildCalleeRail,
buildCallerRail,
buildCodeBlock,
buildOutline,
graphCallLines,
refsByLine,
showsBody,
synthesizedBy,
type Connector,
type LineRef,
} from '../lib/symbol-model';
import { trail } from '../lib/trail.svelte';
import { arrivedFrom, walkTo } from '../lib/walk';
interface Props {
id: string;
line: number | null;
}
let { id, line }: Props = $props();
/* ------------------------------------------------------------ geometry -- */
/** Row height and the gap between two rows pushed apart — spec §3.2. */
const ROW_HEIGHT = 34;
const ROW_GAP = 6;
/** Fallback for the sticky rail header before it has been measured. */
const RAIL_HEADER_FALLBACK = 38;
/* --------------------------------------------------------------- state -- */
let payload = $state<WireSymbolPayload | null>(null);
let source = $state<WireSource | null>(null);
let failure = $state<ApiFailure | null>(null);
let loading = $state(true);
let innerEl = $state<HTMLDivElement | null>(null);
let centerEl = $state<HTMLElement | null>(null);
let railEl = $state<HTMLElement | null>(null);
let leftRailEl = $state<HTMLElement | null>(null);
let tops = $state<number[]>([]);
let foldTop = $state(0);
let noteTop = $state(0);
let stageMinHeight = $state(0);
let connectors = $state<Connector[]>([]);
let overlay = $state({ width: 0, height: 0 });
/* ---------------------------------------------------------------- data -- */
$effect(() => {
const wanted = id;
const controller = new AbortController();
untrack(() => load(wanted, controller.signal));
return () => controller.abort();
});
async function load(nodeId: string, signal: AbortSignal): Promise<void> {
loading = true;
failure = null;
payload = null;
source = null;
railFocus.reset();
hot.set(null);
void project.ensure();
let node: WireSymbolPayload;
try {
node = await fetchSymbol(nodeId, signal);
} catch (cause) {
if (signal.aborted) return;
failure = asFailure(cause);
loading = false;
return;
}
if (signal.aborted) return;
payload = node;
loading = false;
trail.resolve(nodeId, { name: node.node.name, kind: node.node.kind });
// The body is only fetched when it will be drawn: a 2,000-line file node
// shows its outline, and asking for 2,000 lines to throw them away is the
// difference between a screen that settles at once and one that does not.
if (!showsBody(node.node.kind, node.node.lines) || node.drift) return;
try {
const slice = await fetchSource(node.node.file, node.node.line, node.node.endLine, signal);
if (!signal.aborted) source = slice;
} catch {
// No slice: the header, the rails and the blast strip are all still
// true, so the screen loses the body and says so rather than erroring.
}
}
function asFailure(cause: unknown): ApiFailure {
if (cause instanceof ApiFailure) return cause;
return new ApiFailure(0, 'error', cause instanceof Error ? cause.message : String(cause), null);
}
/* -------------------------------------------------------------- models -- */
let callers = $derived(payload ? buildCallerRail(payload) : null);
let callees = $derived(payload ? buildCalleeRail(payload) : null);
let refs = $derived(payload ? refsByLine(payload) : new Map<number, LineRef[]>());
let outline = $derived(payload ? buildOutline(payload) : []);
let wantsBody = $derived(payload ? showsBody(payload.node.kind, payload.node.lines) : false);
let codeBlock = $derived.by(() => {
if (!payload || !source?.lines) return null;
const from = source.from ?? payload.node.line;
return buildCodeBlock(from, source.lines, graphCallLines(payload));
});
let origin = $derived(arrivedFrom());
let originLeft = $derived(origin?.rail === 'left' ? origin.id : null);
let originRight = $derived(origin?.rail === 'right' ? origin.id : null);
let emptyCalleeReason = $derived.by(() => {
if (!payload) return '';
if (!wantsBody) {
return `A ${payload.node.kind.replace(/_/g, ' ')} makes no calls itself — its members do. Open one from the outline.`;
}
return 'This symbol makes no resolved calls — a leaf.';
});
/* ------------------------------------------------------------ movement -- */
/**
* Follow a call. No line is carried across: the call-site line belongs to the
* symbol being left, and the destination opens at its own definition.
*/
function stepDown(node: WireNodeRef): void {
walkTo(node, 'down');
}
/** Go to a caller, landing on the line that makes the call when one is named. */
function stepUp(node: WireNodeRef, at?: number): void {
walkTo(node, 'up', at);
}
/** A jump that is neither up nor down: a breadcrumb, a chip, a member. */
function open(node: WireNodeRef): void {
walkTo(node, 'start');
}
function followRef(ref: LineRef): void {
if (!ref.targetId) return;
const target = payload?.outgoing.items.find((r) => r.node.id === ref.targetId)?.node
?? payload?.typesUsed.find((r) => r.node.id === ref.targetId)?.node;
if (target) walkTo(target, 'down');
}
/* ------------------------------------------------------------ keyboard -- */
function leftRows(): WireNodeRef[] {
return (callers?.groups ?? []).flatMap((group) => group.rows.map((row) => row.relation.node));
}
function rightRows(): WireNodeRef[] {
return (callees?.rows ?? []).map((row) => row.relation.node);
}
function activeRows(): WireNodeRef[] {
return railFocus.rail === 'left' ? leftRows() : rightRows();
}
function onkeydown(event: KeyboardEvent): void {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement)
) {
return;
}
if (!payload) return;
switch (event.key) {
case 'ArrowLeft':
railFocus.switchTo('left');
break;
case 'ArrowRight':
railFocus.switchTo('right');
break;
case 'ArrowDown':
case 'j':
railFocus.step(1, activeRows().length);
break;
case 'ArrowUp':
case 'k':
railFocus.step(-1, activeRows().length);
break;
case 'Enter': {
const node = activeRows()[railFocus.index];
if (node) {
event.preventDefault();
if (railFocus.rail === 'left') stepUp(node);
else stepDown(node);
}
return;
}
default:
return;
}
event.preventDefault();
// Keep the selection on screen; the rails are the only thing that scrolls
// out from under the keyboard.
void tick().then(() => {
const scope = railFocus.rail === 'left' ? leftRailEl : railEl;
// Rows are the only focusable buttons in a rail, and they render in the
// same order the keyboard walks them.
scope?.querySelectorAll('[role="button"]')[railFocus.index]?.scrollIntoView({
block: 'nearest',
});
});
}
/* ----------------------------------------------------------- measuring -- */
/**
* Place every callee row beside its call site, then draw the connectors.
*
* Rows are laid out in source order and never allowed to overlap: a row wants
* to sit at the centre of its first call-site line, but takes
* `previous + height + gap` when that would collide. Order beats exactness —
* a rail whose rows jump around relative to the body stops being a reading of
* the code — and the connector still runs to the line, so the displacement is
* visible rather than silent.
*/
function relayout(): void {
const inner = innerEl;
const center = centerEl;
const rail = railEl;
const rows = callees?.rows ?? [];
if (!inner || !center || !rail) return;
const headerHeight =
rail.querySelector<HTMLElement>('[data-rail-header]')?.offsetHeight ?? RAIL_HEADER_FALLBACK;
const lineCentre = (n: number): number | null => {
const el = center.querySelector<HTMLElement>(`[data-line="${n}"]`);
return el ? el.offsetTop + el.offsetHeight / 2 : null;
};
let y = headerHeight + 14;
const nextTops: number[] = [];
const rowCentres: Array<number | null> = [];
for (const row of rows) {
const centre = row.anchor !== null ? lineCentre(row.anchor) : null;
const wanted = centre !== null ? centre - ROW_HEIGHT / 2 : y;
y = Math.max(wanted, y);
nextTops.push(y);
rowCentres.push(y + ROW_HEIGHT / 2);
y += ROW_HEIGHT + ROW_GAP;
}
const nextFoldTop = y + 8;
if ((callees?.uncertain.length ?? 0) > 0) {
const fold = rail.querySelector<HTMLElement>('[data-rail-fold]');
y = nextFoldTop + (fold?.offsetHeight ?? 30);
}
const nextNoteTop = y + 14;
tops = nextTops;
foldTop = nextFoldTop;
noteTop = nextNoteTop;
stageMinHeight = Math.max(center.offsetHeight, nextNoteTop + 60);
// Connectors: one per call site, from the centre column's right edge to the
// row's own centre. Both coordinate systems are the stage's, so the port
// and the row agree even when the stage is scrolled.
const x0 = center.offsetLeft + center.offsetWidth - 10;
const x1 = rail.offsetLeft + 14;
const cx = (x0 + x1) / 2;
const next: Connector[] = [];
rows.forEach((row, index) => {
const ry = rowCentres[index];
if (ry == null) return;
const via = synthesizedBy(row.relation);
for (const callLine of row.lines) {
const ly = lineCentre(callLine);
if (ly === null) continue;
next.push({
d: `M${x0},${ly} C${cx},${ly} ${cx},${ry} ${x1},${ry}`,
targetId: row.relation.node.id,
uncertain: row.relation.uncertain,
heuristic: via !== null,
origin: row.relation.node.id === originRight,
});
}
});
connectors = next;
overlay = { width: inner.scrollWidth, height: Math.max(inner.offsetHeight, stageMinHeight) };
}
let scheduled = false;
function scheduleRelayout(): void {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
relayout();
});
}
// Re-measure whenever what is drawn changes. The dependencies are the INPUTS
// (the models and the block); the outputs it writes are read untracked inside
// relayout(), so this cannot feed itself.
$effect(() => {
void codeBlock;
void callees;
void outline;
void payload;
void tick().then(scheduleRelayout);
});
// Layout is a function of pixels, not of data: a resized window, a loaded
// font and an opened fold all move the lines without changing the payload.
$effect(() => {
const inner = innerEl;
const center = centerEl;
const rail = railEl;
if (!inner || !center || !rail) return;
const observer = new ResizeObserver(scheduleRelayout);
observer.observe(inner);
observer.observe(center);
observer.observe(rail);
// Opening a fold moves the rail's contents without resizing any box the
// observer watches — the folds are absolutely positioned. `toggle` does not
// bubble, so it is caught on the way down.
inner.addEventListener('toggle', scheduleRelayout, true);
void document.fonts?.ready.then(scheduleRelayout);
return () => {
observer.disconnect();
inner.removeEventListener('toggle', scheduleRelayout, true);
};
});
// Scroll the highlighted call site into view once, when it first appears —
// and not again, so a later resize does not yank the reader back to it.
let scrolledTo: string | null = null;
$effect(() => {
const key = line === null ? null : `${id}:${line}`;
const center = centerEl;
if (!key || !center || !codeBlock || scrolledTo === key) return;
const el = center.querySelector(`[data-line="${line}"]`);
if (!el) return;
scrolledTo = key;
el.scrollIntoView({ block: 'center' });
});
</script>
<div class="scroll">
<div class="emptystate">
<h2>Symbol view</h2>
<p>
<span class="mono">{id}</span>{#if line}<span class="dim"> · line {line}</span>{/if}
</p>
<p>
Callers, the symbol's source, and its callees are not wired up in this build yet.
</p>
<svelte:window {onkeydown} />
{#if failure}
<div class="scroll">
<div class="emptystate">
<h2>{failure.code === 'not-found' ? 'No such symbol' : 'Could not load this symbol'}</h2>
<p>{failure.message}</p>
{#if failure.guidance}<p class="dim">{failure.guidance}</p>{/if}
</div>
</div>
</div>
{:else if loading || !payload || !callers || !callees}
<div class="scroll">
<div class="emptystate"><p class="dim">Loading…</p></div>
</div>
{:else}
<div class="focus">
<aside class="rail-left" bind:this={leftRailEl} aria-label="Called by">
<CallersRail
model={callers}
originId={originLeft}
exported={payload.node.exported === true}
onstepUp={stepUp}
/>
</aside>
<div class="stage">
<div class="stage-inner" bind:this={innerEl} style:min-height={`${stageMinHeight}px`}>
<Connectors {connectors} width={overlay.width} height={overlay.height} />
<section class="center" bind:this={centerEl}>
<SymbolHeader {payload} onopen={open} />
{#if payload.drift}
<div class="drift">
{payload.node.file} changed on disk after the last index sync — the body is not shown, because
the line ranges the graph holds no longer match the file. Run <code>codegraph sync</code>
to bring it up to date.
</div>
{:else if codeBlock}
<SourceBlock
block={codeBlock}
language={payload.node.language}
{refs}
defLine={payload.node.line}
defName={payload.node.name}
highlight={line}
onfollow={followRef}
/>
{:else if !wantsBody}
<!-- The outline below IS the body for a container this size. -->
{:else if source}
<div class="note">{source.reason ?? 'Source is not available for this symbol.'}</div>
{/if}
{#if outline.length > 0}
<MembersOutline
rows={outline}
total={payload.members.total}
truncated={payload.members.truncated}
onopen={open}
/>
{/if}
{#if payload.blast}
<BlastStrip
blast={payload.blast}
scale={project.stats?.blastScale ?? null}
testCalls={callers.tests.calls}
testFiles={callers.tests.files.length}
/>
{/if}
</section>
<aside class="rail-right" bind:this={railEl} aria-label="Calls">
<CalleeRail
model={callees}
{tops}
{foldTop}
{noteTop}
focalFile={payload.node.file}
originId={originRight}
emptyReason={emptyCalleeReason}
onstepDown={stepDown}
/>
</aside>
</div>
</div>
</div>
{/if}
<style>
.scroll {
height: 100%;
overflow: auto;
}
.focus {
display: grid;
grid-template-columns: 300px minmax(520px, 1fr);
height: 100%;
min-height: 0;
}
.rail-left {
overflow: auto;
border-right: 1px solid var(--rule-soft);
background: var(--paper);
}
.stage {
position: relative;
overflow: auto;
}
/* The positioning context every measured coordinate is expressed in: line
offsets, rail row tops and the SVG overlay all share this origin. */
.stage-inner {
position: relative;
display: grid;
grid-template-columns: minmax(480px, 1fr) 320px;
min-height: 100%;
}
.center {
min-width: 0;
padding: 18px 22px 40px;
}
.rail-right {
position: relative;
border-left: 1px solid var(--rule-faint);
}
.drift {
margin-top: 16px;
padding: 10px 12px;
border: 1px solid var(--amber);
background: var(--amber-soft);
color: var(--amber);
font-size: 12.5px;
line-height: 1.5;
}
.drift code {
font-family: var(--mono);
font-size: 12px;
}
.note {
padding: 12px 0;
color: var(--ink-3);
font-size: 12px;
}
@media (max-width: 1100px) {
.focus {
grid-template-columns: 240px minmax(360px, 1fr);
}
.stage-inner {
grid-template-columns: minmax(360px, 1fr) 260px;
}
}
</style>