`GET /api/events` is a server-sent-event stream the viewer holds open for the life of the page. Two signals, two things the browser could not know: changed source files touched on disk, before any sync — the drift banner index the graph moved, naming what the sync re-indexed — the live refresh The server WATCHES and never syncs: the project tree through the engine's own FileWatcher with a notify-only syncFn, the index through one non-recursive fs.watch on the data directory settled at 400 ms. Both start with the first subscriber and stop with the last, so a viewer nobody has open costs no watch descriptors. Nothing polls, on either side. Drift is now parity with codegraph_node (#1474) rather than an absence. `/api/source?ondrift=current` serves a drifted file's CURRENT bytes flagged `showing: 'current'`, and the three screens that can say so switch off everything anchored to the old line numbering — gutter ports, call-site links, call arcs, the callee rail's anchoring — while keeping the source. The banner is paper-2 with a hairline rule, never amber: amber belongs to the untested badge. Also fixes a stale read this exposed. A long-lived reader holds an LRU of nodes by id that only its own writes invalidate, so `/api/node/<id>` kept answering with a symbol another process's sync had deleted while `/api/search` beside it said it was gone. GraphSession now drops the read caches when the database (or its WAL) has been written, and the Symbol view follows a symbol whose id changed because an edit above it moved its start line, carrying the trail across. Measured on a live viewer: banner 360 ms after a save, toast 440 ms after `codegraph sync` returns, 0 requests in 4 idle seconds, and the client gives up reconnecting after ~90 s with "Not live" rather than hammering a dead port.
282 lines
7.0 KiB
Svelte
282 lines
7.0 KiB
Svelte
<!--
|
||
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}
|
||
<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>
|