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
+38 -2
View File
@@ -28,6 +28,7 @@ import {
FlowStrip,
SearchPalette,
SymbolView,
SavedTrails,
TrailBar,
TypeHierarchy,
createHttpAdapter,
@@ -406,8 +407,22 @@ function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
corroborated: true,
timing: { elapsedMs: 1 },
}),
// Deliberately no `events`: a host without a live channel is the normal
// case, and nothing may poll in its absence.
trails: () =>
seen('trails', {
trails: [],
// A host with nowhere to keep trails still ANSWERS the question — it
// says it is read-only rather than omitting the method, so the screens
// show the section explained instead of showing a Save that does
// nothing.
readOnly: true,
readOnlyReason: 'This host does not store trails.',
directory: '.codegraph/ui/trails',
skipped: 0,
bounded: false,
}),
// Deliberately no `events`, `saveTrail` or `deleteTrail`: a host without a
// live channel and without anywhere to write is the normal case, and
// nothing may poll or offer to save in their absence.
};
return { adapter, calls };
}
@@ -601,6 +616,27 @@ describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
expect(host.querySelector('input[role="combobox"]')).not.toBeNull();
});
it('offers no Save when the adapter cannot write, and says why in the list', async () => {
const { adapter } = mockAdapter();
setGraphAdapter(adapter);
trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
await render(TrailBar, {});
// The one screen affordance that must never appear against a read-only
// host: an adapter with no `saveTrail` has no button, not a button that
// fails.
expect(host.textContent ?? '').not.toContain('Save trail');
void unmount(mounted as Record<string, unknown>);
mounted = null;
host.innerHTML = '';
await render(SavedTrails, { hideWhenEmpty: false });
const text = host.textContent ?? '';
expect(text).toContain('Saved trails');
expect(text).toContain('This host does not store trails.');
});
it('CodegraphUi installs the adapter before its children ask for data', async () => {
const { adapter, calls } = mockAdapter();
// NOT installed by hand — the provider is the only thing that installs it.