feat(ui): saved trails — a walk you named, kept, and still true after a re-index (CG-60)

Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.

A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.

This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-27 08:31:48 -05:00
co-authored by Claude Opus 5
parent 55a33055ee
commit 47576b392e
32 changed files with 3162 additions and 78 deletions
+81 -10
View File
@@ -3,8 +3,8 @@
* through one {@link GraphAdapter} (task CG-61).
*
* The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
* the read-only JSON API over loopback. A host that already holds the graph —
* CodeGraph Pro, which opens the index in-process — implements the same twelve
* the JSON API over loopback. A host that already holds the graph — CodeGraph
* Pro, which opens the index in-process — implements the same thirteen required
* methods against its own reads and never makes an HTTP request. The components
* cannot tell the difference, which is the whole point: one implementation of
* the Symbol view, the Flow strip and the Map, drawn from whichever side of the
@@ -41,6 +41,7 @@ import type {
WireSource,
WireStats,
WireSymbolPayload,
WireTrails,
} from './wire';
/* ---------------------------------------------------------------- errors -- */
@@ -144,6 +145,20 @@ export interface DeadCodeRequest {
includeGenerated?: boolean;
}
/**
* A trail to save: a name, an optional note, and the walk as ids.
*
* Ids and directions only. Everything else a saved hop records — the name, the
* kind, the file, the line — is read out of the graph by the answering side, so
* a saved trail is always a claim the index itself made and can therefore
* re-check when it next changes.
*/
export interface SaveTrailRequest {
name: string;
note?: string;
hops: ReadonlyArray<{ dir: 'start' | 'down' | 'up'; id: string }>;
}
/* ----------------------------------------------------------------- live -- */
/**
@@ -171,8 +186,11 @@ export interface LiveHandlers {
* `source`, `file`, `flow`, `map`, `routes` — and the rest are what the screens
* around them need: `stats` (the blast bar's denominator and the top bar's
* counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode` (the
* whole-file view), `entryPoints` (where a reader starts) and `deadCode` (where
* nobody goes).
* whole-file view), `entryPoints` (where a reader starts), `deadCode` (where
* nobody goes) and `trails` (the walks the reader kept).
*
* Everything here answers a question except `saveTrail`/`deleteTrail`, which
* are optional for exactly that reason.
*/
export interface GraphAdapter {
/** The index's own facts: counts, thresholds, the blast scale. */
@@ -198,6 +216,26 @@ export interface GraphAdapter {
entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
/** Symbols nothing reaches, grouped by file, with every exclusion counted. */
deadCode(request?: DeadCodeRequest, signal?: AbortSignal): Promise<WireDeadCode>;
/**
* The reader's saved trails, each hop re-resolved against the current graph.
*
* A host with nowhere to keep them answers `{ trails: [], readOnly: true, … }`
* rather than omitting the method: the screens then show the section as
* empty-and-explained instead of showing a Save button that does nothing.
*/
trails(signal?: AbortSignal): Promise<WireTrails>;
/**
* Save a trail, answering the full list as it now stands.
*
* OPTIONAL, and the only mutating pair in this interface. An adapter that
* refuses to write simply omits {@link saveTrail} and {@link deleteTrail} —
* a host must be able to render the reader without inheriting a filesystem
* write it never asked for, and the viewer hides Save when they are absent
* exactly as it does when the server answers `readOnly`.
*/
saveTrail?(request: SaveTrailRequest, signal?: AbortSignal): Promise<WireTrails>;
/** Remove a saved trail by id, answering the list as it now stands. */
deleteTrail?(id: string, signal?: AbortSignal): Promise<WireTrails>;
/**
* Subscribe to index/disk changes. Optional — a host without a live channel
* omits it and nothing polls. Returns a function that closes the stream.
@@ -229,7 +267,18 @@ function query(params: URLSearchParams): string {
}
/**
* The default adapter: the read-only JSON API `codegraph ui` serves.
* The header every write carries.
*
* The server refuses a `POST`/`DELETE` without it. It is not a secret and is
* not trying to be: a custom request header cannot be sent cross-origin without
* a CORS preflight, and the viewer's server answers none — so its presence is
* proof the request came from a page the server itself served. Must match
* `WRITE_HEADER` in `src/ui-server/security.ts`.
*/
export const WRITE_HEADER = 'X-CodeGraph-UI';
/**
* The default adapter: the JSON API `codegraph ui` serves.
*
* Every failure it can describe comes back as an {@link ApiFailure} carrying
* the server's own sentence. The one it cannot describe — the server was
@@ -241,13 +290,10 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
const doFetch = options.fetch ?? ((...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args));
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
async function call<T>(path: string, init: RequestInit, signal?: AbortSignal): Promise<T> {
let response: Response;
try {
response = await doFetch(`${base}${path}`, {
signal,
headers: { accept: 'application/json' },
});
response = await doFetch(`${base}${path}`, { ...init, signal });
} catch (cause) {
if (signal?.aborted) throw cause;
throw new ApiFailure(
@@ -271,6 +317,24 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return body as T;
}
function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
return call<T>(path, { headers: { accept: 'application/json' } }, signal);
}
/** A write: the marker header, and a JSON body when there is one to send. */
function write<T>(path: string, method: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const headers: Record<string, string> = {
accept: 'application/json',
[WRITE_HEADER]: '1',
};
if (body !== undefined) headers['content-type'] = 'application/json';
return call<T>(
path,
{ method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) },
signal
);
}
return {
stats: (signal) => getJson<WireStats>('api/stats', signal),
@@ -346,6 +410,13 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return getJson<WireDeadCode>(`api/deadcode${query(params)}`, signal);
},
trails: (signal) => getJson<WireTrails>('api/trails', signal),
saveTrail: (request, signal) => write<WireTrails>('api/trails', 'POST', request, signal),
deleteTrail: (id, signal) =>
write<WireTrails>(`api/trails/${encodeURIComponent(id)}`, 'DELETE', undefined, signal),
events(handlers) {
if (typeof EventSource === 'undefined') return () => {};
const stream = new EventSource(`${base}api/events`);
+50 -1
View File
@@ -11,7 +11,7 @@
* shape it answers with.
*/
import { getGraphAdapter } from './adapter';
import { ApiFailure, getGraphAdapter } from './adapter';
import type {
WireDeadCode,
WireEntryPoints,
@@ -25,7 +25,9 @@ import type {
WireSource,
WireStats,
WireSymbolPayload,
WireTrails,
} from './wire';
import type { SaveTrailRequest } from './adapter';
export * from './wire';
export { ApiFailure } from './adapter';
@@ -38,6 +40,7 @@ export type {
LiveHandlers,
MapRequest,
RoutesRequest,
SaveTrailRequest,
SearchRequest,
SourceRequest,
} from './adapter';
@@ -158,3 +161,49 @@ export function fetchFlow(
): Promise<WireFlowPayload> {
return getGraphAdapter().flow(spec, signal);
}
/* ---------------------------------------------------------- saved trails -- */
/**
* The reader's saved trails, every hop re-resolved against the current index.
*
* A trail is stored by qualified name rather than by node id, so this is where
* the graph gets to say what became of each hop since it was written: still
* there, moved, now ambiguous, or gone.
*/
export function fetchTrails(signal?: AbortSignal): Promise<WireTrails> {
return getGraphAdapter().trails(signal);
}
/**
* Whether trails can be written at all through the installed adapter.
*
* Separate from the `readOnly` flag on the payload: that one is the *answering
* side* declining, this one is an adapter that never offered. Both hide Save,
* and the screens say which it was.
*/
export function canWriteTrails(): boolean {
const adapter = getGraphAdapter();
return typeof adapter.saveTrail === 'function' && typeof adapter.deleteTrail === 'function';
}
/** Save a trail, answering the whole list as it now stands. */
export function saveTrail(
request: SaveTrailRequest,
signal?: AbortSignal
): Promise<WireTrails> {
const adapter = getGraphAdapter();
if (!adapter.saveTrail) {
return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot save trails.', null));
}
return adapter.saveTrail(request, signal);
}
/** Remove a saved trail, answering the whole list as it now stands. */
export function deleteTrail(id: string, signal?: AbortSignal): Promise<WireTrails> {
const adapter = getGraphAdapter();
if (!adapter.deleteTrail) {
return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot delete trails.', null));
}
return adapter.deleteTrail(id, signal);
}
+202
View File
@@ -0,0 +1,202 @@
/**
* What a saved trail says about itself, without a browser.
*
* `/api/trails` hands back each trail with every hop already re-resolved
* against the current index — so all this module does is turn that into the
* sentences the rows print. It is pure for the usual reason (it can be tested,
* and a host building its own trail list gets the shipped arithmetic rather
* than its own), and because the interesting decisions here are *wording*
* decisions, which is exactly the kind of thing that drifts when it is spread
* across two components.
*
* The one rule it keeps: **a trail that has decayed never reads as intact.**
* A saved trail is somebody's explanation of a codebase, and the codebase moves
* underneath it. Showing "6 hops" for a trail where two hops no longer resolve
* would make it a lie by omission at exactly the moment it needs to be fixed.
*
* Tested in `__tests__/ui-trails-model.test.ts`.
*/
import type { WireTrail, WireTrailHop, WireTrailHopStatus } from './wire';
import { plural } from './symbol-model';
/** Hops named in the decay line before it stops naming them. */
export const MAX_NAMED_DECAYED = 3;
/**
* The row's second line: how long the walk is, and who wrote it.
*
* The hop count is the SAVED length, always — the trail is six hops whatever
* became of them. What became of them is {@link trailDecay}'s job, on its own
* line, so the two facts cannot be read as one.
*/
export function trailMeta(trail: WireTrail): string {
const hops = plural(trail.hops.length, 'hop');
return trail.author ? `${hops} · ${trail.author}` : hops;
}
/** The verdict a decayed hop carries, in the words a row uses. */
export function hopStatusWord(status: WireTrailHopStatus): string {
switch (status) {
case 'ok':
return 'still here';
case 'moved':
return 'moved';
case 'ambiguous':
return 'ambiguous';
case 'missing':
return 'gone';
}
}
export interface TrailDecay {
/** `warn` when something is unopenable, `note` when it merely moved. */
tone: 'warn' | 'note';
text: string;
}
/**
* What has happened to this trail since it was saved, or null when nothing has.
*
* Two tones, because they call for different things from the reader: a hop that
* MOVED still opens and only wants acknowledging, while a hop that is gone (or
* that now names several symbols) means the trail no longer says what its author
* meant it to say.
*/
export function trailDecay(trail: WireTrail): TrailDecay | null {
const missing = trail.hops.filter((hop) => hop.status === 'missing');
const ambiguous = trail.hops.filter((hop) => hop.status === 'ambiguous');
const moved = trail.hops.filter((hop) => hop.status === 'moved');
if (missing.length > 0) {
return {
tone: 'warn',
text:
`${plural(missing.length, 'hop')} moved or renamed since this was saved — ` +
`${nameList(missing)} no longer in the index.`,
};
}
if (ambiguous.length > 0) {
return {
tone: 'warn',
text: `${nameList(ambiguous)} now ${ambiguous.length === 1 ? 'names' : 'name'} more than one symbol — showing the closest match.`,
};
}
if (moved.length > 0) {
return {
tone: 'note',
text: `${nameList(moved)} moved to another file since this was saved.`,
};
}
return null;
}
/**
* How much of the trail can actually be opened, or null when all of it can.
*
* The payload carries the longest run of CONSECUTIVE resolved hops rather than
* every resolved hop, because the trail is a path: skipping a broken hop would
* encode a step from one symbol to another that nothing joins. When that run is
* shorter than the trail, the row has to say so before somebody opens it and
* wonders where the first two hops went.
*/
export function trailOpens(trail: WireTrail): string | null {
if (trail.encoded === null) return 'None of this trail resolves in the current index.';
if (trail.openCount === trail.hops.length) return null;
const last = trail.openFrom + trail.openCount - 1;
const range = trail.openCount === 1 ? `hop ${trail.openFrom}` : `hops ${trail.openFrom}–${last}`;
return `Opens ${range} of ${trail.hops.length}.`;
}
/** Can this row be opened at all? */
export function isOpenable(trail: WireTrail): boolean {
return trail.encoded !== null && trail.openId !== null;
}
/** Hover text: the whole walk, in order, with its arrows. */
export function trailTitle(trail: WireTrail): string {
const path = trail.hops
.map((hop, index) => (index === 0 ? hop.name : `${arrow(hop)} ${hop.name}`))
.join(' ');
const when = trail.updatedAt ? ` — saved ${trail.updatedAt.slice(0, 10)}` : '';
return `${path}${when}`;
}
function arrow(hop: WireTrailHop): string {
return hop.dir === 'up' ? '←' : hop.dir === 'down' ? '→' : '·';
}
function nameList(hops: readonly WireTrailHop[]): string {
const names = hops.slice(0, MAX_NAMED_DECAYED).map((hop) => hop.name);
const rest = hops.length - names.length;
const listed =
names.length === 1
? (names[0] as string)
: `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
return rest > 0 ? `${listed} and ${rest} more` : listed;
}
/* ------------------------------------------------------------- saving -- */
/**
* Why this name cannot be saved, or null when it can.
*
* Only the two things the server would refuse anyway; everything else about a
* name is the reader's business. Checked here as well so the form can disable
* its own button rather than teaching by round-trip.
*/
export function trailNameProblem(name: string, maxLength: number): string | null {
const trimmed = name.trim();
if (trimmed === '') return 'Give the trail a name.';
if (trimmed.length > maxLength) return `That name is too long (max ${maxLength} characters).`;
return null;
}
/**
* The trail this name would replace, or null when it would be a new one.
*
* Saving under an existing name overwrites it — that is what a reader means by
* pressing Save twice — but they should be told before, not after.
*/
export function replacedTrail(name: string, trails: readonly WireTrail[]): WireTrail | null {
const trimmed = name.trim().replace(/\s+/g, ' ');
return trails.find((trail) => trail.name === trimmed) ?? null;
}
/**
* A saved trail as the file it is, ready to be written somewhere a repository
* will keep it.
*
* The trails directory is inside `.codegraph/`, which is gitignored wholesale —
* that is the right default for a scratch walk and the wrong one for a tour
* worth committing. Exporting is therefore a copy the reader makes on purpose,
* and this is the same shape the viewer writes: each hop's saved IDENTITY —
* qualified name, kind, the file it was in — so dropping the file into another
* checkout re-runs the same resolution rather than baking today's answer in.
* Only the id hint is refreshed to whatever the symbol's id is now, since that
* is all an id has ever been here.
*/
export function trailExport(trail: WireTrail): string {
return `${JSON.stringify(
{
version: 1,
id: trail.id,
name: trail.name,
note: trail.note,
author: trail.author,
createdAt: trail.createdAt,
updatedAt: trail.updatedAt,
hops: trail.hops.map((hop) => ({
dir: hop.dir,
name: hop.name,
qualifiedName: hop.qualifiedName,
kind: hop.kind,
file: hop.savedFile,
line: hop.savedLine,
id: hop.id ?? '',
})),
},
null,
2
)}\n`;
}
+164
View File
@@ -0,0 +1,164 @@
/**
* The saved trails, as live state.
*
* Everything that decides what a row *says* is in `trails-model.ts`; this owns
* the parts that need time — one fetch shared by every screen that lists them,
* and the two writes.
*
* Two things it does deliberately:
*
* - **A write answers with the whole list, and the whole list is adopted.**
* Saving does not patch one row in place. The server re-resolves every hop of
* every trail on the way out, so a save is also the cheapest moment to learn
* that a trail saved last week has decayed — and patching locally would show
* a screen that had quietly stopped agreeing with the files on disk.
* - **Failures are kept, not thrown away.** The one place in the viewer that
* can fail because of the *filesystem* (a read-only checkout, a full disk) is
* here, and "nothing happened" is the worst possible answer to a reader who
* just pressed Save.
*/
import { canWriteTrails, deleteTrail, fetchTrails, saveTrail, type WireTrail, type WireTrails } from './api';
import type { TrailHop } from './trail-codec';
let payload = $state<WireTrails | null>(null);
/** Null until the first attempt settles — the section says "reading" until then. */
let settled = $state(false);
let failure = $state<string | null>(null);
let busy = $state(false);
let inflight: Promise<void> | null = null;
function load(): Promise<void> {
if (inflight) return inflight;
inflight = fetchTrails()
.then((value) => {
payload = value;
failure = null;
})
.catch((cause: unknown) => {
// A viewer whose trails cannot be listed still works; the section is the
// only thing that has to know, and it prints the reason rather than an
// empty box that looks like "you have never saved one".
payload = null;
failure = cause instanceof Error ? cause.message : String(cause);
})
.finally(() => {
settled = true;
});
return inflight;
}
function adopt(next: WireTrails): void {
payload = next;
failure = null;
settled = true;
// The in-flight promise is the *load*; replacing the payload out from under
// it is fine, but a later `ensure()` must not resolve to the stale one.
inflight = Promise.resolve();
}
export const trails = {
get list(): readonly WireTrail[] {
return payload?.trails ?? [];
},
get payload(): WireTrails | null {
return payload;
},
/** False until the first fetch settles, however it settled. */
get settled(): boolean {
return settled;
},
get failure(): string | null {
return failure;
},
/** A save or a delete is in flight — the form disables itself. */
get busy(): boolean {
return busy;
},
/**
* Whether the viewer offers to save at all.
*
* Two independent reasons it might not, and the screens distinguish them:
* the adapter never offered a write ({@link canWriteTrails}), or the
* answering side declined this one (`readOnly` on the payload). Until the
* first fetch settles we assume it can, so the Save button does not flicker
* into existence a moment after the trail bar draws.
*/
get canSave(): boolean {
if (!canWriteTrails()) return false;
return payload === null || !payload.readOnly;
},
/**
* Why saving is off, when it is.
*
* The answering side's own sentence wins when there is one — it is the more
* specific truth, and it is the one that names the flag or the mount that
* caused it. The generic line is only for an adapter that never offered a
* write at all, which has nothing to say for itself.
*/
get readOnlyReason(): string | null {
if (payload?.readOnly) return payload.readOnlyReason ?? 'This viewer is running read-only.';
if (!canWriteTrails()) return 'This viewer cannot save trails.';
return null;
},
/** Where the files live, project-relative. Null until known. */
get directory(): string | null {
return payload?.directory ?? null;
},
/** Load once. Every screen that lists trails calls this. */
ensure: load,
/** Ask again, because the index moved or a file changed underneath us. */
reload(): Promise<void> {
inflight = null;
return load();
},
/**
* Save the walk under a name.
*
* Hops travel as ids and directions only — the answering side reads each
* symbol's name, kind and file out of the graph, so a saved trail is always
* something the index itself said.
*
* @returns the id written, or null when the save failed (see `failure`).
*/
async save(name: string, note: string, hops: readonly TrailHop[]): Promise<string | null> {
busy = true;
try {
const answer = await saveTrail({
name,
note,
hops: hops.map((hop) => ({ dir: hop.dir, id: hop.id })),
});
adopt(answer);
return answer.saved ?? null;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
return null;
} finally {
busy = false;
}
},
/** Remove a saved trail. Returns whether it went. */
async remove(id: string): Promise<boolean> {
busy = true;
try {
adopt(await deleteTrail(id));
return true;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
return false;
} finally {
busy = false;
}
},
/** Drop the last failure, so a retry starts from a clean screen. */
clearFailure(): void {
failure = null;
},
};
+72
View File
@@ -686,3 +686,75 @@ export interface WireDeadCode {
corroborated: boolean;
timing: { elapsedMs: number };
}
/* ---------------------------------------------------------- saved trails -- */
/**
* How a saved hop fared against the index as it is NOW.
*
* A trail is stored by qualified name rather than by node id (a node id
* contains its start line, so any edit above a symbol renames it), and every
* hop is re-resolved on the way out. This is what that re-resolution found.
*/
export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
export interface WireTrailHop {
dir: 'start' | 'down' | 'up';
/** The name as it was when the trail was saved. */
name: string;
qualifiedName: string;
kind: string;
savedFile: string;
savedLine: number;
status: WireTrailHopStatus;
/** The symbol's id NOW. Null when nothing answers to it any more. */
id: string | null;
file: string | null;
line: number | null;
/** Finished screen wording for a status that is not `ok`; null when it is. */
note: string | null;
}
export interface WireTrail {
id: string;
name: string;
note: string;
author: string;
createdAt: string;
updatedAt: string;
hops: WireTrailHop[];
/** Hops that still resolve to a symbol in this index. */
resolved: number;
/** Every hop resolved, and none of them moved. */
intact: boolean;
/**
* The longest run of CONSECUTIVE resolved hops, as the `t` param. Null when
* nothing in the trail resolves. Never stitched across a hole — the trail is
* a path, and a fabricated adjacency is worse than a short one.
*/
encoded: string | null;
/** 1-based index of the first hop `encoded` carries. */
openFrom: number;
/** How many hops `encoded` carries. */
openCount: number;
/** The symbol the trail opens at — the last hop of that run. */
openId: string | null;
}
export interface WireTrails {
trails: WireTrail[];
/** Writes are off. Save and Delete are hidden, and the screen says why. */
readOnly: boolean;
readOnlyReason: string | null;
/** Project-relative directory the files live in. */
directory: string;
/** Files in that directory that were not readable trails. */
skipped: number;
bounded: boolean;
/** The id just written, on the answer to a save. */
saved?: string;
/** That save replaced a trail of the same name. */
replaced?: boolean;
/** The id just removed, on the answer to a delete. */
deleted?: string;
}