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
+175 -5
View File
@@ -1,10 +1,71 @@
<script lang="ts">
/**
* The path the reader walked — and the one place they can keep it.
*
* "Save trail" is the viewer's only write. It opens a one-field form rather
* than a dialog because naming a walk is a thought the reader is already
* having; anything modal would stop the reading to ask about filing.
*/
import KindGlyph from './KindGlyph.svelte';
import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
import { navigate, symbolHref, flowHref } from '../lib/navigation';
import { trails } from '../lib/trails.svelte';
import { replacedTrail, trailNameProblem } from '../lib/trails-model';
import { toast } from '../lib/toast.svelte';
/** Matches `MAX_TRAIL_NAME` in `src/ui-server/api/trail-store.ts`. */
const MAX_NAME = 120;
let hops = $derived(trail.hops);
let naming = $state(false);
let name = $state('');
let nameInput: HTMLInputElement | null = $state(null);
// The list is wanted before Save is pressed, not after: it decides whether
// this name would REPLACE something, which the form has to say beforehand.
$effect(() => {
if (naming) void trails.ensure();
});
let problem = $derived(trailNameProblem(name, MAX_NAME));
let replaces = $derived(naming ? replacedTrail(name, trails.list) : null);
function openForm() {
trails.clearFailure();
naming = true;
// The last hop is the thing the reader is looking at, so it is the most
// likely name for the walk that got there — offered, not imposed.
name = trail.current?.name ?? '';
queueMicrotask(() => {
nameInput?.focus();
nameInput?.select();
});
}
function closeForm() {
naming = false;
name = '';
}
async function submit(event: Event) {
event.preventDefault();
if (problem || trails.busy) return;
const replacing = replaces !== null;
const saved = await trails.save(name, '', hops);
if (saved === null) return; // the reason is on `trails.failure`, shown below
toast.show(replacing ? `Trail replaced · ${name.trim()}` : `Trail saved · ${name.trim()}`);
closeForm();
}
function onkeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeForm();
}
}
function step(index: number) {
const hop = hops[index];
if (!hop) return;
@@ -42,6 +103,10 @@
}
</script>
<!-- One root element, always: the save form is a second row inside it rather
than a sibling, so a host's layout still sees the trail bar as one box
whose height grows only while the form is open. -->
<div class="trailwrap">
<div class="trailbar">
<span class="label">Trail</span>
@@ -84,19 +149,66 @@
{#if hops.length > 1}
<button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
{/if}
{#if hops.length > 0 && trails.canSave && !naming}
<button type="button" class="tb-btn" onclick={openForm}>Save trail</button>
{/if}
{#if hops.length > 0}
<button type="button" class="tb-btn" onclick={clear}>Clear</button>
{/if}
</div>
{#if naming}
<form class="saveform" onsubmit={submit}>
<label for="trail-name">Name this trail</label>
<input
bind:this={nameInput}
bind:value={name}
{onkeydown}
id="trail-name"
type="text"
maxlength={MAX_NAME}
autocomplete="off"
spellcheck="false"
placeholder="How a request reaches the handler"
/>
<button type="submit" class="tb-btn" disabled={problem !== null || trails.busy}>
{trails.busy ? 'Saving…' : replaces ? 'Replace' : 'Save'}
</button>
<button type="button" class="tb-btn" onclick={closeForm}>Cancel</button>
<!-- Everything the reader should know BEFORE pressing, in one line: what
it will be called, that it will overwrite, and where it lands. -->
<span class="hint" class:warn={replaces !== null}>
{#if replaces}
Replaces the saved trail of the same name.
{:else if trails.directory}
{hops.length} hop{hops.length === 1 ? '' : 's'} · saved to {trails.directory}
{:else}
{hops.length} hop{hops.length === 1 ? '' : 's'}
{/if}
</span>
{#if trails.failure}
<span class="err">{trails.failure}</span>
{/if}
</form>
{/if}
</div>
<style>
.trailbar {
.trailwrap {
display: flex;
align-items: center;
gap: 0;
padding: 0 18px;
min-height: 0;
flex-direction: column;
background: var(--paper-2);
border-bottom: 1px solid var(--rule-soft);
}
.trailbar {
display: flex;
height: var(--trailbar-h, 34px);
align-items: center;
flex: 0 0 auto;
gap: 0;
padding: 0 18px;
overflow-x: auto;
white-space: nowrap;
font-family: var(--mono);
@@ -158,8 +270,66 @@
font-family: var(--sans);
}
.tb-btn:hover {
.tb-btn:hover:not(:disabled) {
color: var(--ink);
border-color: var(--ink);
}
.tb-btn:disabled {
color: var(--ink-4);
border-color: var(--rule-faint);
}
/* ---------- the one-field save form ---------- */
.saveform {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 18px 8px;
border-top: 1px solid var(--rule-faint);
flex-wrap: wrap;
}
.saveform label {
color: var(--ink-2);
font-family: var(--sans);
font-size: 12px;
}
.saveform input {
width: 320px;
height: 30px;
max-width: 100%;
padding: 0 10px;
border: 1px solid var(--rule-soft);
background: var(--paper);
color: var(--ink);
font: 13px var(--sans);
}
.saveform input:focus {
border-color: var(--ink);
outline: none;
}
.saveform input::placeholder {
color: var(--ink-4);
}
.hint {
color: var(--ink-3);
font-family: var(--sans);
font-size: 11.5px;
}
.hint.warn {
color: var(--amber);
}
.err {
color: var(--accent);
font-family: var(--sans);
font-size: 11.5px;
}
</style>