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:
@@ -2620,6 +2620,42 @@ export class QueryBuilder {
|
||||
return row?.last ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index's revision marker: how far the last sync got, and how many files
|
||||
* it left behind — one query, both numbers.
|
||||
*
|
||||
* This is the cheapest honest answer to "has the index moved since I last
|
||||
* looked". `MAX(indexed_at)` alone is not enough: a sync that only DELETES
|
||||
* files (a branch checkout that removed a directory) advances nothing, and
|
||||
* the graph the viewer is showing has still changed underneath it. The row
|
||||
* count catches exactly that case.
|
||||
*/
|
||||
getIndexRevision(): { lastIndexedAt: number | null; fileCount: number } {
|
||||
const row = this.db
|
||||
.prepare('SELECT MAX(indexed_at) AS last, COUNT(*) AS files FROM files')
|
||||
.get() as { last: number | null; files: number } | undefined;
|
||||
return { lastIndexedAt: row?.last ?? null, fileCount: row?.files ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Files re-indexed strictly after `since` (ms since epoch), newest first.
|
||||
*
|
||||
* `total` is the real count; `paths` is capped at `limit`. Used by the
|
||||
* viewer's live channel to name what a sync just picked up. A file the same
|
||||
* sync DELETED cannot appear here — it has no row left — which is why the
|
||||
* caller compares {@link getIndexRevision} as well rather than treating an
|
||||
* empty list as "nothing happened".
|
||||
*/
|
||||
getFilesIndexedSince(since: number, limit: number): { paths: string[]; total: number } {
|
||||
const count = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM files WHERE indexed_at > ?')
|
||||
.get(since) as { n: number } | undefined;
|
||||
const rows = this.db
|
||||
.prepare('SELECT path FROM files WHERE indexed_at > ? ORDER BY indexed_at DESC, path LIMIT ?')
|
||||
.all(since, Math.max(0, limit)) as Array<{ path: string }>;
|
||||
return { paths: rows.map((r) => r.path), total: count?.n ?? rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files that need re-indexing (hash changed)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user