Files
codegraph/ui/src/components/TrailBar.svelte
T
Colby McHenryandClaude Opus 5 47576b392e 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>
2026-08-27 08:31:48 -05:00

336 lines
8.6 KiB
Svelte

<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;
trail.truncateTo(index);
navigate(symbolHref(hop.id, { trail: encodeTrail(trail.hops) }));
}
/**
* The walk itself IS the flow: the Flow view does not search for a path, it
* looks up the edge already joining each consecutive pair and draws the cards
* at those lines. So the trail travels under the same `t` param it uses
* everywhere else — a flow read from a trail is one walk under two lenses.
*/
function readAsFlow() {
navigate(flowHref({ trail: encodeTrail(hops) }));
}
/**
* Clear the path, keep the place.
*
* Emptying the trail while you are reading a symbol would also throw the
* symbol away, which is not what "Clear" says. It restarts the trail at
* where you are — one `start` hop — and only leaves for the empty screen
* when there is nowhere to stay.
*/
function clear() {
const here = trail.current;
trail.clear();
if (!here) {
navigate('#/');
return;
}
trail.push({ id: here.id, name: here.name, kind: here.kind, dir: 'start' });
navigate(symbolHref(here.id, { trail: encodeTrail(trail.hops) }), { replace: true });
}
</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>
{#if hops.length === 0}
<span class="empty"
>Step into a call on the right, or up to a caller on the left — the trail records the
path.</span
>
{:else}
{#each hops as hop, i (hop.id)}
{#if i > 0}
<span
class="hop-arrow"
class:up={hop.dir === 'up'}
title={hop.dir === 'up'
? 'stepped up to a caller'
: hop.dir === 'down'
? 'stepped down into a call'
: 'jumped here'}
aria-hidden="true"
>
{hop.dir === 'up' ? '←' : hop.dir === 'down' ? '→' : '·'}
</span>
{/if}
<button
type="button"
class="hop"
class:cur={i === hops.length - 1}
aria-current={i === hops.length - 1 ? 'true' : undefined}
onclick={() => step(i)}
>
<KindGlyph kind={hop.kind} />
<span>{hopLabel(hop)}</span>
</button>
{/each}
{/if}
<span class="spacer"></span>
{#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>
.trailwrap {
display: flex;
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);
font-size: 12px;
}
.label {
margin-right: 10px;
color: var(--ink-3);
font-family: var(--sans);
}
.empty {
color: var(--ink-3);
font-family: var(--sans);
}
.hop {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
color: var(--ink-2);
border: 1px solid transparent;
font-family: var(--mono);
font-size: 12px;
}
.hop:hover {
color: var(--ink);
background: var(--press);
}
.hop.cur {
color: var(--accent);
border-color: var(--accent-line);
background: var(--paper);
}
.hop-arrow {
padding: 0 2px;
color: var(--ink-3);
}
.hop-arrow.up {
color: var(--ink-2);
}
.spacer {
flex: 1;
}
.tb-btn {
margin-left: 8px;
padding: 4px 8px;
color: var(--ink-2);
background: var(--paper);
border: 1px solid var(--rule-soft);
font-family: var(--sans);
}
.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>