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
+21 -5
View File
@@ -64,8 +64,8 @@ build so that mistake cannot land twice.
Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
`FileSourceView`, `EntryPointsView`, `DeadCodeView`, `TypeHierarchy`, `TrailBar`,
`SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`,
`ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
`SavedTrails`, `SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`,
`KindGlyph`, `ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`,
`buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter
answers in.
@@ -91,6 +91,11 @@ interface GraphAdapter {
routes(request?, signal?): Promise<WireRoutes>;
entryPoints(request?, signal?): Promise<WireEntryPoints>;
deadCode(request?, signal?): Promise<WireDeadCode>;
trails(signal?): Promise<WireTrails>;
// The only mutating pair, and the only optional methods besides `events`.
saveTrail?(request, signal?): Promise<WireTrails>;
deleteTrail?(id, signal?): Promise<WireTrails>;
events?(handlers): () => void; // optional: the live channel
}
```
@@ -99,8 +104,8 @@ The shapes are exactly what `src/ui-server/api/` serialises, and they live in
`src/lib/wire.ts` — no imports, no runtime — so a host can depend on the
vocabulary without depending on the viewer. The default implementation,
`createHttpAdapter()`, is the loopback JSON API; a host that already holds the
index implements the same twelve methods against its own reads and never makes
an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
index implements the same thirteen required methods against its own reads and
never makes an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
built package but `lib/adapter.js` touches the network, because a screen that
reached past the adapter would be a screen that ignored the host.
@@ -108,6 +113,15 @@ reached past the adapter would be a screen that ignored the host.
that learns about a sync some other way calls `live.signal('index')` instead,
which is the same code path the stream uses.
`saveTrail` / `deleteTrail` are optional for a different reason: they are the
only methods in the interface that CHANGE anything, and a host must be able to
render the reader without inheriting a write it never asked for. Omit them and
`TrailBar` grows no Save button and `SavedTrails` says the host does not store
them — the same thing it does when `trails()` answers `readOnly: true`, which is
how a host that *can* store them declines a particular project. `trails()` itself
is required: a host with nowhere to keep them answers an empty read-only list, so
the screen is explained rather than silently missing.
### Three things that will bite
1. **Import `theme.css` once.** Every component paints from the design tokens.
@@ -165,6 +179,8 @@ src/
App.svelte top bar / trail bar / main, global keys
lib/router.svelte.ts hash router: #/s/<id>, #/file/<path>, #/map, #/flow, #/entry
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
lib/trails.svelte.ts saved trails: one shared fetch, and the two writes
lib/trails-model.ts what a saved trail's row says, incl. its decay (pure)
lib/kinds.ts kind glyph letters
lib/map-model.ts the Map's deterministic layered layout (pure)
lib/flow-model.ts the Flow strip's card/link geometry + the end cap — a DAG (pure)
@@ -174,7 +190,7 @@ src/
lib/export-image.ts rasterising that SVG to PNG, clipboard and download
lib/live.svelte.ts /api/events: two counters every screen refreshes from
lib/toast.svelte.ts the one transient note ("Index updated · reloaded")
components/ TopBar, TrailBar, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
components/ TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
views/ one component per route
```