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
+9
-6
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 < 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>
|
||||
@@ -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 < 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}
|
||||
{/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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user