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:
@@ -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