feat(ui): the Symbol view — callers, gutter-ported source, line-anchored callee rail (CG-44)
The core screen of `codegraph ui`: who calls a symbol on the left, its verbatim body in the middle with a port on every line that has an outgoing edge, and what it calls on the right — each callee row placed beside the line that makes the call, with a hairline connector between them. The callee rail is the part that is not a list. A row wants to sit at the centre of its first call-site line and is pushed down only when that would collide with the row above, so the rail keeps source order; the connector still runs to the real line, so the displacement is visible rather than silent. Positions come from measuring the laid-out DOM, so they are recomputed on resize, on font load and whenever a fold opens. Honesty is carried in the drawing, not in a footnote: a filled port means the resolver matched something on that line and a hollow one means it only guessed; uncertain connectors are dashed and their targets fold away behind their count; synthesized edges are dashed differently and tagged with the mechanism that made them; references that leave the index are text with a soft underline rather than links to nowhere, and they are counted. Long bodies keep their head plus a window round every call site — windowed on graph edges only, since a function calling `console.log` two hundred times would otherwise window round every line and buy nothing. Containers over 80 lines show a members outline with per-member fan-in/fan-out instead of 700 lines of braces. Two small additions to the read-only API this needed: * `/api/node` gives every outline member its own fanIn/fanOut (two batched queries for the whole outline). A class's own fan-out is nearly always zero because its methods do the calling, so without these the outline cannot say which member carries weight. * `/api/stats` gains `blastScale` — the denominator the blast bar is drawn against, so one symbol's radius reads as wide or narrow *for this repo*. It is measured across the index's 24 most-depended-on symbols (found with a new `getTopDependedOn`, distinct dependents rather than edges), memoised against the index stamp, and reported as sampled; a symbol wider than the sample becomes the scale instead of overflowing the track. Verified against a real index in a real browser: parity with the prototype on `CodeGraph.sync` (259 lines, 27 callee rows, no overlaps), `GraphTraverser` (20-member outline), a 773-line function (26 windows, 78 connectors), light and dark, hover linking in both directions, keyboard-only navigation, and reflow on resize and on fold toggles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7288ffa36
commit
5cecaabfc2
@@ -0,0 +1,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>
|
||||
Reference in New Issue
Block a user