Files
codegraph/ui/src/lib/project.svelte.ts
T
Colby McHenry ecd6e1cd15 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.
2026-08-27 04:28:56 -05:00

59 lines
1.9 KiB
TypeScript

/**
* The project's own facts — loaded once, read everywhere.
*
* `/api/stats` describes the index rather than any one symbol, so every screen
* that needs a piece of it (the top bar's counts, the Symbol view's blast
* scale) would otherwise re-fetch the same payload. The promise is memoised,
* not the value, so callers made before it lands still get the same request.
*/
import { fetchStats, type WireStats } from './api';
let stats = $state<WireStats | null>(null);
let error = $state<string | null>(null);
let inflight: Promise<void> | null = null;
function load(): Promise<void> {
if (inflight) return inflight;
inflight = fetchStats()
.then((value) => {
stats = value;
error = null;
})
.catch((cause: unknown) => {
// A failure here costs a couple of numbers in the top bar and the blast
// bar's denominator — never the screen. It is recorded, not thrown.
error = cause instanceof Error ? cause.message : String(cause);
});
return inflight;
}
export const project = {
get stats(): WireStats | null {
return stats;
},
get error(): string | null {
return error;
},
/** "codegraph" — the indexed project's directory name. */
get name(): string | null {
return stats?.project.name ?? null;
},
/** "13,495 symbols · 47,433 edges · 632 files indexed". */
get summary(): string | null {
if (!stats) return null;
const n = (value: number): string => value.toLocaleString();
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();
},
};