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
+106 -44
View File
@@ -30,6 +30,7 @@
import FileCodeRail from '../components/file/FileCodeRail.svelte';
import FileModeTabs from '../components/file/FileModeTabs.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
import DriftBanner from '../components/DriftBanner.svelte';
import {
ApiFailure,
fetchFileCode,
@@ -61,6 +62,7 @@
type FileArc,
type FileCallRow,
} from '../lib/filecode-model';
import { liveRefresh } from '../lib/live.svelte';
import { plural, synthesizedBy, type Connector, type LineRef } from '../lib/symbol-model';
import { walkTo } from '../lib/walk';
@@ -98,25 +100,49 @@
return () => controller.abort();
});
async function load(file: string, signal: AbortSignal): Promise<void> {
loading = true;
failure = null;
payload = null;
/** Aborts a live-triggered reload when the screen moves on without it. */
let liveController: AbortController | null = null;
// The index moved, or this file changed on disk. Both change what is drawn in
// the margins AND what the source says, so both drop every cached page — but
// the scroll position stays, because the reader has not moved.
liveRefresh(
() => payload?.file.path ?? path,
() => {
const wanted = path;
liveController?.abort();
liveController = new AbortController();
void load(wanted, liveController.signal, true);
}
);
/**
* @param quiet a live refresh rather than a navigation: the pages are still
* thrown away (the file changed — that is the whole point) but the scroll
* position, the landing and the header stay put.
*/
async function load(file: string, signal: AbortSignal, quiet = false): Promise<void> {
if (!quiet) {
loading = true;
failure = null;
payload = null;
landed = null;
hoverLine = null;
hoverFocus = null;
highlight = null;
if (stageEl) stageEl.scrollTop = 0;
}
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;
failure = null;
} catch (cause) {
if (signal.aborted) return;
failure =
@@ -143,7 +169,17 @@
const page = pageFor(index, file.totalLines);
const signal = pageController?.signal;
try {
const slice = await fetchSource(file.path, page.requestFrom, page.to, signal);
// A drifted file is paged as its CURRENT bytes: the numbering the pages
// use is the file's own, and every graph-derived marking over it is off
// (see `driftMode` below). Showing nothing would be honest and useless —
// the source itself is still exactly readable.
const slice = await fetchSource(
file.path,
page.requestFrom,
page.to,
signal,
payload?.drift ? 'current' : undefined
);
// A different file (or a reload) landed while this was in flight.
if (signal?.aborted || payload?.file.path !== file.path) return;
if (!slice.lines) {
@@ -166,18 +202,35 @@
/* -------------------------------------------------------------- models -- */
/**
* The file has changed on disk since it was indexed.
*
* Everything in this screen's margins — the arcs, the gutter ports, the rail
* rows, the outline's line numbers — is a line number the graph recorded, and
* the file no longer has those lines. So in this mode the margins go away and
* the source stays: current bytes are correct by construction, and a call arc
* drawn between two lines that have moved is the one thing here that could be
* confidently wrong. Parity with `codegraph_node`, which serves a drifted
* file whole and current rather than slicing it (issue #1474).
*/
let driftMode = $derived(payload?.drift === true);
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 refs = $derived(
payload && !driftMode ? buildFileRefs(payload) : new Map<number, LineRef[]>()
);
let rows = $derived(payload && !driftMode ? buildFileCallRows(payload) : []);
let arcs = $derived(payload && !driftMode ? 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),
}))
driftMode
? []
: (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']);
@@ -185,6 +238,7 @@
/** 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>();
if (driftMode) return map;
for (const entry of payload?.outline.items ?? []) {
if (!map.has(entry.line)) map.set(entry.line, entry.name);
}
@@ -242,7 +296,7 @@
// 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;
if (!payload) return;
const wanted = pagesForRange(visible.first, visible.last, totalLines);
untrack(() => {
for (const index of wanted) void loadPage(index);
@@ -417,7 +471,7 @@
<FileModeTabs path={payload.file.path} {line} source={true} />
</div>
<div class="toolbar">
<div class="toolbar" class:hidden={driftMode}>
<span class="arcnote">
{arcSummary(payload.intraFileCalls)}{#if crowded}{' '}<span class="dim"
>— showing the ones the symbol under the pointer takes part in</span
@@ -436,22 +490,25 @@
</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 class="banner">
<DriftBanner file={payload.file.path}>
indexed line ranges may be shifted; showing the file's current source, with the
call arcs, ports and rail switched off — they are drawn from lines this file no
longer has. The next sync picks it up.
</DriftBanner>
</div>
{:else if payload.file.totalLines === null}
<div class="drift">
{payload.reason ?? 'This file could not be read from disk.'}
<div class="banner">
<DriftBanner file={payload.file.path}>
{payload.reason ?? 'it could not be read from disk.'}
</DriftBanner>
</div>
{:else if pageError}
<div class="drift">{pageError}</div>
<div class="note">{pageError}</div>
{/if}
</header>
{#if !payload.drift && payload.file.totalLines !== null}
{#if payload.file.totalLines !== null}
<div
class="stage"
bind:this={stageEl}
@@ -462,7 +519,9 @@
>
<div class="stage-inner" style:height={`${docHeight}px`}>
<div class="arccol">
<CodeArcs arcs={windowArcs} height={docHeight} {hoverLine} onfollow={followArc} />
{#if !driftMode}
<CodeArcs arcs={windowArcs} height={docHeight} {hoverLine} onfollow={followArc} />
{/if}
</div>
<div class="codecol" bind:this={codeEl}>
@@ -481,13 +540,15 @@
</div>
<aside class="rail" bind:this={railEl} aria-label="Calls">
<FileCodeRail
rows={windowRows}
focalFile={payload.file.path}
{focusId}
onopen={openNode}
onhover={onhoverRow}
/>
{#if !driftMode}
<FileCodeRail
rows={windowRows}
focalFile={payload.file.path}
{focusId}
onopen={openNode}
onhover={onhoverRow}
/>
{/if}
</aside>
<Connectors {connectors} width={columns.width} height={docHeight} />
@@ -592,18 +653,19 @@
white-space: nowrap;
}
.drift {
.banner {
margin-top: 10px;
padding: 8px 12px;
border: 1px solid var(--amber);
background: var(--amber-soft);
color: var(--amber);
}
.note {
margin-top: 10px;
color: var(--ink-3);
font-size: 12.5px;
line-height: 1.5;
}
.drift code {
font: 12px var(--mono);
.toolbar.hidden {
display: none;
}
.stage {
+41 -24
View File
@@ -21,6 +21,7 @@
import FileRail from '../components/file/FileRail.svelte';
import FileModeTabs from '../components/file/FileModeTabs.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
import DriftBanner from '../components/DriftBanner.svelte';
import { ApiFailure, fetchFile, type WireFilePayload, type WireNodeRef } from '../lib/api';
import {
basename,
@@ -29,6 +30,7 @@
fileMetaLine,
} from '../lib/file-model';
import { fileHref, navigate } from '../lib/router.svelte';
import { liveRefresh } from '../lib/live.svelte';
import { plural } from '../lib/symbol-model';
import { walkTo } from '../lib/walk';
@@ -55,19 +57,41 @@
return () => controller.abort();
});
async function load(file: string, signal: AbortSignal): Promise<void> {
loading = true;
failure = null;
payload = null;
pane = 'outline';
index = -1;
// Leaving and coming back to the same `?hl=` URL must land again; the
// guard below only exists to stop a re-render re-selecting.
landed = null;
/** Aborts a live-triggered reload when the screen moves on without it. */
let liveController: AbortController | null = null;
// The index moved, or this file changed on disk (the drift banner). Refetch
// in place — the outline is where the reader's eye is.
liveRefresh(
() => payload?.file.path ?? path,
() => {
const wanted = path;
liveController?.abort();
liveController = new AbortController();
void load(wanted, liveController.signal, true);
}
);
/**
* @param quiet a live refresh rather than a navigation: keep the outline on
* screen (and the reader's selection in it) until the new payload lands.
*/
async function load(file: string, signal: AbortSignal, quiet = false): Promise<void> {
if (!quiet) {
loading = true;
failure = null;
payload = null;
pane = 'outline';
index = -1;
// Leaving and coming back to the same `?hl=` URL must land again; the
// guard below only exists to stop a re-render re-selecting.
landed = null;
}
try {
const next = await fetchFile(file, signal);
if (signal.aborted) return;
payload = next;
failure = null;
} catch (cause) {
if (signal.aborted) return;
failure =
@@ -280,10 +304,13 @@
</div>
{#if payload.drift}
<div class="drift">
This file changed on disk after the last index sync, so the line numbers below
are the ones it had when it was indexed. Run <code>codegraph sync</code> to bring
them up to date.
<div class="banner">
<DriftBanner file={payload.file.path}>
indexed line ranges may be shifted, so the outline below is the shape the file had
when it was indexed —
<a href={fileHref(payload.file.path, { source: true })}>read its current source</a>.
The next sync picks it up.
</DriftBanner>
</div>
{/if}
@@ -403,18 +430,8 @@
text-underline-offset: 3px;
}
.drift {
.banner {
margin-top: 12px;
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: 12px var(--mono);
}
@media (max-width: 1100px) {
+8 -2
View File
@@ -19,6 +19,7 @@
import FlowCard from '../components/flow/FlowCard.svelte';
import FlowLink from '../components/flow/FlowLink.svelte';
import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { navigate, symbolHref } from '../lib/router.svelte';
import { trail, encodeTrail, type TrailHop } from '../lib/trail.svelte';
import { decodeTrail } from '../lib/trail-codec';
@@ -72,14 +73,19 @@
error = null;
return;
}
// Re-run when the index moves: a path is a walk over edges that a sync can
// add, remove or re-route, and a strip drawn from the previous graph would
// disagree with `codegraph_explore` about the same question.
void live.indexTick;
const controller = new AbortController();
loading = true;
error = null;
const keep = picked;
fetchFlow(spec, controller.signal)
.then((next) => {
payload = next;
picked = next.flows[0]?.id ?? null;
showAll = false;
// A refresh keeps the reader's chosen path when it survived the sync.
picked = next.flows.some((f) => f.id === keep) ? keep : (next.flows[0]?.id ?? null);
loading = false;
})
.catch((err: unknown) => {
+6
View File
@@ -19,6 +19,7 @@
import ModuleEdge from '../components/map/ModuleEdge.svelte';
import MapSidePanel from '../components/map/MapSidePanel.svelte';
import { fetchMap, type WireMapPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { mapHref, navigate } from '../lib/router.svelte';
import {
buildMapLayout,
@@ -62,6 +63,11 @@
$effect(() => {
const wantRoot = root;
const wantDepth = depth;
// Read so the effect re-runs when the index moves: the map IS the graph,
// and the layering changes with it. The canvas stays on screen while the
// new aggregation lands (the server answers a cached one in milliseconds
// when nothing actually changed).
void live.indexTick;
const controller = new AbortController();
loading = true;
error = null;
+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 {