feat(ui): live refresh and drift banners — the viewer keeps up with the project (CG-53)

`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.
This commit is contained in:
Colby McHenry
2026-08-27 04:28:56 -05:00
parent bd99c5e99a
commit ecd6e1cd15
28 changed files with 2236 additions and 142 deletions
+161 -34
View File
@@ -22,7 +22,17 @@
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 DriftBanner from '../components/DriftBanner.svelte';
import {
ApiFailure,
fetchFile,
fetchSource,
fetchSymbol,
type WireNodeDetail,
type WireNodeRef,
type WireSource,
type WireSymbolPayload,
} from '../lib/api';
import { tokensByLine, type Token } from '../lib/highlight';
import { hot, railFocus } from '../lib/focus.svelte';
import { project } from '../lib/project.svelte';
@@ -38,7 +48,9 @@
type Connector,
type LineRef,
} from '../lib/symbol-model';
import { trail } from '../lib/trail.svelte';
import { encodeTrail, trail } from '../lib/trail.svelte';
import { liveRefresh } from '../lib/live.svelte';
import { fileHref, navigate, symbolHref } from '../lib/router.svelte';
import { arrivedFrom, walkTo } from '../lib/walk';
interface Props {
@@ -56,6 +68,21 @@
/** Fallback for the sticky rail header before it has been measured. */
const RAIL_HEADER_FALLBACK = 38;
/**
* How much of a DRIFTED file this screen will show in place of the body.
*
* When the file has moved on, the symbol's indexed range names nothing, so
* the only correct source to show is the whole current file — the same call
* `codegraph_node` makes (issue #1474), for the same reason: current bytes
* are right by construction, a slice of them is a guess. Past this length
* that stops being a symbol view and becomes a file view badly done, so the
* banner points at the real one instead.
*/
const DRIFT_INLINE_MAX_LINES = 400;
/** One shared empty map, so the drift path does not allocate per render. */
const EMPTY_REFS: Map<number, LineRef[]> = new Map();
/* --------------------------------------------------------------- state -- */
let payload = $state<WireSymbolPayload | null>(null);
@@ -92,14 +119,35 @@
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);
placed = false;
/** Aborts a live-triggered reload when the screen moves on without it. */
let liveController: AbortController | null = null;
// The graph moved, or the file on screen changed on disk. Either way what is
// drawn is out of date; refetch it in place rather than blanking the screen.
liveRefresh(
() => payload?.node.file ?? null,
() => {
const wanted = id;
liveController?.abort();
liveController = new AbortController();
void load(wanted, liveController.signal, true);
}
);
/**
* @param quiet a live refresh rather than a navigation: keep what is on
* screen until the new payload lands, so a sync does not blink the view.
*/
async function load(nodeId: string, signal: AbortSignal, quiet = false): Promise<void> {
if (!quiet) {
loading = true;
failure = null;
payload = null;
source = null;
railFocus.reset();
hot.set(null);
placed = false;
}
void project.ensure();
let node: WireSymbolPayload;
@@ -107,25 +155,73 @@
node = await fetchSymbol(nodeId, signal);
} catch (cause) {
if (signal.aborted) return;
// A node's id contains its start line, so a sync that moved this symbol
// down two lines answers 404 for an id that was valid a second ago. On a
// live refresh — and only there, because only there do we still hold the
// symbol's name — find it again in its file rather than telling the
// reader their screen no longer exists.
if (quiet && asFailure(cause).code === 'not-found' && payload !== null) {
const moved = await refind(payload.node, signal);
if (signal.aborted) return;
if (moved !== null) {
trail.rename(nodeId, moved);
navigate(symbolHref(moved.id, { trail: encodeTrail(trail.hops) }), { replace: true });
return;
}
}
failure = asFailure(cause);
loading = false;
return;
}
if (signal.aborted) return;
payload = node;
failure = null;
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;
if (!showsBody(node.node.kind, node.node.lines)) {
source = null;
return;
}
try {
const slice = await fetchSource(node.node.file, node.node.line, node.node.endLine, signal);
// A drifted file is asked for WHOLE and CURRENT — its indexed range is
// the one thing about it that is certainly wrong — and the answer comes
// back flagged `showing: 'current'`, which is what switches every
// line-anchored marking below off.
const slice = node.drift
? await fetchSource(node.node.file, 1, 0, signal, 'current')
: 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.
if (!signal.aborted) source = null;
}
}
/**
* The same symbol after a sync renumbered its file.
*
* The file's outline is the exact answer — every symbol in that file with its
* new id — so this is one request and no ranking. Same name and kind is the
* match; when a file holds several (overloads), the one that moved least is
* the one the reader was on.
*/
async function refind(previous: WireNodeDetail, signal: AbortSignal): Promise<WireNodeRef | null> {
try {
const file = await fetchFile(previous.file, signal);
const candidates = file.outline.items.filter(
(entry) => entry.name === previous.name && entry.kind === previous.kind
);
if (candidates.length === 0) return null;
return candidates.reduce((best, entry) =>
Math.abs(entry.line - previous.line) < Math.abs(best.line - previous.line) ? entry : best
);
} catch {
return null;
}
}
@@ -143,8 +239,29 @@
let wantsBody = $derived(payload ? showsBody(payload.node.kind, payload.node.lines) : false);
/**
* The body on screen is the file's CURRENT source, not this symbol's.
*
* Everything the graph knows is anchored to line numbers the file no longer
* has, so in this mode the ports, the call-site links, the definition-name
* weight and the `?line=` highlight are all switched off together. Leaving
* any one of them on would put a marking from the previous version of the
* file over a line of the new one — a lie that looks exactly like the truth.
*/
let showingCurrent = $derived(source?.showing === 'current');
/** A drifted file too long to stand in for the body; the banner links out. */
let driftTooLong = $derived(
payload?.drift === true &&
(source === null || (source.totalLines ?? 0) > DRIFT_INLINE_MAX_LINES)
);
let codeBlock = $derived.by(() => {
if (!payload || !source?.lines) return null;
if (showingCurrent) {
if (driftTooLong) return null;
return buildCodeBlock(source.from ?? 1, source.lines, []);
}
const from = source.from ?? payload.node.line;
return buildCodeBlock(from, source.lines, graphCallLines(payload));
});
@@ -290,7 +407,16 @@
const headerHeight =
rail.querySelector<HTMLElement>('[data-rail-header]')?.offsetHeight ?? RAIL_HEADER_FALLBACK;
// A drifted file's body is the CURRENT source under CURRENT numbering, and
// the rail's anchors are the numbers the index recorded. A line that
// happens to exist in both is a coincidence, not a call site — so in that
// mode nothing is anchored: the rows stack in source order and no
// connector is drawn. The rail is still true about WHAT this symbol calls;
// it has stopped being true about WHERE, and says so by not pointing.
const anchored = !showingCurrent;
const lineCentre = (n: number): number | null => {
if (!anchored) return null;
const el = center.querySelector<HTMLElement>(`[data-line="${n}"]`);
return el ? el.offsetTop + el.offsetHeight / 2 : null;
};
@@ -437,21 +563,33 @@
<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 class="banner">
<DriftBanner file={payload.node.file}>
{#if driftTooLong}
indexed line ranges may be shifted, and the file is too long to stand in for
this symbol's body here —
<a href={fileHref(payload.node.file, { source: true })}>open its current source</a>.
The next sync picks it up.
{:else}
indexed line ranges may be shifted; showing the file's current source. The next
sync picks it up.
{/if}
</DriftBanner>
</div>
{:else if codeBlock}
{/if}
{#if codeBlock}
<SourceBlock
block={codeBlock}
tokens={codeTokens}
{refs}
defLine={payload.node.line}
defName={payload.node.name}
highlight={line}
refs={showingCurrent ? EMPTY_REFS : refs}
defLine={showingCurrent ? -1 : payload.node.line}
defName={showingCurrent ? '' : payload.node.name}
highlight={showingCurrent ? null : line}
onfollow={followRef}
/>
{:else if payload.drift}
<!-- The banner above is the whole answer for this file. -->
{:else if !wantsBody}
<!-- The outline below IS the body for a container this size. -->
{:else if source}
@@ -538,19 +676,8 @@
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;
.banner {
margin: 16px 0 4px;
}
.note {