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
+34 -12
View File
@@ -1856,8 +1856,10 @@ function printNoIndexGuidance(projectPath: string): void {
* codegraph ui [path] (alias: web)
*
* The browser reader: serves the built viewer (`dist/viewer/`) over loopback
* and opens it. Read-only in every sense — it answers GET, it opens the index
* for reading, and it never writes to the project or the graph.
* and opens it. It opens the index for reading and never writes to it, never
* indexes, and never changes a line of the project's code. The single thing it
* writes is a trail the reader saved, as JSON under `.codegraph/ui/trails/`;
* `--read-only` turns even that off.
*
* Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
* banner tells the user nothing leaves their machine, so it must not be the
@@ -1870,6 +1872,7 @@ program
.description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
.option('--port <number>', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
.option('--no-open', 'Print the URL instead of opening a browser')
.option('--read-only', 'Refuse every write — saved trails can be opened but not saved or deleted')
.addHelpText(
'after',
`
@@ -1897,10 +1900,17 @@ The page keeps up with the project while it is open: save a file and it says so
within about a third of a second, and whatever is on screen re-reads the graph
when something re-indexes it. It watches for that; it never polls.
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
and it is read-only: it opens an index that already exists and never changes
your project or your graph. Requests from any other host are refused, and
nothing is sent anywhere: no code, no paths, no analytics.
Save a walk you want to keep: name the trail and it is written to
.codegraph/ui/trails/ (already gitignored) as plain JSON, listed on the empty
screen, and reopened at the symbol you left. Hops are remembered by name rather
than by position, so a saved trail survives re-indexing and says which hop moved
when one does. Pass --read-only to refuse every write.
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it.
It opens an index that already exists, never indexes, and never changes a line
of your code — the one thing it writes is a trail you asked it to save.
Requests from any other host are refused, and nothing is sent anywhere: no code,
no paths, no analytics.
Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
@@ -1908,7 +1918,7 @@ Set ${BROWSER_ENV}=<command> to choose which browser opens, or
${BROWSER_ENV}=none to never open one.
`
)
.action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => {
.action(async (pathArg: string | undefined, options: { port?: string; open?: boolean; readOnly?: boolean }) => {
// An explicit --port stays explicit: a scripted `--port 8080` that quietly
// lands on 8081 is worse than one that says the port is busy. The default
// port is the only one we're free to walk away from.
@@ -1942,10 +1952,17 @@ ${BROWSER_ENV}=none to never open one.
'../ui-server'
);
// The read-only JSON API the viewer reads its screens from. It opens the
// index lazily on the first request, so a slow first paint is the only cost
// of mounting it here rather than after the browser connects.
const api = createGraphApi({ projectRoot: projectPath });
// The JSON API the viewer reads its screens from. It opens the index lazily
// on the first request, so a slow first paint is the only cost of mounting
// it here rather than after the browser connects.
const readOnly = options.readOnly === true;
const api = createGraphApi({
projectRoot: projectPath,
readOnly,
readOnlyReason: readOnly
? 'This viewer was started with --read-only, so trails cannot be saved.'
: undefined,
});
let handle: UiServerHandle;
try {
@@ -1968,7 +1985,12 @@ ${BROWSER_ENV}=none to never open one.
console.log('');
console.log(` ${chalk.dim('Reading')} ${projectPath}`);
console.log(` ${chalk.dim('URL')} ${chalk.cyan(handle.url)}`);
console.log(` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`);
console.log(
` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} ` +
(readOnly
? 'read-only, nothing leaves your computer'
: 'nothing leaves your computer; saved trails are the only thing written')
);
console.log('');
const opened = options.open === false ? false : openBrowser(handle.url);