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:
@@ -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 —
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user