feat(ui): the whole file — full source with gutter ports and intra-file call arcs (CG-52)
The File view gains a Source tab: the file itself, top to bottom, with the Symbol view's line grid, gutter ports and call-site links, a line-anchored callee rail, and — in the left margin — an arc for every call that stays inside the file, drawn from the calling line to the callee's definition line. The arcs are the point. Source order is already a layout, chosen by whoever wrote the file, so a file's internal call structure can be drawn with no algorithm placing anything. Crabviz's idea, in the one place it is legible. Everything is arithmetic, not measurement. The Symbol view queries the laid-out DOM to place a callee row beside its line; a 6 820-line file cannot afford that. Here a line is exactly 20px at `10 + (n - 1) x 20`, so ~90 line elements exist at a time and the arcs, ports, rail rows and connectors are all functions of a line number. `src/mcp/tools.ts` scrolls at a 16.6ms median frame. - `GET /api/filecode/<path>` — outline, one call group per (caller, callee) PAIR with its call-site lines, unresolved references, and the file's length. The source is NOT in it: it pages through `/api/source` 800 lines at a time with a discarded 150-line lead-in, so a page starting inside a block comment does not render prose as code, and so the ports and arcs are complete from the first frame while the text fills in behind them. - `intraFileCalls` is counted over the groups actually returned, so the header and the picture under it cannot disagree once a cap bites. - Above 40 arcs the diagram narrows to the symbol under the pointer (or the one the scroll position is inside) and the header states the total. Accent is for the pointer only, never for the filter. - Sticky outline rail at >= 1400px, following the reader down the file. - `QueryBuilder.getUnresolvedReferencesInFile` — one indexed lookup instead of one per symbol; `buildOutlineEntries` lifted out of `/api/file` so both readings of a file draw the same rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
62e0a89b0e
commit
bd99c5e99a
24 files changed
+3227
-40
No files matched your search
@@ -5,6 +5,7 @@
|
||||
import HomeView from './views/HomeView.svelte';
|
||||
import SymbolView from './views/SymbolView.svelte';
|
||||
import FileView from './views/FileView.svelte';
|
||||
import FileCodeView from './views/FileCodeView.svelte';
|
||||
import MapView from './views/MapView.svelte';
|
||||
import FlowView from './views/FlowView.svelte';
|
||||
import NotFoundView from './views/NotFoundView.svelte';
|
||||
@@ -95,6 +96,8 @@
|
||||
<main>
|
||||
{#if route.view === 'symbol'}
|
||||
<SymbolView id={route.id} line={route.line} />
|
||||
{:else if route.view === 'file' && route.source}
|
||||
<FileCodeView path={route.path} line={route.line} />
|
||||
{:else if route.view === 'file'}
|
||||
<FileView path={route.path} line={route.line} />
|
||||
{:else if route.view === 'map'}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
The intra-file call arcs — the left margin of the whole-file view
|
||||
(design spec §3.4, task CG-52).
|
||||
|
||||
One arc per call whose callee is defined in this same file, drawn from the
|
||||
calling line to the definition line. This is the one place in the app where a
|
||||
"graph of the file" is legible, and the reason is that it is not a graph
|
||||
drawing: the nodes were placed by whoever wrote the file, in source order, and
|
||||
an arc only has to say which two lines are joined.
|
||||
|
||||
Every arc gets a second, invisible, ten-pixel-wide path on top of it. A 1px
|
||||
hairline is not a click target, and the whole point of the picture is that you
|
||||
can grab one and go to the other end.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { hot } from '../../lib/focus.svelte';
|
||||
import { ARC_COLUMN, type FileArc } from '../../lib/filecode-model';
|
||||
|
||||
interface Props {
|
||||
/** Already filtered and windowed by the view. */
|
||||
arcs: FileArc[];
|
||||
/** Height of the scrolling document — the SVG spans all of it. */
|
||||
height: number;
|
||||
/** The line under the pointer, so an arc leaving or landing on it lights. */
|
||||
hoverLine: number | null;
|
||||
onfollow: (arc: FileArc) => void;
|
||||
}
|
||||
|
||||
let { arcs, height, hoverLine, onfollow }: Props = $props();
|
||||
|
||||
/**
|
||||
* Accent is for the POINTER, never for the filter.
|
||||
*
|
||||
* On a crowded file the view has already narrowed the set to the focused
|
||||
* symbol's arcs; colouring those as well would light every line on screen and
|
||||
* say nothing. The spec's rule is the one that carries information: an arc
|
||||
* goes accent when its line or its callee is under the pointer.
|
||||
*/
|
||||
function isLit(arc: FileArc): boolean {
|
||||
return hot.is(arc.targetId) || arc.fromLine === hoverLine || arc.toLine === hoverLine;
|
||||
}
|
||||
|
||||
function label(arc: FileArc): string {
|
||||
const direction = arc.toLine < arc.fromLine ? 'above' : 'below';
|
||||
return `line ${arc.fromLine} calls ${arc.targetName}, defined ${direction} at line ${arc.toLine}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="arcs"
|
||||
width={ARC_COLUMN}
|
||||
{height}
|
||||
viewBox={`0 0 ${ARC_COLUMN} ${height}`}
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each arcs as arc (arc.key)}
|
||||
<path
|
||||
class="arc"
|
||||
class:lit={isLit(arc)}
|
||||
class:uncertain={arc.uncertain}
|
||||
class:heur={arc.synthesized}
|
||||
d={arc.d}
|
||||
/>
|
||||
<!-- Not in the tab order, and the SVG is aria-hidden: every arc's callee is
|
||||
also a focusable rail row and a focusable call-site link in the body, so
|
||||
the picture is a redundant affordance rather than the only one. -->
|
||||
<path
|
||||
class="hit"
|
||||
d={arc.d}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={() => onfollow(arc)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onfollow(arc);
|
||||
}
|
||||
}}
|
||||
onmouseenter={() => hot.set(arc.targetId)}
|
||||
onmouseleave={() => hot.clear(arc.targetId)}
|
||||
>
|
||||
<title>{label(arc)}</title>
|
||||
</path>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.arcs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.arc {
|
||||
fill: none;
|
||||
stroke: var(--ink-4);
|
||||
stroke-width: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arc.uncertain {
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
|
||||
/* Synthesized rather than parsed — dynamic dispatch the parser cannot see.
|
||||
Same dash the Symbol view's connectors use for the same claim. */
|
||||
.arc.heur {
|
||||
stroke: var(--ink-3);
|
||||
stroke-dasharray: 6 3;
|
||||
}
|
||||
|
||||
.arc.lit {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* The hairline is not a click target; this is. */
|
||||
.hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 10;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
<!--
|
||||
The whole file's source, virtualised, with a gutter port on every line the
|
||||
graph has an edge from (design spec §3.4, task CG-52).
|
||||
|
||||
The same line grid as the Symbol view — `44px | 1fr | 18px`, the same 6x6
|
||||
port, the same accent call-site links — with one difference that changes
|
||||
everything about how it is built: lines are ABSOLUTELY POSITIONED at
|
||||
`lineTop(n)` rather than stacked. That is what lets a 6 820-line file hold
|
||||
ninety-odd elements instead of six thousand, and what lets an arc drawn beside
|
||||
it know where line 4 271 is without asking the browser.
|
||||
|
||||
A page of source that has not arrived yet still draws: its line number, its
|
||||
port and its place in the document are all facts from the graph, not from the
|
||||
text. Only the characters are missing, and they fill in behind the reader.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { tokenClass, type Token } from '../../lib/highlight';
|
||||
import { assignRefs, type LineRef } from '../../lib/symbol-model';
|
||||
import { lineTop } from '../../lib/filecode-model';
|
||||
import { hot } from '../../lib/focus.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Inclusive 1-based range to render. */
|
||||
first: number;
|
||||
last: number;
|
||||
/** Classified source for a line, or null while its page is in flight. */
|
||||
tokensFor: (line: number) => Token[] | null;
|
||||
refs: Map<number, LineRef[]>;
|
||||
/** Lines a definition starts on → its name, set in bold there. */
|
||||
defNames: Map<number, string>;
|
||||
/** The line `?hl=` or an arc click landed on. */
|
||||
highlight: number | null;
|
||||
onfollow: (ref: LineRef) => void;
|
||||
onhoverline: (line: number | null) => void;
|
||||
}
|
||||
|
||||
let { first, last, tokensFor, refs, defNames, highlight, onfollow, onhoverline }: Props =
|
||||
$props();
|
||||
|
||||
interface Part {
|
||||
text: string;
|
||||
cls: string | null;
|
||||
ref: LineRef | null;
|
||||
def: boolean;
|
||||
}
|
||||
|
||||
interface RenderedLine {
|
||||
n: number;
|
||||
top: number;
|
||||
parts: Part[] | null;
|
||||
port: 'sure' | 'unsure' | null;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
let lines = $derived.by<RenderedLine[]>(() => {
|
||||
const out: RenderedLine[] = [];
|
||||
for (let n = first; n <= last; n++) {
|
||||
const lineRefs = refs.get(n) ?? [];
|
||||
const tokens = tokensFor(n);
|
||||
out.push({
|
||||
n,
|
||||
top: lineTop(n),
|
||||
parts: tokens ? toParts(tokens, assignRefs(tokens, lineRefs), defNames.get(n) ?? null) : null,
|
||||
port: portFor(lineRefs),
|
||||
targets: [...new Set(lineRefs.map((r) => r.targetId).filter((id): id is string => !!id))],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function toParts(line: Token[], claimed: Map<number, LineRef>, definition: string | null): Part[] {
|
||||
return line.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.text === definition &&
|
||||
token.cls !== 'comment' &&
|
||||
token.cls !== 'string',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled = the graph resolved something here; hollow = it only guessed, or
|
||||
* the reference left the index. No edge at all means no port: 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 lines as line (line.n)}
|
||||
<div
|
||||
class="ln"
|
||||
class:hot={isHot(line)}
|
||||
style:top={`${line.top}px`}
|
||||
data-line={line.n}
|
||||
onmouseenter={() => onhoverline(line.n)}
|
||||
role="presentation"
|
||||
>
|
||||
<span class="no">{line.n}</span>
|
||||
<span class="tx"
|
||||
>{#if line.parts}{#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}{:else}<span class="pending"></span>{/if}</span
|
||||
>
|
||||
<span class="port">
|
||||
{#if line.port}<i class:sure={line.port === 'sure'}></i>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.code {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
font: var(--code-size) / var(--code-lh) var(--mono);
|
||||
}
|
||||
|
||||
/* 44px gutter | source | 18px port cell — the Symbol view's grid.
|
||||
|
||||
The HEIGHT here is load-bearing: every arc, connector and rail row on this
|
||||
screen is placed at `CODE_TOP_PAD + (line - 1) * CODE_LINE_HEIGHT`, and a
|
||||
line that grew past it would detach all three from the source at once.
|
||||
`filecode-model.ts` holds the constant; this is the other half of it. */
|
||||
.ln {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
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 {
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* A line whose page is still in flight. It keeps its number, its port and its
|
||||
place; only the characters are missing. */
|
||||
.pending {
|
||||
display: inline-block;
|
||||
width: 34%;
|
||||
height: 8px;
|
||||
background: var(--rule-faint);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ---- token classes (near-monochrome by design, spec §2.2) ---- */
|
||||
.t-c {
|
||||
color: var(--code-comment);
|
||||
}
|
||||
|
||||
.t-s {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.t-k {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.t-n {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.t-def {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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,181 @@
|
||||
<!--
|
||||
The sticky navigation rail beside the whole-file source (design spec §3.4,
|
||||
task CG-52). Shown when there is room for it — under 1400px the view drops it
|
||||
rather than squeezing the code.
|
||||
|
||||
The same rows as the File view's outline, at navigation weight: a click
|
||||
scrolls the source to that definition rather than leaving the file. Rows are a
|
||||
fixed height and the list is windowed above a threshold, for the same reason
|
||||
the outline view's is — this repo's own fixtures hold a 1 681-symbol `.d.ts`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import KindGlyph from '../KindGlyph.svelte';
|
||||
import { OUTLINE_VIRTUAL_THRESHOLD, type OutlineEntryRow } from '../../lib/file-model';
|
||||
import { NAV_ROW_HEIGHT } from '../../lib/filecode-model';
|
||||
|
||||
interface Props {
|
||||
rows: OutlineEntryRow[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
/** Id of the symbol the reader is inside, from the scroll position. */
|
||||
currentId: string | null;
|
||||
ongo: (line: number, id: string) => void;
|
||||
}
|
||||
|
||||
let { rows, total, truncated, currentId, ongo }: Props = $props();
|
||||
|
||||
let listEl = $state<HTMLDivElement | null>(null);
|
||||
let scrollTop = $state(0);
|
||||
let viewport = $state(0);
|
||||
|
||||
let virtual = $derived(rows.length > OUTLINE_VIRTUAL_THRESHOLD);
|
||||
|
||||
let window_ = $derived.by(() => {
|
||||
if (!virtual) return { start: 0, end: rows.length, before: 0, after: 0 };
|
||||
const first = Math.floor(scrollTop / NAV_ROW_HEIGHT) - 8;
|
||||
const count = Math.ceil((viewport || 800) / NAV_ROW_HEIGHT) + 16;
|
||||
const start = Math.max(0, Math.min(rows.length - 1, first));
|
||||
const end = Math.max(start, Math.min(rows.length, start + count));
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
before: start * NAV_ROW_HEIGHT,
|
||||
after: (rows.length - end) * NAV_ROW_HEIGHT,
|
||||
};
|
||||
});
|
||||
|
||||
// Follow the reader down the file: when the source scrolls into another
|
||||
// symbol, bring its row into the rail rather than making them find it.
|
||||
$effect(() => {
|
||||
const id = currentId;
|
||||
const el = listEl;
|
||||
if (!id || !el) return;
|
||||
const at = rows.findIndex((row) => row.entry.id === id);
|
||||
if (at < 0) return;
|
||||
const top = at * NAV_ROW_HEIGHT;
|
||||
const scroller = el.parentElement;
|
||||
if (!scroller) return;
|
||||
if (top < scroller.scrollTop) scroller.scrollTop = top - NAV_ROW_HEIGHT * 2;
|
||||
else if (top + NAV_ROW_HEIGHT > scroller.scrollTop + scroller.clientHeight) {
|
||||
scroller.scrollTop = top + NAV_ROW_HEIGHT * 3 - scroller.clientHeight;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = listEl?.parentElement;
|
||||
if (!el) return;
|
||||
const read = () => {
|
||||
scrollTop = el.scrollTop;
|
||||
viewport = el.clientHeight;
|
||||
};
|
||||
read();
|
||||
el.addEventListener('scroll', read, { passive: true });
|
||||
const observer = new ResizeObserver(read);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', read);
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="navrail">
|
||||
<div class="navh">
|
||||
<span>Outline</span>
|
||||
<span class="dim">{total}</span>
|
||||
</div>
|
||||
<div class="navlist">
|
||||
<div bind:this={listEl}>
|
||||
{#if window_.before > 0}<div style:height={`${window_.before}px`}></div>{/if}
|
||||
{#each rows.slice(window_.start, window_.end) as row (row.entry.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="nrow"
|
||||
class:dimmed={row.dimmed}
|
||||
class:current={row.entry.id === currentId}
|
||||
style:padding-left={`${6 + row.indent * 14}px`}
|
||||
title={`${row.entry.qualifiedName} — line ${row.entry.line}`}
|
||||
onclick={() => ongo(row.entry.line, row.entry.id)}
|
||||
>
|
||||
<KindGlyph kind={row.entry.kind} />
|
||||
<span class="nm">{row.entry.name}</span>
|
||||
<span class="ln">{row.entry.line}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if window_.after > 0}<div style:height={`${window_.after}px`}></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if truncated}
|
||||
<div class="note">Showing {rows.length} of {total} symbols.</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.navrail {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--rule-soft);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.navh {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 12px 12px 8px;
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.navlist {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Fixed height — the windowing above assumes it (NAV_ROW_HEIGHT). */
|
||||
.nrow {
|
||||
display: grid;
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
grid-template-columns: 14px minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding-right: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nrow:hover {
|
||||
background: var(--press);
|
||||
}
|
||||
|
||||
.nrow.current {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.nm {
|
||||
overflow: hidden;
|
||||
font: 12px var(--mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nrow.dimmed .nm {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.ln {
|
||||
color: var(--ink-4);
|
||||
font: 10.5px var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--rule-faint);
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<!--
|
||||
The whole-file view's callee rail: one row per (calling symbol, called symbol)
|
||||
pair, beside the line that makes the call (design spec §3.4, task CG-52).
|
||||
|
||||
The Symbol view's rail annotates one body. This one annotates a whole file, so
|
||||
the unit is the PAIR rather than the callee: the same helper called from two
|
||||
functions a thousand lines apart is two rows, because a row is anchored to a
|
||||
line and there is no line that is both. `buildFileCallRows` does the placing;
|
||||
the view windows it; this draws what it is handed.
|
||||
|
||||
Uncertain rows are not folded away here. In a single body the fold is what
|
||||
keeps a guess from reading as a resolved call — there is a header to hang it
|
||||
under. Across six thousand lines there is nowhere to put a fold that does not
|
||||
detach it from the source, so a name-only match stays where its call site is
|
||||
and wears the dotted underline that says what it is.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import KindGlyph from '../KindGlyph.svelte';
|
||||
import { hot } from '../../lib/focus.svelte';
|
||||
import type { FileCallRow } from '../../lib/filecode-model';
|
||||
import type { WireNodeRef } from '../../lib/api';
|
||||
|
||||
interface Props {
|
||||
/** Already windowed to the viewport by the view. */
|
||||
rows: FileCallRow[];
|
||||
/** This file's path — a callee inside it reads "same file", not a path. */
|
||||
focalFile: string;
|
||||
/** The symbol whose neighbourhood is lit. */
|
||||
focusId: string | null;
|
||||
onopen: (node: WireNodeRef) => void;
|
||||
onhover: (row: FileCallRow | null) => void;
|
||||
}
|
||||
|
||||
let { rows, focalFile, focusId, onopen, onhover }: Props = $props();
|
||||
|
||||
function title(row: FileCallRow): string {
|
||||
const node = row.call.relation.node;
|
||||
const where = row.lines.length > 0 ? ` · called from line ${row.lines.join(', ')}` : '';
|
||||
return `${node.qualifiedName} — ${node.file}:${node.line}${where}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each rows as row (row.key)}
|
||||
{@const node = row.call.relation.node}
|
||||
<div
|
||||
class="rrow"
|
||||
class:hot={hot.is(node.id)}
|
||||
class:focused={focusId !== null && (row.ownerId === focusId || node.id === focusId)}
|
||||
class:uncertain={row.call.relation.uncertain}
|
||||
style:top={`${row.top}px`}
|
||||
data-target={node.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title={title(row)}
|
||||
onclick={() => onopen(node)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onopen(node);
|
||||
}
|
||||
}}
|
||||
onmouseenter={() => {
|
||||
hot.set(node.id);
|
||||
onhover(row);
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
hot.clear(node.id);
|
||||
onhover(null);
|
||||
}}
|
||||
>
|
||||
<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.via}<span
|
||||
class="tag"
|
||||
title="A synthesized edge — dynamic dispatch the parser cannot see">via {row.via}</span
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.rrow {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
left: 14px;
|
||||
display: grid;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
grid-template-columns: 16px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--paper);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rrow:hover {
|
||||
background: var(--press);
|
||||
}
|
||||
|
||||
.rrow.focused {
|
||||
border-color: var(--rule-soft);
|
||||
}
|
||||
|
||||
.rrow.hot {
|
||||
border-color: var(--accent-line);
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!--
|
||||
Outline or source — the two readings of a file (design spec §3.4).
|
||||
|
||||
A link, not a toggle, because the choice belongs in the URL: a file opened at
|
||||
a line from a search result, a flow card or a review comment has to reopen in
|
||||
the same mode, and `?src=1` is how it travels.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { fileHref } from '../../lib/router.svelte';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
line: number | null;
|
||||
/** Which mode is showing. */
|
||||
source: boolean;
|
||||
}
|
||||
|
||||
let { path, line, source }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="modes" aria-label="File view mode">
|
||||
<a
|
||||
class="mode"
|
||||
class:on={!source}
|
||||
href={fileHref(path, line ? { line } : {})}
|
||||
aria-current={!source ? 'page' : undefined}>Outline</a
|
||||
>
|
||||
<a
|
||||
class="mode"
|
||||
class:on={source}
|
||||
href={fileHref(path, { source: true, ...(line ? { line } : {}) })}
|
||||
aria-current={source ? 'page' : undefined}>Source</a
|
||||
>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.modes {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mode {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--rule-soft);
|
||||
color: var(--ink-2);
|
||||
font-size: 12.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mode:hover {
|
||||
background: var(--press);
|
||||
}
|
||||
|
||||
.mode.on {
|
||||
border-color: var(--ink);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -215,6 +215,48 @@ export interface WireFilePayload {
|
||||
dependents: string[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ whole-file source view -- */
|
||||
|
||||
/** A reference the resolver never landed: a gutter port with no destination. */
|
||||
export interface WireFileOutsideRef {
|
||||
line: number;
|
||||
col: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
|
||||
export interface WireFileCall {
|
||||
/** The symbol making the calls — the file node itself for top-level code. */
|
||||
ownerId: string;
|
||||
ownerLine: number;
|
||||
relation: WireRelation;
|
||||
}
|
||||
|
||||
export interface WireFileCodePayload {
|
||||
file: {
|
||||
path: string;
|
||||
language: string;
|
||||
size: number;
|
||||
indexedAt: number;
|
||||
contentHash: string;
|
||||
generated: boolean;
|
||||
test: boolean;
|
||||
errors: string[];
|
||||
id: string | null;
|
||||
/** Lines on disk now — the height of the scrolling document. */
|
||||
totalLines: number | null;
|
||||
};
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
outline: WireList<WireOutlineEntry>;
|
||||
calls: WireList<WireFileCall>;
|
||||
outside: WireList<WireFileOutsideRef>;
|
||||
/** Calls landing on a definition in this same file — the arc diagram's total. */
|
||||
intraFileCalls: number;
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
export interface WireBlastScale {
|
||||
maxDirect: number;
|
||||
maxWithinHops: number;
|
||||
@@ -534,6 +576,20 @@ export function fetchFile(path: string, signal?: AbortSignal): Promise<WireFileP
|
||||
return getJson<WireFilePayload>(`api/file/${encoded}`, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the graph says about the lines of one file: the outline, one row
|
||||
* per (caller, callee) pair with its call-site lines, and the references that
|
||||
* resolved to nothing. The SOURCE is not in here — it pages through
|
||||
* `fetchSource`, so the ports and arcs are complete before any text arrives.
|
||||
*/
|
||||
export function fetchFileCode(
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WireFileCodePayload> {
|
||||
const encoded = path.split('/').map(encodeURIComponent).join('/');
|
||||
return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
|
||||
}
|
||||
|
||||
export function fetchSource(
|
||||
file: string,
|
||||
from: number,
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* The whole-file view's geometry (design spec §3.4, task CG-52).
|
||||
*
|
||||
* The Symbol view *measures* the laid-out DOM to place a callee row beside its
|
||||
* line, because a body of 60 lines can be re-flowed by a fold, a font or a
|
||||
* width. A whole file cannot afford that: `src/mcp/tools.ts` is 6 820 lines, and
|
||||
* asking the browser where each of them ended up — after rendering all of them —
|
||||
* is neither 60 fps nor possible.
|
||||
*
|
||||
* So this screen inverts the contract. **Every line is exactly
|
||||
* {@link CODE_LINE_HEIGHT} tall and its position is arithmetic**, which buys
|
||||
* three things at once: the document's height is known before a byte of source
|
||||
* arrives, only the visible lines are ever in the DOM, and the arcs, the ports
|
||||
* and the rail rows are all functions of a line number rather than of a
|
||||
* measurement. Nothing here touches the DOM.
|
||||
*
|
||||
* The one thing the view still measures is the x of two column edges, for the
|
||||
* connector hairlines — a single ResizeObserver, not a query per line.
|
||||
*
|
||||
* If you ever let a code line grow (a wrapped line, a taller row, an inline
|
||||
* banner), this whole screen silently drifts: lines land at the wrong offset,
|
||||
* arcs point between the wrong ones, rail rows detach from their calls. Change
|
||||
* {@link CODE_LINE_HEIGHT} and the CSS together, or make the list measure.
|
||||
* Same contract as the File view's outline and the Flow strip's cards.
|
||||
*/
|
||||
|
||||
import type {
|
||||
WireFileCall,
|
||||
WireFileCodePayload,
|
||||
WireFileOutsideRef,
|
||||
WireOutlineEntry,
|
||||
} from './api';
|
||||
import { lastSegment, relationWords, synthesizedBy, type LineRef } from './symbol-model';
|
||||
|
||||
/* ------------------------------------------------------------- constants -- */
|
||||
|
||||
/** Height of one source line. Pinned in `FileCodeBlock.svelte`'s CSS. */
|
||||
export const CODE_LINE_HEIGHT = 20;
|
||||
|
||||
/** Blank space above line 1, so the first line is not flush against the rule. */
|
||||
export const CODE_TOP_PAD = 10;
|
||||
|
||||
/** Blank space after the last line, so the end of a file can be scrolled to. */
|
||||
export const CODE_BOTTOM_PAD = 120;
|
||||
|
||||
/** The arc diagram's column, left of the line numbers (design spec §3.4). */
|
||||
export const ARC_COLUMN = 56;
|
||||
|
||||
/** Callee rail width and row geometry — the Symbol view's numbers, unchanged. */
|
||||
export const RAIL_WIDTH = 320;
|
||||
export const ROW_HEIGHT = 34;
|
||||
export const ROW_GAP = 6;
|
||||
|
||||
/** Row height of the sticky navigation rail. Pinned in `FileCodeOutline`'s CSS. */
|
||||
export const NAV_ROW_HEIGHT = 24;
|
||||
|
||||
/** Lines drawn either side of the viewport, so a flick does not show holes. */
|
||||
export const OVERSCAN_LINES = 24;
|
||||
|
||||
/**
|
||||
* Source lines fetched in one page.
|
||||
*
|
||||
* Measured on this repo's own TypeScript with the shipped Shiki setup: a warm
|
||||
* grammar tokenises ~7 000 lines/second, so a page plus its lead-in is ~130 ms
|
||||
* of single-threaded server. Bigger pages mean fewer, longer stalls; smaller
|
||||
* ones mean the lead-in dominates. The scroll itself never waits on this —
|
||||
* ports, arcs and rail rows are already drawn from the graph, and the text
|
||||
* arrives behind them.
|
||||
*/
|
||||
export const PAGE_LINES = 800;
|
||||
|
||||
/**
|
||||
* Lines fetched BEFORE a page and thrown away.
|
||||
*
|
||||
* A page that starts in the middle of a block comment, a template literal or a
|
||||
* JSX block does not know it: TextMate state is built by scanning from the top.
|
||||
* Tokenising a run-up and discarding it is what keeps page 6 from rendering a
|
||||
* doc comment as code. The same trick the Flow strip's source windows use, at a
|
||||
* different scale — 150 lines covers every real comment block; a 3 000-line
|
||||
* literal would still be wrong, and would be wrong at any bounded lead-in.
|
||||
*/
|
||||
export const PAGE_LEAD_IN = 150;
|
||||
|
||||
/**
|
||||
* Arcs above which the diagram shows only the focused symbol's.
|
||||
*
|
||||
* Design spec §3.4. Two hundred arcs over six thousand lines is not a picture
|
||||
* of anything — every one of them is a full-height sweep and they overlap into
|
||||
* a grey wash. Past this point the header states the count and the arcs follow
|
||||
* the pointer instead.
|
||||
*/
|
||||
export const ARC_CROWD_LIMIT = 40;
|
||||
|
||||
/** Innermost and outermost bulge of an arc, within {@link ARC_COLUMN}. */
|
||||
const ARC_MIN_DEPTH = 7;
|
||||
const ARC_MAX_DEPTH = ARC_COLUMN - 8;
|
||||
|
||||
/* --------------------------------------------------------------- pixels -- */
|
||||
|
||||
/** Top edge of a 1-based file line, in the scrolling document's coordinates. */
|
||||
export function lineTop(line: number): number {
|
||||
return CODE_TOP_PAD + (line - 1) * CODE_LINE_HEIGHT;
|
||||
}
|
||||
|
||||
/** Vertical centre of a line — what an arc, a port and a connector all anchor to. */
|
||||
export function lineCentre(line: number): number {
|
||||
return lineTop(line) + CODE_LINE_HEIGHT / 2;
|
||||
}
|
||||
|
||||
/** Total height of the scrolling document for a file of `totalLines`. */
|
||||
export function documentHeight(totalLines: number): number {
|
||||
return CODE_TOP_PAD + Math.max(0, totalLines) * CODE_LINE_HEIGHT + CODE_BOTTOM_PAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 1-based line at a vertical offset in the document.
|
||||
*
|
||||
* Clamped into the file: the top pad is above line 1 and the bottom pad is
|
||||
* below the last, and both should read as the line they are adjacent to.
|
||||
*/
|
||||
export function lineAtOffset(y: number, totalLines: number): number {
|
||||
if (totalLines <= 0) return 1;
|
||||
const line = Math.floor((y - CODE_TOP_PAD) / CODE_LINE_HEIGHT) + 1;
|
||||
return Math.max(1, Math.min(totalLines, line));
|
||||
}
|
||||
|
||||
/** The 1-based lines to render for a scroll position, with overscan. */
|
||||
export function visibleLines(
|
||||
scrollTop: number,
|
||||
viewport: number,
|
||||
totalLines: number,
|
||||
overscan = OVERSCAN_LINES
|
||||
): { first: number; last: number } {
|
||||
if (totalLines <= 0) return { first: 1, last: 0 };
|
||||
const firstRaw = Math.floor((scrollTop - CODE_TOP_PAD) / CODE_LINE_HEIGHT) + 1 - overscan;
|
||||
const count = Math.ceil((viewport || 800) / CODE_LINE_HEIGHT) + overscan * 2;
|
||||
const first = Math.max(1, Math.min(totalLines, firstRaw));
|
||||
return { first, last: Math.max(first - 1, Math.min(totalLines, first + count)) };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- pages -- */
|
||||
|
||||
export interface SourcePage {
|
||||
/** 0-based page index. */
|
||||
index: number;
|
||||
/** First and last line the page OWNS. */
|
||||
from: number;
|
||||
to: number;
|
||||
/** First line to ASK for — `from` minus the lead-in that gets discarded. */
|
||||
requestFrom: number;
|
||||
}
|
||||
|
||||
export function pageOf(line: number): number {
|
||||
return Math.floor((line - 1) / PAGE_LINES);
|
||||
}
|
||||
|
||||
export function pageFor(index: number, totalLines: number): SourcePage {
|
||||
const from = index * PAGE_LINES + 1;
|
||||
return {
|
||||
index,
|
||||
from,
|
||||
to: Math.min(totalLines, from + PAGE_LINES - 1),
|
||||
requestFrom: Math.max(1, from - PAGE_LEAD_IN),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every page index a rendered line range touches, in reading order. */
|
||||
export function pagesForRange(first: number, last: number, totalLines: number): number[] {
|
||||
if (totalLines <= 0 || last < first) return [];
|
||||
const pages: number[] = [];
|
||||
for (let p = pageOf(Math.max(1, first)); p <= pageOf(Math.min(totalLines, last)); p++) {
|
||||
pages.push(p);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- ownership -- */
|
||||
|
||||
/**
|
||||
* Which symbol owns a line — the DEEPEST outline entry whose range holds it.
|
||||
*
|
||||
* Entries arrive in source order, so a symbol's descendants follow it and the
|
||||
* last containing entry is the specific answer. Taking the first would land on
|
||||
* the enclosing class, which owns every line of the file equally and would make
|
||||
* hovering anywhere light every arc in it. Same rule the File view's `?hl=`
|
||||
* landing uses.
|
||||
*/
|
||||
export function ownerAt(outline: readonly WireOutlineEntry[], line: number): string | null {
|
||||
let owner: string | null = null;
|
||||
for (const entry of outline) {
|
||||
if (entry.line > line) break;
|
||||
if (line <= entry.endLine) owner = entry.id;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- ports -- */
|
||||
|
||||
/**
|
||||
* Which identifiers on which lines the graph has something to say about.
|
||||
*
|
||||
* Same shape the Symbol view's code block consumes, so `assignRefs` — the
|
||||
* ladder that makes `this.mutex.withLock(…)` underline `withLock` and not
|
||||
* `this` — is shared rather than re-derived. See `symbol-model.ts`.
|
||||
*
|
||||
* Two file-scale details the single-symbol version does not have:
|
||||
*
|
||||
* * A relation caps the EDGES it ships but never its `lines`, so a group with
|
||||
* more call sites than edges would lose ports on the overflow. Every line
|
||||
* without a ref for its target gets one anyway, with no column — the port is
|
||||
* right, the underline just falls back to the first matching token.
|
||||
* * Unresolved references arrive as one file-wide list rather than as a sample
|
||||
* per symbol, and are what keeps a line calling `console.log` from showing an
|
||||
* empty gutter that reads as "nothing happens here".
|
||||
*/
|
||||
export function buildFileRefs(payload: WireFileCodePayload): 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 call of payload.calls.items) {
|
||||
const relation = call.relation;
|
||||
const ident = lastSegment(relation.node.name);
|
||||
const words = relationWords(relation);
|
||||
const covered = new Set<number>();
|
||||
for (const edge of relation.edges) {
|
||||
if (!edge.line) continue;
|
||||
covered.add(edge.line);
|
||||
add(edge.line, {
|
||||
ident,
|
||||
col: typeof edge.col === 'number' ? edge.col : null,
|
||||
targetId: relation.node.id,
|
||||
uncertain: relation.uncertain,
|
||||
outside: false,
|
||||
title:
|
||||
`${words[0] || 'calls'} ${relation.node.qualifiedName} — ${relation.node.file}:${relation.node.line}` +
|
||||
(edge.confidence != null ? ` · confidence ${edge.confidence}` : '') +
|
||||
(edge.resolvedBy ? ` · resolved by ${edge.resolvedBy}` : ''),
|
||||
});
|
||||
}
|
||||
for (const line of relation.lines) {
|
||||
if (covered.has(line)) continue;
|
||||
add(line, {
|
||||
ident,
|
||||
col: null,
|
||||
targetId: relation.node.id,
|
||||
uncertain: relation.uncertain,
|
||||
outside: false,
|
||||
title: `${words[0] || 'calls'} ${relation.node.qualifiedName} — ${relation.node.file}:${relation.node.line}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of payload.outside.items) add(ref.line, outsideRef(ref));
|
||||
return byLine;
|
||||
}
|
||||
|
||||
function outsideRef(ref: WireFileOutsideRef): LineRef {
|
||||
return {
|
||||
ident: ref.name,
|
||||
col: ref.col,
|
||||
targetId: null,
|
||||
uncertain: false,
|
||||
outside: true,
|
||||
title: `${ref.name} is not in the index — nothing here resolves it`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- rail -- */
|
||||
|
||||
export interface FileCallRow {
|
||||
/** Stable across re-renders: the pair IS the row. */
|
||||
key: string;
|
||||
/** The symbol in this file that makes the calls. */
|
||||
ownerId: string;
|
||||
call: WireFileCall;
|
||||
/** First call-site line — the height the row wants. */
|
||||
anchor: number | null;
|
||||
lines: number[];
|
||||
words: string[];
|
||||
via: string | null;
|
||||
/** Top edge, in the document's coordinates, after collision resolution. */
|
||||
top: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callee rail: one row per (calling symbol, called symbol) pair, placed.
|
||||
*
|
||||
* The pair is the unit here, not the target. Inside one body a helper called
|
||||
* from three lines is one row with `×3`, and it can sit beside them because
|
||||
* they are a few lines apart. Across a 6 800-line file the same helper called
|
||||
* from two functions a thousand lines apart cannot be one row: a row is
|
||||
* anchored to a line, and there is no line that is both. The server groups it
|
||||
* that way; this places it.
|
||||
*
|
||||
* Placement is the Symbol view's rule, run over the whole file: a row wants the
|
||||
* centre of its first call site, and takes `previous + height + gap` when that
|
||||
* would overlap. Order beats exactness — a rail whose rows jump around relative
|
||||
* to the source stops being a reading of it — and the connector still runs to
|
||||
* the real line, so any displacement is visible rather than silent.
|
||||
*/
|
||||
export function buildFileCallRows(payload: WireFileCodePayload): FileCallRow[] {
|
||||
const rows: FileCallRow[] = payload.calls.items.map((call) => ({
|
||||
key: `${call.ownerId}>${call.relation.node.id}`,
|
||||
ownerId: call.ownerId,
|
||||
call,
|
||||
anchor: call.relation.lines[0] ?? null,
|
||||
lines: call.relation.lines,
|
||||
words: relationWords(call.relation),
|
||||
via: synthesizedBy(call.relation),
|
||||
top: 0,
|
||||
}));
|
||||
|
||||
// The server already sorts by first call line; sorting again keeps this
|
||||
// function correct on its own, which is what the tests exercise.
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
(a.anchor ?? Number.MAX_SAFE_INTEGER) - (b.anchor ?? Number.MAX_SAFE_INTEGER) ||
|
||||
a.call.ownerLine - b.call.ownerLine ||
|
||||
a.call.relation.node.name.localeCompare(b.call.relation.node.name)
|
||||
);
|
||||
|
||||
let y = CODE_TOP_PAD;
|
||||
for (const row of rows) {
|
||||
const wanted = row.anchor === null ? y : lineCentre(row.anchor) - ROW_HEIGHT / 2;
|
||||
y = Math.max(wanted, y);
|
||||
row.top = y;
|
||||
y += ROW_HEIGHT + ROW_GAP;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The rows whose boxes intersect a pixel range. Rows are sorted by `top`. */
|
||||
export function rowsInRange(
|
||||
rows: readonly FileCallRow[],
|
||||
from: number,
|
||||
to: number
|
||||
): FileCallRow[] {
|
||||
const out: FileCallRow[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.top > to) break;
|
||||
if (row.top + ROW_HEIGHT >= from) out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Height the rail needs, so the document never ends above its last row. */
|
||||
export function railHeight(rows: readonly FileCallRow[]): number {
|
||||
const last = rows[rows.length - 1];
|
||||
return last ? last.top + ROW_HEIGHT + CODE_BOTTOM_PAD : 0;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- arcs -- */
|
||||
|
||||
export interface FileArc {
|
||||
key: string;
|
||||
/** The line that makes the call. */
|
||||
fromLine: number;
|
||||
/** The line the callee is defined on. */
|
||||
toLine: number;
|
||||
ownerId: string;
|
||||
targetId: string;
|
||||
targetName: string;
|
||||
uncertain: boolean;
|
||||
synthesized: boolean;
|
||||
/** SVG path, an ellipse half bulging left of the gutter. */
|
||||
d: string;
|
||||
/** Bounding lines, for windowing. */
|
||||
minLine: number;
|
||||
maxLine: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One arc per call whose callee is defined in this same file.
|
||||
*
|
||||
* This is the one place a "graph of the file" is legible, and the reason is
|
||||
* that it is not a graph drawing at all: the nodes are already placed, by the
|
||||
* author, in source order, and an arc only has to say which two lines are
|
||||
* connected. Crabviz's layout, with the file's own line numbering as the
|
||||
* vertical axis.
|
||||
*
|
||||
* **Depth is a function of the arc's own span, not of its rank.** Short arcs sit
|
||||
* innermost, as the spec asks, but computing that from a sort position would
|
||||
* make every arc jump sideways the moment the set is filtered to one symbol's.
|
||||
* A log scale against the file's longest span is monotonic in span, stable
|
||||
* under filtering, and keeps the local calls — the ones a reader is following —
|
||||
* legible next to the gutter.
|
||||
*/
|
||||
export function buildFileArcs(
|
||||
payload: WireFileCodePayload,
|
||||
rows: readonly FileCallRow[]
|
||||
): FileArc[] {
|
||||
const path = payload.file.path;
|
||||
const arcs: FileArc[] = [];
|
||||
let maxSpan = 1;
|
||||
|
||||
for (const row of rows) {
|
||||
const target = row.call.relation.node;
|
||||
if (target.file !== path) continue;
|
||||
for (const line of row.lines) {
|
||||
// A recursive call sits ON its own definition line often enough to be
|
||||
// worth skipping: a zero-height arc is a dot, not a drawing.
|
||||
if (line === target.line) continue;
|
||||
const span = Math.abs(target.line - line);
|
||||
if (span > maxSpan) maxSpan = span;
|
||||
arcs.push({
|
||||
key: `${row.key}:${line}`,
|
||||
fromLine: line,
|
||||
toLine: target.line,
|
||||
ownerId: row.ownerId,
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
uncertain: row.call.relation.uncertain,
|
||||
synthesized: row.via !== null,
|
||||
d: '',
|
||||
minLine: Math.min(line, target.line),
|
||||
maxLine: Math.max(line, target.line),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scale = Math.log1p(maxSpan);
|
||||
for (const arc of arcs) {
|
||||
const span = arc.maxLine - arc.minLine;
|
||||
const depth =
|
||||
ARC_MIN_DEPTH +
|
||||
(ARC_MAX_DEPTH - ARC_MIN_DEPTH) * (scale > 0 ? Math.log1p(span) / scale : 0);
|
||||
arc.d = arcPath(arc.fromLine, arc.toLine, depth);
|
||||
}
|
||||
|
||||
// Long arcs first so short ones paint on top of them — the innermost arc is
|
||||
// the one a reader is most likely to be aiming at.
|
||||
arcs.sort((a, b) => b.maxLine - b.minLine - (a.maxLine - a.minLine));
|
||||
return arcs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Half an ellipse from one line to another, bulging into the arc column.
|
||||
*
|
||||
* Both ends sit on the column's right edge, so `rx` is the whole of the bulge
|
||||
* and `ry` is half the vertical span — the chord is a diameter, which makes the
|
||||
* arc exactly determined and its widest point exactly `depth` to the left.
|
||||
* The sweep flag flips with direction: on a screen whose y grows downward,
|
||||
* sweep 0 runs anticlockwise, which passes left going DOWN and right going up.
|
||||
*/
|
||||
export function arcPath(fromLine: number, toLine: number, depth: number): string {
|
||||
const y0 = lineCentre(fromLine);
|
||||
const y1 = lineCentre(toLine);
|
||||
const ry = Math.max(1, Math.abs(y1 - y0) / 2);
|
||||
const sweep = y1 > y0 ? 0 : 1;
|
||||
return `M${ARC_COLUMN},${y0} A${depth.toFixed(1)},${ry.toFixed(1)} 0 0 ${sweep} ${ARC_COLUMN},${y1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The arcs to draw: everything when there are few, otherwise the focused
|
||||
* symbol's alone (design spec §3.4).
|
||||
*
|
||||
* "The focused symbol's" is both directions — the calls it makes and the calls
|
||||
* that reach it — because a reader hovering a function is asking about its
|
||||
* neighbourhood, not about its out-edges.
|
||||
*/
|
||||
export function visibleArcs(
|
||||
arcs: readonly FileArc[],
|
||||
focusId: string | null,
|
||||
crowded: boolean
|
||||
): FileArc[] {
|
||||
if (!crowded) return [...arcs];
|
||||
if (!focusId) return [];
|
||||
return arcs.filter((arc) => arc.ownerId === focusId || arc.targetId === focusId);
|
||||
}
|
||||
|
||||
/** Arcs that reach into a rendered line range. */
|
||||
export function arcsInRange(arcs: readonly FileArc[], first: number, last: number): FileArc[] {
|
||||
return arcs.filter((arc) => arc.minLine <= last && arc.maxLine >= first);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- header -- */
|
||||
|
||||
/** `209 calls within this file` / `no calls stay inside this file`. */
|
||||
export function arcSummary(count: number): string {
|
||||
if (count === 0) return 'No calls in this file reach another symbol in it.';
|
||||
return `${count} ${count === 1 ? 'call stays' : 'calls stay'} within this file`;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* Routes (design spec §3.2–§3.6):
|
||||
* #/ home / nothing selected
|
||||
* #/s/<id> symbol view (?hl=<line> highlights a line, ?t=<trail>)
|
||||
* #/file/<path> file view (?hl=<line>)
|
||||
* #/file/<path> file view (?hl=<line>, ?src=1 for whole-file source)
|
||||
* #/map module map (?root=&depth=&tests=1)
|
||||
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
|
||||
*
|
||||
@@ -22,7 +22,13 @@
|
||||
export type Route =
|
||||
| { view: 'home' }
|
||||
| { view: 'symbol'; id: string; line: number | null }
|
||||
| { view: 'file'; path: string; line: number | null }
|
||||
| {
|
||||
view: 'file';
|
||||
path: string;
|
||||
line: number | null;
|
||||
/** The whole-file source view rather than the outline (design spec §3.4). */
|
||||
source: boolean;
|
||||
}
|
||||
| { view: 'map'; root: string | null; depth: number; tests: boolean }
|
||||
| {
|
||||
view: 'flow';
|
||||
@@ -81,7 +87,7 @@ export function parseHash(hash: string): RouterLocation {
|
||||
} else if (head === 's' && rest.length > 0) {
|
||||
route = { view: 'symbol', id: rest.join('/'), line };
|
||||
} else if (head === 'file' && rest.length > 0) {
|
||||
route = { view: 'file', path: rest.join('/'), line };
|
||||
route = { view: 'file', path: rest.join('/'), line, source: params.get('src') === '1' };
|
||||
} else if (head === 'map' && rest.length === 0) {
|
||||
// The map's shape travels in the URL like the trail does: a link to
|
||||
// "src/vs at depth 2, tests on" has to reopen the same picture.
|
||||
@@ -120,9 +126,17 @@ export function symbolHref(id: string, opts: { line?: number; trail?: string } =
|
||||
return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function fileHref(path: string, opts: { line?: number } = {}): string {
|
||||
const query = opts.line ? `?hl=${opts.line}` : '';
|
||||
return `#/file/${encodePath(path)}${query}`;
|
||||
export function fileHref(
|
||||
path: string,
|
||||
opts: { line?: number; source?: boolean } = {}
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
// `src` before `hl` so the two file URLs a reader shares differ in their
|
||||
// first character after the path, not somewhere in the middle.
|
||||
if (opts.source) params.set('src', '1');
|
||||
if (opts.line) params.set('hl', String(opts.line));
|
||||
const query = params.toString();
|
||||
return `#/file/${encodePath(path)}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function mapHref(
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
<!--
|
||||
The whole-file view: the file's own source, top to bottom, with the graph
|
||||
drawn into its margins (design spec §3.4, task CG-52).
|
||||
|
||||
Four columns, all scrolling as one document because all four are readings of
|
||||
the same line numbers:
|
||||
|
||||
outline rail | arcs | source with gutter ports | callee rail
|
||||
(≥ 1400px) 56px 320px
|
||||
|
||||
The arc column is the piece that only works here. A call whose callee lives in
|
||||
the same file is a relationship between two LINES, and lines already have
|
||||
positions — the author put them there. So the intra-file call graph can be
|
||||
drawn without a layout algorithm, without physics, and without moving a single
|
||||
symbol from where the reader expects it.
|
||||
|
||||
**Everything is placed arithmetically.** One line is 20px, `lineTop(n)` is its
|
||||
offset, and the arcs, the ports, the rail rows and the connectors all derive
|
||||
from that. Nothing measures the DOM except the x of two column edges. That is
|
||||
what makes a 6 820-line file scroll: ninety-odd line elements exist at a time,
|
||||
and the ones the reader has not reached yet still show their port and their
|
||||
place while the text pages in behind them. See `lib/filecode-model.ts`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import CodeArcs from '../components/file/CodeArcs.svelte';
|
||||
import Connectors from '../components/symbol/Connectors.svelte';
|
||||
import FileCodeBlock from '../components/file/FileCodeBlock.svelte';
|
||||
import FileCodeOutline from '../components/file/FileCodeOutline.svelte';
|
||||
import FileCodeRail from '../components/file/FileCodeRail.svelte';
|
||||
import FileModeTabs from '../components/file/FileModeTabs.svelte';
|
||||
import KindGlyph from '../components/KindGlyph.svelte';
|
||||
import {
|
||||
ApiFailure,
|
||||
fetchFileCode,
|
||||
fetchSource,
|
||||
type WireFileCodePayload,
|
||||
type WireNodeRef,
|
||||
} from '../lib/api';
|
||||
import { tokensByLine, type Token } from '../lib/highlight';
|
||||
import { hot } from '../lib/focus.svelte';
|
||||
import { basename, formatBytes, type OutlineEntryRow } from '../lib/file-model';
|
||||
import {
|
||||
ARC_CROWD_LIMIT,
|
||||
arcSummary,
|
||||
arcsInRange,
|
||||
buildFileArcs,
|
||||
buildFileCallRows,
|
||||
buildFileRefs,
|
||||
documentHeight,
|
||||
lineAtOffset,
|
||||
lineCentre,
|
||||
lineTop,
|
||||
ownerAt,
|
||||
pageFor,
|
||||
pagesForRange,
|
||||
railHeight,
|
||||
rowsInRange,
|
||||
visibleArcs,
|
||||
visibleLines,
|
||||
type FileArc,
|
||||
type FileCallRow,
|
||||
} from '../lib/filecode-model';
|
||||
import { plural, synthesizedBy, type Connector, type LineRef } from '../lib/symbol-model';
|
||||
import { walkTo } from '../lib/walk';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
line: number | null;
|
||||
}
|
||||
|
||||
let { path, line }: Props = $props();
|
||||
|
||||
/* ---------------------------------------------------------------- data -- */
|
||||
|
||||
let payload = $state<WireFileCodePayload | null>(null);
|
||||
let failure = $state<ApiFailure | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
/**
|
||||
* Classified source by file line, filled in a page at a time.
|
||||
*
|
||||
* A plain Map behind a `$state` box: pages arrive a dozen times over a whole
|
||||
* file, so replacing the map on each one costs nothing measurable and keeps
|
||||
* the reads inside the code block's `$derived` honest.
|
||||
*/
|
||||
let tokens = $state(new Map<number, Token[]>());
|
||||
let loadedPages = new Set<number>();
|
||||
let inflightPages = new Set<number>();
|
||||
let pageError = $state<string | null>(null);
|
||||
/** Aborts every page still in flight when the screen moves to another file. */
|
||||
let pageController: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const wanted = path;
|
||||
const controller = new AbortController();
|
||||
untrack(() => load(wanted, controller.signal));
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
async function load(file: string, signal: AbortSignal): Promise<void> {
|
||||
loading = true;
|
||||
failure = null;
|
||||
payload = null;
|
||||
tokens = new Map();
|
||||
loadedPages = new Set();
|
||||
inflightPages = new Set();
|
||||
pageError = null;
|
||||
pageController?.abort();
|
||||
pageController = new AbortController();
|
||||
landed = null;
|
||||
hoverLine = null;
|
||||
hoverFocus = null;
|
||||
highlight = null;
|
||||
if (stageEl) stageEl.scrollTop = 0;
|
||||
try {
|
||||
const next = await fetchFileCode(file, signal);
|
||||
if (signal.aborted) return;
|
||||
payload = next;
|
||||
} catch (cause) {
|
||||
if (signal.aborted) return;
|
||||
failure =
|
||||
cause instanceof ApiFailure
|
||||
? cause
|
||||
: new ApiFailure(0, 'error', cause instanceof Error ? cause.message : String(cause), null);
|
||||
} finally {
|
||||
if (!signal.aborted) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one page of source and merge its tokens in.
|
||||
*
|
||||
* The request reaches back {@link PAGE_LEAD_IN} lines and the lead-in is
|
||||
* thrown away: a page starting inside a block comment cannot tell that it is,
|
||||
* and would render the prose as code. See `filecode-model.ts`.
|
||||
*/
|
||||
async function loadPage(index: number): Promise<void> {
|
||||
const file = payload?.file;
|
||||
if (!file || file.totalLines === null) return;
|
||||
if (loadedPages.has(index) || inflightPages.has(index)) return;
|
||||
inflightPages.add(index);
|
||||
const page = pageFor(index, file.totalLines);
|
||||
const signal = pageController?.signal;
|
||||
try {
|
||||
const slice = await fetchSource(file.path, page.requestFrom, page.to, signal);
|
||||
// A different file (or a reload) landed while this was in flight.
|
||||
if (signal?.aborted || payload?.file.path !== file.path) return;
|
||||
if (!slice.lines) {
|
||||
pageError = slice.reason ?? 'Source is not available for this file.';
|
||||
return;
|
||||
}
|
||||
const from = slice.from ?? page.requestFrom;
|
||||
const decoded = tokensByLine(slice.lines, from, slice.highlight);
|
||||
const next = new Map(tokens);
|
||||
for (const [n, value] of decoded) if (n >= page.from) next.set(n, value);
|
||||
tokens = next;
|
||||
loadedPages.add(index);
|
||||
} catch (cause) {
|
||||
if (signal?.aborted) return;
|
||||
pageError = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
inflightPages.delete(index);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- models -- */
|
||||
|
||||
let totalLines = $derived(payload?.file.totalLines ?? 0);
|
||||
let refs = $derived(payload ? buildFileRefs(payload) : new Map<number, LineRef[]>());
|
||||
let rows = $derived(payload ? buildFileCallRows(payload) : []);
|
||||
let arcs = $derived(payload ? buildFileArcs(payload, rows) : []);
|
||||
let crowded = $derived(arcs.length > ARC_CROWD_LIMIT);
|
||||
|
||||
let outlineRows = $derived<OutlineEntryRow[]>(
|
||||
(payload?.outline.items ?? []).map((entry) => ({
|
||||
entry,
|
||||
indent: Math.min(entry.depth, 3),
|
||||
dimmed: QUIET_KINDS.has(entry.kind),
|
||||
}))
|
||||
);
|
||||
|
||||
const QUIET_KINDS = new Set(['property', 'field', 'enum_member', 'variable', 'constant']);
|
||||
|
||||
/** Lines a definition starts on → its name, so the name is bold in the body. */
|
||||
let defNames = $derived.by(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const entry of payload?.outline.items ?? []) {
|
||||
if (!map.has(entry.line)) map.set(entry.line, entry.name);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Every symbol this file's calls reach, by id — what a call-site link opens. */
|
||||
let targets = $derived.by(() => {
|
||||
const map = new Map<string, WireNodeRef>();
|
||||
for (const row of rows) map.set(row.call.relation.node.id, row.call.relation.node);
|
||||
return map;
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------- scroll -- */
|
||||
|
||||
let stageEl = $state<HTMLElement | null>(null);
|
||||
let codeEl = $state<HTMLElement | null>(null);
|
||||
let railEl = $state<HTMLElement | null>(null);
|
||||
let scrollTop = $state(0);
|
||||
let viewport = $state(0);
|
||||
/** x of the code column's right edge and the rail's left, for the hairlines. */
|
||||
let columns = $state({ x0: 0, x1: 0, width: 0 });
|
||||
|
||||
$effect(() => {
|
||||
const el = stageEl;
|
||||
if (!el) return;
|
||||
const read = (): void => {
|
||||
scrollTop = el.scrollTop;
|
||||
viewport = el.clientHeight;
|
||||
};
|
||||
const measure = (): void => {
|
||||
read();
|
||||
const code = codeEl;
|
||||
const rail = railEl;
|
||||
if (!code || !rail) return;
|
||||
columns = {
|
||||
x0: code.offsetLeft + code.offsetWidth - 10,
|
||||
x1: rail.offsetLeft + 14,
|
||||
width: el.scrollWidth,
|
||||
};
|
||||
};
|
||||
measure();
|
||||
el.addEventListener('scroll', read, { passive: true });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', read);
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
let visible = $derived(visibleLines(scrollTop, viewport, totalLines));
|
||||
|
||||
// Whatever is on screen has to have its source. The pages are fetched here
|
||||
// rather than inside the block so a fast scroll past a page does not leave a
|
||||
// request for it half-applied to a screen that has moved on.
|
||||
$effect(() => {
|
||||
if (!payload || payload.drift) return;
|
||||
const wanted = pagesForRange(visible.first, visible.last, totalLines);
|
||||
untrack(() => {
|
||||
for (const index of wanted) void loadPage(index);
|
||||
});
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------- focus -- */
|
||||
|
||||
let hoverLine = $state<number | null>(null);
|
||||
/** Set by a rail row: its row names a caller the code lines cannot. */
|
||||
let hoverFocus = $state<string | null>(null);
|
||||
let highlight = $state<number | null>(null);
|
||||
|
||||
/** The symbol the reader is inside, from the scroll position. */
|
||||
let currentId = $derived(
|
||||
ownerAt(payload?.outline.items ?? [], lineAtOffset(scrollTop + 8, totalLines))
|
||||
);
|
||||
|
||||
/**
|
||||
* The symbol whose arcs are drawn once there are too many to draw all of them.
|
||||
*
|
||||
* Pointer first, scroll position last. There is deliberately no *pinned*
|
||||
* symbol: clicking an arc scrolls to its callee, so the callee becomes the
|
||||
* symbol the reader is inside and its arcs follow from that. A pin would be a
|
||||
* second, invisible piece of state answering the same question — and would go
|
||||
* stale the moment the reader scrolled somewhere else.
|
||||
*/
|
||||
let focusId = $derived.by(() => {
|
||||
if (hoverFocus !== null) return hoverFocus;
|
||||
const outline = payload?.outline.items ?? [];
|
||||
if (hoverLine !== null) return ownerAt(outline, hoverLine);
|
||||
return currentId;
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------- drawing -- */
|
||||
|
||||
let shownArcs = $derived(visibleArcs(arcs, focusId, crowded));
|
||||
let windowArcs = $derived(arcsInRange(shownArcs, visible.first, visible.last));
|
||||
|
||||
let windowRows = $derived(
|
||||
rowsInRange(rows, scrollTop - viewport, scrollTop + viewport * 2)
|
||||
);
|
||||
|
||||
/**
|
||||
* One hairline per call site, from the gutter port to its rail row.
|
||||
*
|
||||
* Both coordinates are the document's, so they hold at any scroll position —
|
||||
* and only the rows on screen are drawn, which is what keeps eight hundred
|
||||
* call sites from being eight hundred paths.
|
||||
*/
|
||||
let connectors = $derived.by<Connector[]>(() => {
|
||||
const { x0, x1 } = columns;
|
||||
if (x1 <= x0) return [];
|
||||
const cx = (x0 + x1) / 2;
|
||||
const out: Connector[] = [];
|
||||
for (const row of windowRows) {
|
||||
const ry = row.top + 17;
|
||||
const via = synthesizedBy(row.call.relation);
|
||||
for (const callLine of row.lines) {
|
||||
const ly = lineCentre(callLine);
|
||||
out.push({
|
||||
d: `M${x0},${ly} C${cx},${ly} ${cx},${ry} ${x1},${ry}`,
|
||||
targetId: row.call.relation.node.id,
|
||||
uncertain: row.call.relation.uncertain,
|
||||
heuristic: via !== null,
|
||||
origin: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
let docHeight = $derived(Math.max(documentHeight(totalLines), railHeight(rows)));
|
||||
|
||||
/* ------------------------------------------------------------ movement -- */
|
||||
|
||||
/** Scroll a line into the upper third, where a reader looks for it. */
|
||||
function goToLine(target: number): void {
|
||||
const el = stageEl;
|
||||
if (!el) return;
|
||||
el.scrollTop = Math.max(0, lineTop(target) - el.clientHeight / 3);
|
||||
highlight = target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicking an arc focuses its callee: the source moves to the definition and
|
||||
* the callee lights everywhere it appears — its rail row, its call sites,
|
||||
* its other arcs. The call line the reader came from stays the thing that put
|
||||
* it on screen, which is why the pin is the TARGET and not the owner.
|
||||
*/
|
||||
function followArc(arc: FileArc): void {
|
||||
hot.set(arc.targetId);
|
||||
goToLine(arc.toLine);
|
||||
}
|
||||
|
||||
/** A call site in the body, or a rail row: open the callee as a symbol. */
|
||||
function openNode(node: WireNodeRef): void {
|
||||
// Same file, same screen — jump rather than leave.
|
||||
if (node.file === payload?.file.path) {
|
||||
hot.set(node.id);
|
||||
goToLine(node.line);
|
||||
return;
|
||||
}
|
||||
walkTo({ id: node.id, name: node.name, kind: node.kind }, 'start');
|
||||
}
|
||||
|
||||
function followRef(ref: LineRef): void {
|
||||
const node = ref.targetId ? targets.get(ref.targetId) : undefined;
|
||||
if (node) openNode(node);
|
||||
}
|
||||
|
||||
/** A rail row names the CALLER, which is the symbol its arcs belong to. */
|
||||
function onhoverRow(row: FileCallRow | null): void {
|
||||
hoverFocus = row ? row.ownerId : null;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- arriving at a line -- */
|
||||
|
||||
let landed: string | null = null;
|
||||
$effect(() => {
|
||||
const key = line === null ? null : `${path}:${line}`;
|
||||
if (!key || !payload || landed === key || !stageEl || totalLines === 0) return;
|
||||
landed = key;
|
||||
goToLine(line as number);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if failure}
|
||||
<div class="scroll">
|
||||
<div class="emptystate">
|
||||
<h2>{failure.code === 'not-found' ? 'Not in the index' : 'Could not load this file'}</h2>
|
||||
<p class="mono">{path}</p>
|
||||
<p>{failure.message}</p>
|
||||
{#if failure.guidance}<p class="dim">{failure.guidance}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading || !payload}
|
||||
<div class="scroll">
|
||||
<div class="emptystate"><p class="dim">Loading…</p></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="codeview" class:wide={outlineRows.length > 0}>
|
||||
{#if outlineRows.length > 0}
|
||||
<aside class="nav">
|
||||
<FileCodeOutline
|
||||
rows={outlineRows}
|
||||
total={payload.outline.total}
|
||||
truncated={payload.outline.truncated}
|
||||
{currentId}
|
||||
ongo={(at, id) => {
|
||||
hot.set(id);
|
||||
goToLine(at);
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<section class="main">
|
||||
<header class="band">
|
||||
<div class="card-h">
|
||||
<KindGlyph kind="file" />
|
||||
<h1>{basename(payload.file.path)}</h1>
|
||||
<span class="kindword">
|
||||
{payload.file.language} · {formatBytes(payload.file.size)} ·
|
||||
{payload.file.totalLines === null
|
||||
? 'length unknown'
|
||||
: plural(payload.file.totalLines, 'line')} ·
|
||||
{plural(payload.outline.total, 'symbol')}
|
||||
</span>
|
||||
<span class="loc">{payload.file.path}</span>
|
||||
<div class="spacer"></div>
|
||||
<FileModeTabs path={payload.file.path} {line} source={true} />
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="arcnote">
|
||||
{arcSummary(payload.intraFileCalls)}{#if crowded}{' '}<span class="dim"
|
||||
>— showing the ones the symbol under the pointer takes part in</span
|
||||
>{/if}
|
||||
</span>
|
||||
<span
|
||||
class="railnote"
|
||||
title="One row per pair: a symbol in this file, and a symbol it reaches."
|
||||
>
|
||||
Calls <span class="n">{payload.calls.total}</span>{#if payload.calls.truncated}<span
|
||||
class="dim"> · showing {payload.calls.shown}</span
|
||||
>{/if}{#if payload.outside.total > 0}{' '}<span class="dim"
|
||||
>· {plural(payload.outside.total, 'reference')} outside the index</span
|
||||
>{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if payload.drift}
|
||||
<div class="drift">
|
||||
{payload.reason ??
|
||||
'This file changed on disk after the last index sync.'} The source is not shown, because
|
||||
the line numbers the graph holds no longer match it. Run <code>codegraph sync</code> to bring
|
||||
them up to date.
|
||||
</div>
|
||||
{:else if payload.file.totalLines === null}
|
||||
<div class="drift">
|
||||
{payload.reason ?? 'This file could not be read from disk.'}
|
||||
</div>
|
||||
{:else if pageError}
|
||||
<div class="drift">{pageError}</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if !payload.drift && payload.file.totalLines !== null}
|
||||
<div
|
||||
class="stage"
|
||||
bind:this={stageEl}
|
||||
onmouseleave={() => {
|
||||
hoverLine = null;
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="stage-inner" style:height={`${docHeight}px`}>
|
||||
<div class="arccol">
|
||||
<CodeArcs arcs={windowArcs} height={docHeight} {hoverLine} onfollow={followArc} />
|
||||
</div>
|
||||
|
||||
<div class="codecol" bind:this={codeEl}>
|
||||
<FileCodeBlock
|
||||
first={visible.first}
|
||||
last={visible.last}
|
||||
tokensFor={(n) => tokens.get(n) ?? null}
|
||||
{refs}
|
||||
{defNames}
|
||||
{highlight}
|
||||
onfollow={followRef}
|
||||
onhoverline={(n) => {
|
||||
hoverLine = n;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside class="rail" bind:this={railEl} aria-label="Calls">
|
||||
<FileCodeRail
|
||||
rows={windowRows}
|
||||
focalFile={payload.file.path}
|
||||
{focusId}
|
||||
onopen={openNode}
|
||||
onhover={onhoverRow}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<Connectors {connectors} width={columns.width} height={docHeight} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.codeview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The navigation rail is a luxury, not the screen: below 1400px the code and
|
||||
its two margins take the whole width rather than all three squeezing. */
|
||||
@media (min-width: 1400px) {
|
||||
.codeview.wide {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.codeview.wide .nav {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Outside the scroller on purpose: a header inside it would put every line at
|
||||
`headerHeight + (n - 1) * 20`, and the header's height is a measurement. */
|
||||
.band {
|
||||
padding: 14px 22px 8px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.kindword {
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.loc {
|
||||
color: var(--ink-2);
|
||||
font: 11.5px var(--mono);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.railnote .n {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.arcnote,
|
||||
.railnote {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drift {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--amber);
|
||||
background: var(--amber-soft);
|
||||
color: var(--amber);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drift code {
|
||||
font: 12px var(--mono);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* The positioning context every arithmetic coordinate is expressed in: line
|
||||
tops, arc endpoints, rail rows and the connector overlay share this origin. */
|
||||
.stage-inner {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(420px, 1fr) 320px;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.arccol {
|
||||
position: relative;
|
||||
border-right: 1px solid var(--rule-faint);
|
||||
}
|
||||
|
||||
.codecol {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.rail {
|
||||
position: relative;
|
||||
border-left: 1px solid var(--rule-faint);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stage-inner {
|
||||
grid-template-columns: 56px minmax(320px, 1fr) 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,13 +11,15 @@
|
||||
— every cross-file edge, not just resolved import statements. See
|
||||
`lib/file-model.ts` for why that distinction is the whole point of the rails.
|
||||
|
||||
Whole-file source with gutter ports is phase 2 (CG-52); this screen is the
|
||||
outline, and the row that opens a symbol is the way into the code.
|
||||
The whole file's source, with the same gutter ports and an arc diagram for the
|
||||
calls that stay inside it, is the other reading of this screen — `?src=1`,
|
||||
`FileCodeView.svelte` (CG-52). The tabs in the header switch between them.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from 'svelte';
|
||||
import FileOutline from '../components/file/FileOutline.svelte';
|
||||
import FileRail from '../components/file/FileRail.svelte';
|
||||
import FileModeTabs from '../components/file/FileModeTabs.svelte';
|
||||
import KindGlyph from '../components/KindGlyph.svelte';
|
||||
import { ApiFailure, fetchFile, type WireFilePayload, type WireNodeRef } from '../lib/api';
|
||||
import {
|
||||
@@ -253,6 +255,8 @@
|
||||
<h1>{basename(payload.file.path)}</h1>
|
||||
<span class="kindword">{fileMetaLine(payload)}</span>
|
||||
<span class="loc">{payload.file.path}</span>
|
||||
<div class="spacer"></div>
|
||||
<FileModeTabs path={payload.file.path} {line} source={false} />
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
@@ -350,6 +354,10 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.kindword {
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
|
||||
Reference in new issue
Block a user