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
+24
View File
@@ -9,9 +9,12 @@
import MapView from './views/MapView.svelte';
import FlowView from './views/FlowView.svelte';
import NotFoundView from './views/NotFoundView.svelte';
import Toast from './components/Toast.svelte';
import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
import { trail, resolveTrailNames } from './lib/trail.svelte';
import { project } from './lib/project.svelte';
import { live } from './lib/live.svelte';
import { toast } from './lib/toast.svelte';
// One `/api/stats` for the whole app: the top bar's counts and the Symbol
// view's blast-radius denominator come out of the same payload.
@@ -19,6 +22,26 @@
void project.ensure();
});
// The live channel: one connection for the page, opened once. Every screen
// reads its counters; nothing polls.
$effect(() => {
live.start();
});
// The index moving is the one thing worth a note — the screen under it has
// already refetched by the time this shows. `/api/stats` is re-read for the
// same reason: the top bar's counts came from the graph that just changed.
let seenIndexTick = live.indexTick;
$effect(() => {
const tick = live.indexTick;
untrack(() => {
if (tick === seenIndexTick) return;
seenIndexTick = tick;
void project.reload();
toast.show('Index updated · reloaded');
});
});
let topbar: TopBar | null = $state(null);
let route = $derived(router.route);
@@ -115,6 +138,7 @@
<HomeView project={project.name} />
{/if}
</main>
<Toast />
<style>
/* The shell grid lives on #app (index.html's mount host) in app.css —
+76
View File
@@ -0,0 +1,76 @@
<!--
"This file changed on disk after the last index sync."
One block, said the same way on every screen that can say it (design spec:
paper-2 fill, hairline rule, ⚠ in ink-3, 12.5px ink-2). Deliberately NOT
amber: amber is the untested badge's colour and nothing else's, and a warning
that borrows it makes two unrelated things look like the same kind of problem.
Deliberately not a modal either — the screen underneath is still mostly true,
and interrupting to say so would be the overclaim.
The caller supplies the tail of the sentence, because what follows the dash is
the only part that differs: what this particular screen did about it.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Project-relative path, shown in mono. */
file: string;
/** The rest of the sentence: what this screen is showing instead. */
children: Snippet;
}
let { file, children }: Props = $props();
</script>
<div class="drift" role="status">
<span class="glyph" aria-hidden="true"></span>
<span class="body"><code>{file}</code> changed on disk after the last index sync — {@render children()}</span>
</div>
<style>
.drift {
display: grid;
grid-template-columns: 16px 1fr;
gap: 6px;
align-items: start;
padding: 8px 12px;
border: 1px solid var(--rule-soft);
background: var(--paper-2);
color: var(--ink-2);
font-size: 12.5px;
line-height: 1.5;
}
.glyph {
color: var(--ink-3);
font-size: 12px;
line-height: 1.55;
}
.body :global(code) {
font-family: var(--mono);
font-size: 12px;
color: var(--ink);
}
.body :global(button) {
background: none;
border: 0;
padding: 0;
color: var(--accent);
font: inherit;
cursor: pointer;
text-decoration: underline;
text-decoration-color: var(--accent-line);
text-underline-offset: 3px;
}
.body :global(a) {
color: var(--accent);
text-decoration: underline;
text-decoration-color: var(--accent-line);
text-underline-offset: 3px;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<!--
The bottom-centre note. Ink fill, paper text, 2.6 s (design spec §appendix).
`aria-live="polite"` rather than `alert`: the screen has already refreshed by
the time this appears, so it is a confirmation, not something to interrupt for.
-->
<script lang="ts">
import { toast } from '../lib/toast.svelte';
</script>
<div class="live-region" aria-live="polite">
{#if toast.message}
<div class="toast">{toast.message}</div>
{/if}
</div>
<style>
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: var(--ink);
color: var(--paper);
padding: 8px 14px;
font-size: 12.5px;
line-height: 1.4;
max-width: 70ch;
z-index: 50;
animation: rise 140ms ease-out;
}
@keyframes rise {
from {
opacity: 0;
transform: translate(-50%, 6px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
</style>
+40 -2
View File
@@ -5,6 +5,7 @@
import SearchPalette from './SearchPalette.svelte';
import type { PaletteItem } from '../lib/search-model';
import { walkTo } from '../lib/walk';
import { live } from '../lib/live.svelte';
interface Props {
/** Indexed project name, e.g. "codegraph/". Null until stats load. */
@@ -111,6 +112,31 @@
}
let searchBox: HTMLDivElement | null = $state(null);
/**
* Why this page has stopped updating itself, when it has.
*
* The whole point of the live channel is that the screen keeps up with the
* project; a screen that has silently stopped keeping up is worse than one
* that never claimed to. So both ways it can end say so, in the one place
* that is on every view.
*/
let liveNote = $derived.by(() => {
if (live.degraded !== null) {
return {
text: 'Live updates off',
title: `${live.degraded} This page no longer refreshes itself — reload it after a sync.`,
};
}
if (live.stopped) {
return {
text: 'Not live',
title:
'Lost the connection to codegraph ui and stopped retrying. Focus this tab to try again, or reload the page.',
};
}
return null;
});
</script>
<svelte:window {onpointerdown} />
@@ -152,6 +178,7 @@
</div>
<div class="project" title="Indexed project">
{#if liveNote}<span class="offline" title={liveNote.title}>{liveNote.text}</span>{/if}
{#if project}<span class="mono">{project}</span>{/if}
{#if stats}<span class="dim">{stats}</span>{/if}
</div>
@@ -246,9 +273,20 @@
white-space: nowrap;
}
/* Below ~1000px the stats are the first thing worth losing. */
.offline {
padding: 2px 6px;
margin-right: 8px;
border: 1px solid var(--rule-soft);
background: var(--paper-2);
color: var(--ink-3);
font-size: 11.5px;
}
/* Below ~1000px the stats are the first thing worth losing — but not the
note that the page has stopped updating itself. */
@media (max-width: 1000px) {
.project {
.project .mono,
.project .dim {
display: none;
}
}
@@ -94,11 +94,6 @@
hub · {plural(payload.counts.callers, 'caller')}
</span>
{/if}
{#if payload.drift}
<span class="badge warn" title="The line ranges below come from the last index sync">
<span class="sw"></span>changed on disk after the last index sync
</span>
{/if}
<span class="badge" class:warn={testBadge.warn} title={testBadge.title}>
<span class="sw"></span>{testBadge.text}
</span>
+26 -3
View File
@@ -145,13 +145,20 @@ export interface WireSource {
file: string;
language: string;
drift: boolean;
/**
* Which numbering `lines` belong to. `'indexed'` — the file matches the
* index. `'current'` — it drifted and we asked for the bytes anyway
* (`ondrift: 'current'`), so nothing the graph holds about this file lines up
* with them. `'none'` — it drifted and no slice came back.
*/
showing: 'indexed' | 'current' | 'none';
contentHash: string;
indexedAt: number;
generated: boolean;
totalLines: number | null;
from?: number;
to?: number;
/** Absent when `drift` — a mis-sliced body is worse than no body. */
/** Absent when the file drifted and `ondrift` was left at its default. */
lines?: string[];
truncated?: boolean;
reason?: string;
@@ -590,13 +597,29 @@ export function fetchFileCode(
return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
}
/**
* A slice of an indexed file.
*
* `ondrift` decides what happens when the file has changed since it was
* indexed. The default omits the slice — an indexed range over rewritten bytes
* can show a different symbol's code under the right name. `'current'` asks for
* the file's current lines instead, which is only correct for a caller that is
* also going to SAY so: the response comes back `showing: 'current'`, and every
* line-anchored thing the graph knows (ports, arcs, call sites, rail rows) has
* to be switched off over it.
*/
export function fetchSource(
file: string,
from: number,
to: number,
signal?: AbortSignal
signal?: AbortSignal,
ondrift?: 'current'
): Promise<WireSource> {
const params = new URLSearchParams({ file, from: String(from), to: String(to) });
const params = new URLSearchParams({ file, from: String(from) });
// `to` is 1-based on the wire and absent means "to the end of the file" —
// sending 0 for that would be out of range, not a synonym.
if (to > 0) params.set('to', String(to));
if (ondrift) params.set('ondrift', ondrift);
return getJson<WireSource>(`api/source?${params}`, signal);
}
+325
View File
@@ -0,0 +1,325 @@
/**
* The live channel — the viewer's end of `/api/events` (CG-53).
*
* The server watches two things and says so; this module turns that into two
* counters every screen can read:
*
* `live.indexTick` — the graph moved. Every screen is one round-trip stale.
* `live.diskTick` — source files changed on disk. Only the screens showing
* one of them care, and what they care about is drift.
*
* A counter rather than a callback list because Svelte's effects already do the
* subscribing: a view that reads `live.indexTick` inside an `$effect` re-runs
* when it moves, and one that does not read it is not subscribed. `liveRefresh`
* below wraps the three lines of bookkeeping that turns "the counter moved"
* into "call this once".
*
* ## Nothing polls, and nothing loops
*
* `EventSource` is the transport, but its own reconnect is not: left alone it
* retries forever at a fixed interval, so a viewer left open against a stopped
* `codegraph ui` becomes a request every three seconds until the tab is closed.
* So each `error` closes the stream and schedules ONE reconnect on a backoff
* that ends: after {@link MAX_ATTEMPTS} consecutive failures the connection
* gives up and says so, and only a deliberate signal — the tab coming back to
* the foreground, or the window regaining focus — starts it again.
*
* The same rule covers the server's own bad day: a `degraded` event means live
* watching has stopped for good on that side. The client records it and shows
* it. It must never respond by asking again on a timer — a degraded watcher is
* exactly the case where a poll would run forever.
*/
import { untrack } from 'svelte';
/* ----------------------------------------------------------- wire shapes -- */
export interface LiveIndexRevision {
lastIndexedAt: number | null;
files: number;
}
export interface LiveHello {
type: 'hello';
index: LiveIndexRevision | null;
watching: { source: boolean; index: boolean };
degraded: string | null;
heartbeatMs: number;
at: number;
}
export interface LiveChanged {
type: 'changed';
files: string[];
total: number;
truncated: boolean;
/** The change could not be described file by file — assume any file is affected. */
scan: boolean;
at: number;
}
export interface LiveIndexEvent {
type: 'index';
index: LiveIndexRevision;
files: string[];
total: number;
truncated: boolean;
at: number;
}
/* --------------------------------------------------------------- backoff -- */
/** Reconnect delays, in order. The last one repeats until the attempts run out. */
export const BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000];
/** Consecutive failures before the connection stops trying on its own. */
export const MAX_ATTEMPTS = 8;
/* ----------------------------------------------------------------- state -- */
let connected = $state(false);
/** Gave up reconnecting. Only a foreground/focus signal restarts it. */
let stopped = $state(false);
let degraded = $state<string | null>(null);
let watching = $state<{ source: boolean; index: boolean } | null>(null);
let indexTick = $state(0);
let diskTick = $state(0);
let lastIndex = $state<LiveIndexEvent | null>(null);
let lastChanged = $state<LiveChanged | null>(null);
let source: EventSource | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
let started = false;
/**
* Ticks that arrived while the tab was in the background.
*
* A hidden tab still gets every event — the stream does not care — but making
* it refetch is work nobody is looking at. The counters move when it comes
* back, and because they are counters, ten syncs in the background still cost
* exactly one refresh.
*/
let deferredIndex = false;
let deferredDisk = false;
function hidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
function bumpIndex(event: LiveIndexEvent): void {
lastIndex = event;
if (hidden()) {
deferredIndex = true;
return;
}
indexTick += 1;
}
function bumpDisk(event: LiveChanged): void {
lastChanged = event;
if (hidden()) {
deferredDisk = true;
return;
}
diskTick += 1;
}
function flushDeferred(): void {
if (deferredIndex) {
deferredIndex = false;
indexTick += 1;
}
if (deferredDisk) {
deferredDisk = false;
diskTick += 1;
}
}
/* ------------------------------------------------------------ connection -- */
function open(): void {
if (source || typeof EventSource === 'undefined') return;
if (retry !== null) {
clearTimeout(retry);
retry = null;
}
stopped = false;
const es = new EventSource('api/events');
source = es;
es.addEventListener('open', () => {
connected = true;
});
es.addEventListener('hello', (event) => {
const hello = parse<LiveHello>(event);
if (!hello) return;
// A hello is the only proof the stream is really working: `open` fires on
// the response headers, and a server that answered and then died would
// otherwise reset the backoff it should have been paying.
attempts = 0;
connected = true;
watching = hello.watching;
degraded = hello.degraded;
});
es.addEventListener('changed', (event) => {
const changed = parse<LiveChanged>(event);
if (changed) bumpDisk(changed);
});
es.addEventListener('index', (event) => {
const moved = parse<LiveIndexEvent>(event);
if (moved) bumpIndex(moved);
});
es.addEventListener('degraded', (event) => {
const note = parse<{ reason: string }>(event);
if (note) degraded = note.reason;
});
es.addEventListener('error', () => {
connected = false;
es.close();
if (source === es) source = null;
attempts += 1;
if (attempts >= MAX_ATTEMPTS) {
// Out of attempts. Nothing on a timer from here — the tab coming back to
// the foreground is the only thing that tries again.
stopped = true;
return;
}
const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
retry = setTimeout(open, delay);
});
}
function parse<T>(event: Event): T | null {
const data = (event as MessageEvent<string>).data;
if (typeof data !== 'string') return null;
try {
return JSON.parse(data) as T;
} catch {
return null;
}
}
/** Connect, once, for the life of the page. */
function start(): void {
if (started || typeof window === 'undefined') return;
started = true;
document.addEventListener('visibilitychange', () => {
if (hidden()) return;
flushDeferred();
// Back in the foreground is the deliberate signal a stopped connection
// waits for. A tab that has been asleep for an hour reconnects when it is
// looked at, and not before.
if (stopped) {
attempts = 0;
open();
}
});
window.addEventListener('focus', () => {
if (!stopped) return;
attempts = 0;
open();
});
window.addEventListener('pagehide', () => {
source?.close();
source = null;
});
open();
}
/* ----------------------------------------------------------------- store -- */
export const live = {
get connected(): boolean {
return connected;
},
/** True once the client has stopped trying to reconnect on its own. */
get stopped(): boolean {
return stopped;
},
/** Why the SERVER stopped watching, when it has. Never a reason to poll. */
get degraded(): string | null {
return degraded;
},
get watching(): { source: boolean; index: boolean } | null {
return watching;
},
get indexTick(): number {
return indexTick;
},
get diskTick(): number {
return diskTick;
},
get lastIndex(): LiveIndexEvent | null {
return lastIndex;
},
get lastChanged(): LiveChanged | null {
return lastChanged;
},
start,
};
/**
* Whether the latest on-disk change is one a screen showing `file` should react
* to.
*
* `scan: true` means the watcher could not name the files (a directory removal,
* or a burst past its ceiling), so the honest answer is yes.
*/
export function touchesFile(file: string | null): boolean {
const changed = lastChanged;
if (!changed) return false;
if (changed.scan || changed.truncated) return true;
if (file === null) return false;
return changed.files.includes(file);
}
/**
* Call `refresh` when what a screen is showing has gone stale.
*
* Two different staleness signals, deliberately not merged:
*
* - **the index moved** — every screen refetches. Not "the file I am showing
* changed": a rail is the answer to a question about the whole graph, and a
* symbol gains a caller when some *other* file is edited. Filtering by the
* focused file here would leave the rails quietly wrong, which is the failure
* this whole task exists to remove. One request per sync is the cost, and a
* sync is not a thing that happens in a loop.
* - **the file changed on disk** — only the screen showing that file, and only
* so its drift banner appears without waiting for the sync.
*
* Must be called during component initialisation (it creates an `$effect`).
*/
export function liveRefresh(
file: () => string | null,
refresh: (reason: 'index' | 'disk') => void
): void {
let seenIndex = indexTick;
let seenDisk = diskTick;
$effect(() => {
const index = live.indexTick;
const disk = live.diskTick;
const path = file();
untrack(() => {
if (index !== seenIndex) {
seenIndex = index;
// An index event supersedes any disk event before it: the sync that
// just landed is what those edits became.
seenDisk = disk;
refresh('index');
return;
}
if (disk !== seenDisk) {
seenDisk = disk;
if (touchesFile(path)) refresh('disk');
}
});
});
}
+9
View File
@@ -46,4 +46,13 @@ export const project = {
return `${n(stats.graph.nodes)} symbols · ${n(stats.graph.edges)} edges · ${n(stats.graph.files)} files indexed`;
},
ensure: load,
/**
* Re-read `/api/stats` because the index moved (the live channel's `index`
* event). Distinct from `ensure`, which memoises the first request forever —
* memoising this one would mean the top bar's counts never move again.
*/
reload(): Promise<void> {
inflight = null;
return load();
},
};
+36
View File
@@ -0,0 +1,36 @@
/**
* The one transient message the viewer has: "Index updated · reloaded".
*
* A note, not a dialog — nothing was asked of the reader and nothing is waiting
* on them. It replaces itself rather than stacking, because the only thing it
* ever reports is the most recent state of one fact.
*/
/** How long a note stays up (design spec). */
export const TOAST_MS = 2_600;
let message = $state<string | null>(null);
let timer: ReturnType<typeof setTimeout> | null = null;
function show(text: string): void {
if (timer !== null) clearTimeout(timer);
message = text;
timer = setTimeout(() => {
message = null;
timer = null;
}, TOAST_MS);
}
function clear(): void {
if (timer !== null) clearTimeout(timer);
timer = null;
message = null;
}
export const toast = {
get message(): string | null {
return message;
},
show,
clear,
};
+21
View File
@@ -73,6 +73,27 @@ export const trail = {
];
},
/**
* The same symbol, under a new id.
*
* A node's id contains its start LINE (`generateNodeId`), so any edit above a
* symbol gives it a different id at the next sync — while it is the same
* symbol, in the same place in the reader's path. Swapping it in place keeps
* the trail a path; pushing the new id would draw a hop that describes no
* call, and dropping the trail would lose the walk that got here.
*/
rename(oldId: string, next: { id: string; name?: string | null; kind?: string | null }): void {
const at = hops.findIndex((h) => h.id === oldId);
if (at < 0) return;
remember(next.id, next);
const hop = hops[at] as TrailHop;
hops = [
...hops.slice(0, at),
{ ...hop, id: next.id, name: next.name ?? hop.name, kind: next.kind ?? hop.kind },
...hops.slice(at + 1),
];
},
/** Drop every hop after `index`, making it the current one. */
truncateTo(index: number): void {
if (index < 0 || index >= hops.length) return;
+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 {