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
+5
View File
@@ -23,6 +23,7 @@
} from './lib/router.svelte';
import { palette } from './lib/palette.svelte';
import { trail, resolveTrailNames } from './lib/trail.svelte';
import { trails } from './lib/trails.svelte';
import { project } from './lib/project.svelte';
import { live } from './lib/live.svelte';
import { toast } from './lib/toast.svelte';
@@ -53,6 +54,10 @@
// kept — so without this the resting palette, the empty screen and the
// entry-points panel would all keep describing the graph as it was.
void palette.reloadEntries();
// Saved trails are re-resolved by the server against the index that just
// moved, so their decay lines are stale the moment it does — a hop that
// was "gone" a minute ago may be back, and vice versa.
void trails.reload();
toast.show('Index updated · reloaded');
});
});
+6 -2
View File
@@ -78,11 +78,15 @@ h3 {
/* ---------- app shell ----------
Design spec §3.1: top bar 48px / trail bar 34px / main. The grid is on
index.html's mount host, which App.svelte fills directly (no wrapper — a
second #app would duplicate the id). */
second #app would duplicate the id).
The trail row is `auto`, not `--trailbar-h`: the bar keeps that height on
its own (see TrailBar.svelte) and grows only while the save-trail form is
open. Pinning the row instead would clip the form. */
#app {
height: 100vh;
display: grid;
grid-template-rows: var(--topbar-h) var(--trailbar-h) 1fr;
grid-template-rows: var(--topbar-h) auto 1fr;
}
/* ---------- cross-view primitives ---------- */
+305
View File
@@ -0,0 +1,305 @@
<script lang="ts">
/**
* The trails somebody kept — the fifth answer to "where do I start".
*
* The other four (routes, executable files, tests, hubs) are derived from the
* graph and describe the project. This one is written by hand and describes
* what a person thought was worth explaining, which is why it sits above them
* on the empty screen: a named walk beats a ranked list every time there is
* one.
*
* Rows follow the search-result grid (18px glyph · name · meta) so the empty
* screen reads as one list rather than two lists in one column. What they add
* is the honesty line: a trail is a claim about code that has since moved, and
* every row says what became of its hops.
*/
import KindGlyph from './KindGlyph.svelte';
import { symbolHref, navigate } from '../lib/navigation';
import { trails } from '../lib/trails.svelte';
import { trail } from '../lib/trail.svelte';
import { decodeTrail } from '../lib/trail-codec';
import {
isOpenable,
trailDecay,
trailExport,
trailMeta,
trailOpens,
trailTitle,
} from '../lib/trails-model';
import type { WireTrail } from '../lib/api';
interface Props {
/** Heading text. The empty screen and the entry-points panel word it alike. */
title?: string;
/** Render nothing at all when there are no saved trails (the empty screen). */
hideWhenEmpty?: boolean;
}
let { title = 'Saved trails', hideWhenEmpty = true }: Props = $props();
$effect(() => {
void trails.ensure();
});
/** Which row is asking to be confirmed before it is deleted. */
let confirming = $state<string | null>(null);
let list = $derived(trails.list);
/**
* Open a trail: adopt its hops, then navigate to the one it ends on.
*
* The store is primed BEFORE the URL changes so the bar draws named hops
* immediately rather than a row of hashes that resolve a moment later — the
* encoded trail carries ids and nothing else, and every name is already here.
*/
function open(saved: WireTrail) {
if (!isOpenable(saved)) return;
const hops = decodeTrail(saved.encoded);
trail.clear();
const resolved = saved.hops.filter((hop) => hop.id !== null);
for (const hop of hops) {
const known = resolved.find((h) => h.id === hop.id);
trail.push({ id: hop.id, name: known?.name ?? null, kind: known?.kind ?? null, dir: hop.dir });
}
navigate(symbolHref(saved.openId as string, { trail: saved.encoded as string }));
}
async function remove(saved: WireTrail) {
if (confirming !== saved.id) {
confirming = saved.id;
return;
}
confirming = null;
await trails.remove(saved.id);
}
/**
* Hand the trail over as the file it is.
*
* `.codegraph/` is gitignored wholesale, which is right for a scratch walk
* and wrong for a tour worth committing — so exporting is a copy the reader
* makes deliberately, and lands wherever their browser puts downloads.
*/
function download(saved: WireTrail) {
const blob = new Blob([trailExport(saved)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${saved.id}.json`;
link.click();
URL.revokeObjectURL(url);
}
</script>
{#if !(hideWhenEmpty && list.length === 0 && trails.failure === null)}
<section class="trails" aria-label={title}>
<div class="head">
<h3>{title}</h3>
{#if trails.directory}
<span class="where">{trails.directory}</span>
{/if}
</div>
{#if trails.failure}
<p class="msg err">{trails.failure}</p>
{:else if !trails.settled}
<p class="msg">Reading saved trails…</p>
{:else if list.length === 0}
<p class="msg">
No saved trails yet. Walk a path through the code, then press
<strong>Save trail</strong> on the trail bar to keep it.
{#if trails.readOnlyReason}
<br />{trails.readOnlyReason}
{/if}
</p>
{:else}
<div class="rows">
{#each list as saved (saved.id)}
{@const decay = trailDecay(saved)}
{@const opens = trailOpens(saved)}
<div class="row" class:dead={!isOpenable(saved)}>
<button
type="button"
class="pick"
title={trailTitle(saved)}
disabled={!isOpenable(saved)}
onclick={() => open(saved)}
>
<KindGlyph kind={saved.hops[0]?.kind ?? null} />
<span class="mid">
<span class="nm">{saved.name}</span>
{#if saved.note}<span class="note">{saved.note}</span>{/if}
</span>
<span class="meta">{trailMeta(saved)}</span>
</button>
<div class="acts">
<button type="button" class="act" onclick={() => download(saved)}>Export</button>
{#if trails.canSave}
<button
type="button"
class="act"
class:armed={confirming === saved.id}
disabled={trails.busy}
onclick={() => remove(saved)}
onblur={() => (confirming = confirming === saved.id ? null : confirming)}
>
{confirming === saved.id ? 'Delete?' : 'Delete'}
</button>
{/if}
</div>
<!-- The honesty line. A saved trail is a claim about code that has
since moved; this is where the graph gets to say so. -->
{#if decay || opens}
<p class="decay" class:warn={decay?.tone === 'warn'}>
{[decay?.text, opens].filter(Boolean).join(' ')}
</p>
{/if}
</div>
{/each}
</div>
{#if trails.payload?.bounded}
<p class="msg">Only the first trails in the directory are listed.</p>
{/if}
{/if}
</section>
{/if}
<style>
.trails {
max-width: 720px;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.trails h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.where {
color: var(--ink-4);
font-family: var(--mono);
font-size: 11px;
}
.msg {
margin: 0;
padding: 8px 0 0;
color: var(--ink-3);
font-size: 12px;
}
.msg.err {
color: var(--accent);
}
.rows {
border: 1px solid var(--rule-soft);
}
.row {
position: relative;
border-bottom: 1px solid var(--rule-faint);
}
.row:last-child {
border-bottom: 0;
}
.pick {
display: grid;
width: 100%;
align-items: baseline;
padding: 6px 10px;
color: var(--ink);
gap: 10px;
grid-template-columns: 18px 1fr auto;
text-align: left;
}
.pick:hover:not(:disabled) {
background: var(--press);
}
.pick:disabled {
color: var(--ink-3);
cursor: default;
}
.mid {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nm {
font-family: var(--mono);
font-size: 12.5px;
}
.note {
margin-left: 6px;
color: var(--ink-3);
font-size: 11.5px;
}
/* Room for the actions, which overlay the row's right edge. */
.meta {
padding-right: 96px;
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
/* Always drawn, never revealed on hover: a control that appears when the
pointer arrives is one a keyboard reader has to guess at. It recedes to
ink-3 instead, which is the same thing done with ink. */
.acts {
position: absolute;
top: 4px;
right: 8px;
display: flex;
gap: 4px;
}
.act {
padding: 2px 6px;
color: var(--ink-3);
background: var(--paper);
border: 1px solid var(--rule-soft);
font-family: var(--sans);
font-size: 11px;
}
.act:hover:not(:disabled) {
color: var(--ink);
border-color: var(--ink);
}
.act.armed {
color: var(--accent);
border-color: var(--accent-line);
background: var(--accent-soft);
}
.decay {
margin: 0;
padding: 0 10px 6px 38px;
color: var(--ink-3);
font-size: 11.5px;
}
.decay.warn {
color: var(--amber);
}
</style>
+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>
+19 -1
View File
@@ -54,6 +54,7 @@ export type {
LiveHandlers,
MapRequest,
RoutesRequest,
SaveTrailRequest,
SearchRequest,
SourceRequest,
} from './lib/adapter';
@@ -102,8 +103,10 @@ export { default as DeadCodeView } from './views/DeadCodeView.svelte';
/* -------------------------------------------------------- the furniture -- */
/** The path walked, with its arrows and its "read as flow". */
/** The path walked, with its arrows, its "read as flow" and its Save. */
export { default as TrailBar } from './components/TrailBar.svelte';
/** The trails somebody kept, each hop re-resolved against the current graph. */
export { default as SavedTrails } from './components/SavedTrails.svelte';
/** The search box, its keyboard and its results panel — one component. */
export { default as SearchPalette } from './components/SearchPalette.svelte';
/** The results panel alone, for a host that owns the input. */
@@ -124,6 +127,7 @@ export { default as TypeHierarchy } from './components/symbol/TypeHierarchy.svel
export { trail, resolveTrailNames } from './lib/trail.svelte';
export { encodeTrail, decodeTrail, hopLabel } from './lib/trail-codec';
export type { HopDirection, TrailHop } from './lib/trail-codec';
export { trails } from './lib/trails.svelte';
export { live, liveRefresh, touchesFile } from './lib/live.svelte';
export type { LiveChanged, LiveHello, LiveIndexEvent, LiveIndexRevision } from './lib/live.svelte';
export { project } from './lib/project.svelte';
@@ -230,6 +234,20 @@ export {
DEAD_CODE_CAVEAT,
} from './lib/deadcode-model';
export {
hopStatusWord,
isOpenable as isTrailOpenable,
replacedTrail,
trailDecay,
trailExport,
trailMeta,
trailNameProblem,
trailOpens,
trailTitle,
MAX_NAMED_DECAYED,
} from './lib/trails-model';
export type { TrailDecay } from './lib/trails-model';
export { buildEntryPanel, flowPair, matchEntries } from './lib/entry-model';
export type {
EntryGroup,
+81 -10
View File
@@ -3,8 +3,8 @@
* through one {@link GraphAdapter} (task CG-61).
*
* The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
* the read-only JSON API over loopback. A host that already holds the graph —
* CodeGraph Pro, which opens the index in-process — implements the same twelve
* the JSON API over loopback. A host that already holds the graph — CodeGraph
* Pro, which opens the index in-process — implements the same thirteen required
* methods against its own reads and never makes an HTTP request. The components
* cannot tell the difference, which is the whole point: one implementation of
* the Symbol view, the Flow strip and the Map, drawn from whichever side of the
@@ -41,6 +41,7 @@ import type {
WireSource,
WireStats,
WireSymbolPayload,
WireTrails,
} from './wire';
/* ---------------------------------------------------------------- errors -- */
@@ -144,6 +145,20 @@ export interface DeadCodeRequest {
includeGenerated?: boolean;
}
/**
* A trail to save: a name, an optional note, and the walk as ids.
*
* Ids and directions only. Everything else a saved hop records — the name, the
* kind, the file, the line — is read out of the graph by the answering side, so
* a saved trail is always a claim the index itself made and can therefore
* re-check when it next changes.
*/
export interface SaveTrailRequest {
name: string;
note?: string;
hops: ReadonlyArray<{ dir: 'start' | 'down' | 'up'; id: string }>;
}
/* ----------------------------------------------------------------- live -- */
/**
@@ -171,8 +186,11 @@ export interface LiveHandlers {
* `source`, `file`, `flow`, `map`, `routes` — and the rest are what the screens
* around them need: `stats` (the blast bar's denominator and the top bar's
* counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode` (the
* whole-file view), `entryPoints` (where a reader starts) and `deadCode` (where
* nobody goes).
* whole-file view), `entryPoints` (where a reader starts), `deadCode` (where
* nobody goes) and `trails` (the walks the reader kept).
*
* Everything here answers a question except `saveTrail`/`deleteTrail`, which
* are optional for exactly that reason.
*/
export interface GraphAdapter {
/** The index's own facts: counts, thresholds, the blast scale. */
@@ -198,6 +216,26 @@ export interface GraphAdapter {
entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
/** Symbols nothing reaches, grouped by file, with every exclusion counted. */
deadCode(request?: DeadCodeRequest, signal?: AbortSignal): Promise<WireDeadCode>;
/**
* The reader's saved trails, each hop re-resolved against the current graph.
*
* A host with nowhere to keep them answers `{ trails: [], readOnly: true, … }`
* rather than omitting the method: the screens then show the section as
* empty-and-explained instead of showing a Save button that does nothing.
*/
trails(signal?: AbortSignal): Promise<WireTrails>;
/**
* Save a trail, answering the full list as it now stands.
*
* OPTIONAL, and the only mutating pair in this interface. An adapter that
* refuses to write simply omits {@link saveTrail} and {@link deleteTrail} —
* a host must be able to render the reader without inheriting a filesystem
* write it never asked for, and the viewer hides Save when they are absent
* exactly as it does when the server answers `readOnly`.
*/
saveTrail?(request: SaveTrailRequest, signal?: AbortSignal): Promise<WireTrails>;
/** Remove a saved trail by id, answering the list as it now stands. */
deleteTrail?(id: string, signal?: AbortSignal): Promise<WireTrails>;
/**
* Subscribe to index/disk changes. Optional — a host without a live channel
* omits it and nothing polls. Returns a function that closes the stream.
@@ -229,7 +267,18 @@ function query(params: URLSearchParams): string {
}
/**
* The default adapter: the read-only JSON API `codegraph ui` serves.
* The header every write carries.
*
* The server refuses a `POST`/`DELETE` without it. It is not a secret and is
* not trying to be: a custom request header cannot be sent cross-origin without
* a CORS preflight, and the viewer's server answers none — so its presence is
* proof the request came from a page the server itself served. Must match
* `WRITE_HEADER` in `src/ui-server/security.ts`.
*/
export const WRITE_HEADER = 'X-CodeGraph-UI';
/**
* The default adapter: the JSON API `codegraph ui` serves.
*
* Every failure it can describe comes back as an {@link ApiFailure} carrying
* the server's own sentence. The one it cannot describe — the server was
@@ -241,13 +290,10 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
const doFetch = options.fetch ?? ((...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args));
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
async function call<T>(path: string, init: RequestInit, signal?: AbortSignal): Promise<T> {
let response: Response;
try {
response = await doFetch(`${base}${path}`, {
signal,
headers: { accept: 'application/json' },
});
response = await doFetch(`${base}${path}`, { ...init, signal });
} catch (cause) {
if (signal?.aborted) throw cause;
throw new ApiFailure(
@@ -271,6 +317,24 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return body as T;
}
function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
return call<T>(path, { headers: { accept: 'application/json' } }, signal);
}
/** A write: the marker header, and a JSON body when there is one to send. */
function write<T>(path: string, method: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const headers: Record<string, string> = {
accept: 'application/json',
[WRITE_HEADER]: '1',
};
if (body !== undefined) headers['content-type'] = 'application/json';
return call<T>(
path,
{ method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) },
signal
);
}
return {
stats: (signal) => getJson<WireStats>('api/stats', signal),
@@ -346,6 +410,13 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return getJson<WireDeadCode>(`api/deadcode${query(params)}`, signal);
},
trails: (signal) => getJson<WireTrails>('api/trails', signal),
saveTrail: (request, signal) => write<WireTrails>('api/trails', 'POST', request, signal),
deleteTrail: (id, signal) =>
write<WireTrails>(`api/trails/${encodeURIComponent(id)}`, 'DELETE', undefined, signal),
events(handlers) {
if (typeof EventSource === 'undefined') return () => {};
const stream = new EventSource(`${base}api/events`);
+50 -1
View File
@@ -11,7 +11,7 @@
* shape it answers with.
*/
import { getGraphAdapter } from './adapter';
import { ApiFailure, getGraphAdapter } from './adapter';
import type {
WireDeadCode,
WireEntryPoints,
@@ -25,7 +25,9 @@ import type {
WireSource,
WireStats,
WireSymbolPayload,
WireTrails,
} from './wire';
import type { SaveTrailRequest } from './adapter';
export * from './wire';
export { ApiFailure } from './adapter';
@@ -38,6 +40,7 @@ export type {
LiveHandlers,
MapRequest,
RoutesRequest,
SaveTrailRequest,
SearchRequest,
SourceRequest,
} from './adapter';
@@ -158,3 +161,49 @@ export function fetchFlow(
): Promise<WireFlowPayload> {
return getGraphAdapter().flow(spec, signal);
}
/* ---------------------------------------------------------- saved trails -- */
/**
* The reader's saved trails, every hop re-resolved against the current index.
*
* A trail is stored by qualified name rather than by node id, so this is where
* the graph gets to say what became of each hop since it was written: still
* there, moved, now ambiguous, or gone.
*/
export function fetchTrails(signal?: AbortSignal): Promise<WireTrails> {
return getGraphAdapter().trails(signal);
}
/**
* Whether trails can be written at all through the installed adapter.
*
* Separate from the `readOnly` flag on the payload: that one is the *answering
* side* declining, this one is an adapter that never offered. Both hide Save,
* and the screens say which it was.
*/
export function canWriteTrails(): boolean {
const adapter = getGraphAdapter();
return typeof adapter.saveTrail === 'function' && typeof adapter.deleteTrail === 'function';
}
/** Save a trail, answering the whole list as it now stands. */
export function saveTrail(
request: SaveTrailRequest,
signal?: AbortSignal
): Promise<WireTrails> {
const adapter = getGraphAdapter();
if (!adapter.saveTrail) {
return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot save trails.', null));
}
return adapter.saveTrail(request, signal);
}
/** Remove a saved trail, answering the whole list as it now stands. */
export function deleteTrail(id: string, signal?: AbortSignal): Promise<WireTrails> {
const adapter = getGraphAdapter();
if (!adapter.deleteTrail) {
return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot delete trails.', null));
}
return adapter.deleteTrail(id, signal);
}
+202
View File
@@ -0,0 +1,202 @@
/**
* What a saved trail says about itself, without a browser.
*
* `/api/trails` hands back each trail with every hop already re-resolved
* against the current index — so all this module does is turn that into the
* sentences the rows print. It is pure for the usual reason (it can be tested,
* and a host building its own trail list gets the shipped arithmetic rather
* than its own), and because the interesting decisions here are *wording*
* decisions, which is exactly the kind of thing that drifts when it is spread
* across two components.
*
* The one rule it keeps: **a trail that has decayed never reads as intact.**
* A saved trail is somebody's explanation of a codebase, and the codebase moves
* underneath it. Showing "6 hops" for a trail where two hops no longer resolve
* would make it a lie by omission at exactly the moment it needs to be fixed.
*
* Tested in `__tests__/ui-trails-model.test.ts`.
*/
import type { WireTrail, WireTrailHop, WireTrailHopStatus } from './wire';
import { plural } from './symbol-model';
/** Hops named in the decay line before it stops naming them. */
export const MAX_NAMED_DECAYED = 3;
/**
* The row's second line: how long the walk is, and who wrote it.
*
* The hop count is the SAVED length, always — the trail is six hops whatever
* became of them. What became of them is {@link trailDecay}'s job, on its own
* line, so the two facts cannot be read as one.
*/
export function trailMeta(trail: WireTrail): string {
const hops = plural(trail.hops.length, 'hop');
return trail.author ? `${hops} · ${trail.author}` : hops;
}
/** The verdict a decayed hop carries, in the words a row uses. */
export function hopStatusWord(status: WireTrailHopStatus): string {
switch (status) {
case 'ok':
return 'still here';
case 'moved':
return 'moved';
case 'ambiguous':
return 'ambiguous';
case 'missing':
return 'gone';
}
}
export interface TrailDecay {
/** `warn` when something is unopenable, `note` when it merely moved. */
tone: 'warn' | 'note';
text: string;
}
/**
* What has happened to this trail since it was saved, or null when nothing has.
*
* Two tones, because they call for different things from the reader: a hop that
* MOVED still opens and only wants acknowledging, while a hop that is gone (or
* that now names several symbols) means the trail no longer says what its author
* meant it to say.
*/
export function trailDecay(trail: WireTrail): TrailDecay | null {
const missing = trail.hops.filter((hop) => hop.status === 'missing');
const ambiguous = trail.hops.filter((hop) => hop.status === 'ambiguous');
const moved = trail.hops.filter((hop) => hop.status === 'moved');
if (missing.length > 0) {
return {
tone: 'warn',
text:
`${plural(missing.length, 'hop')} moved or renamed since this was saved — ` +
`${nameList(missing)} no longer in the index.`,
};
}
if (ambiguous.length > 0) {
return {
tone: 'warn',
text: `${nameList(ambiguous)} now ${ambiguous.length === 1 ? 'names' : 'name'} more than one symbol — showing the closest match.`,
};
}
if (moved.length > 0) {
return {
tone: 'note',
text: `${nameList(moved)} moved to another file since this was saved.`,
};
}
return null;
}
/**
* How much of the trail can actually be opened, or null when all of it can.
*
* The payload carries the longest run of CONSECUTIVE resolved hops rather than
* every resolved hop, because the trail is a path: skipping a broken hop would
* encode a step from one symbol to another that nothing joins. When that run is
* shorter than the trail, the row has to say so before somebody opens it and
* wonders where the first two hops went.
*/
export function trailOpens(trail: WireTrail): string | null {
if (trail.encoded === null) return 'None of this trail resolves in the current index.';
if (trail.openCount === trail.hops.length) return null;
const last = trail.openFrom + trail.openCount - 1;
const range = trail.openCount === 1 ? `hop ${trail.openFrom}` : `hops ${trail.openFrom}${last}`;
return `Opens ${range} of ${trail.hops.length}.`;
}
/** Can this row be opened at all? */
export function isOpenable(trail: WireTrail): boolean {
return trail.encoded !== null && trail.openId !== null;
}
/** Hover text: the whole walk, in order, with its arrows. */
export function trailTitle(trail: WireTrail): string {
const path = trail.hops
.map((hop, index) => (index === 0 ? hop.name : `${arrow(hop)} ${hop.name}`))
.join(' ');
const when = trail.updatedAt ? ` — saved ${trail.updatedAt.slice(0, 10)}` : '';
return `${path}${when}`;
}
function arrow(hop: WireTrailHop): string {
return hop.dir === 'up' ? '←' : hop.dir === 'down' ? '→' : '·';
}
function nameList(hops: readonly WireTrailHop[]): string {
const names = hops.slice(0, MAX_NAMED_DECAYED).map((hop) => hop.name);
const rest = hops.length - names.length;
const listed =
names.length === 1
? (names[0] as string)
: `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
return rest > 0 ? `${listed} and ${rest} more` : listed;
}
/* ------------------------------------------------------------- saving -- */
/**
* Why this name cannot be saved, or null when it can.
*
* Only the two things the server would refuse anyway; everything else about a
* name is the reader's business. Checked here as well so the form can disable
* its own button rather than teaching by round-trip.
*/
export function trailNameProblem(name: string, maxLength: number): string | null {
const trimmed = name.trim();
if (trimmed === '') return 'Give the trail a name.';
if (trimmed.length > maxLength) return `That name is too long (max ${maxLength} characters).`;
return null;
}
/**
* The trail this name would replace, or null when it would be a new one.
*
* Saving under an existing name overwrites it — that is what a reader means by
* pressing Save twice — but they should be told before, not after.
*/
export function replacedTrail(name: string, trails: readonly WireTrail[]): WireTrail | null {
const trimmed = name.trim().replace(/\s+/g, ' ');
return trails.find((trail) => trail.name === trimmed) ?? null;
}
/**
* A saved trail as the file it is, ready to be written somewhere a repository
* will keep it.
*
* The trails directory is inside `.codegraph/`, which is gitignored wholesale —
* that is the right default for a scratch walk and the wrong one for a tour
* worth committing. Exporting is therefore a copy the reader makes on purpose,
* and this is the same shape the viewer writes: each hop's saved IDENTITY —
* qualified name, kind, the file it was in — so dropping the file into another
* checkout re-runs the same resolution rather than baking today's answer in.
* Only the id hint is refreshed to whatever the symbol's id is now, since that
* is all an id has ever been here.
*/
export function trailExport(trail: WireTrail): string {
return `${JSON.stringify(
{
version: 1,
id: trail.id,
name: trail.name,
note: trail.note,
author: trail.author,
createdAt: trail.createdAt,
updatedAt: trail.updatedAt,
hops: trail.hops.map((hop) => ({
dir: hop.dir,
name: hop.name,
qualifiedName: hop.qualifiedName,
kind: hop.kind,
file: hop.savedFile,
line: hop.savedLine,
id: hop.id ?? '',
})),
},
null,
2
)}\n`;
}
+164
View File
@@ -0,0 +1,164 @@
/**
* The saved trails, as live state.
*
* Everything that decides what a row *says* is in `trails-model.ts`; this owns
* the parts that need time — one fetch shared by every screen that lists them,
* and the two writes.
*
* Two things it does deliberately:
*
* - **A write answers with the whole list, and the whole list is adopted.**
* Saving does not patch one row in place. The server re-resolves every hop of
* every trail on the way out, so a save is also the cheapest moment to learn
* that a trail saved last week has decayed — and patching locally would show
* a screen that had quietly stopped agreeing with the files on disk.
* - **Failures are kept, not thrown away.** The one place in the viewer that
* can fail because of the *filesystem* (a read-only checkout, a full disk) is
* here, and "nothing happened" is the worst possible answer to a reader who
* just pressed Save.
*/
import { canWriteTrails, deleteTrail, fetchTrails, saveTrail, type WireTrail, type WireTrails } from './api';
import type { TrailHop } from './trail-codec';
let payload = $state<WireTrails | null>(null);
/** Null until the first attempt settles — the section says "reading" until then. */
let settled = $state(false);
let failure = $state<string | null>(null);
let busy = $state(false);
let inflight: Promise<void> | null = null;
function load(): Promise<void> {
if (inflight) return inflight;
inflight = fetchTrails()
.then((value) => {
payload = value;
failure = null;
})
.catch((cause: unknown) => {
// A viewer whose trails cannot be listed still works; the section is the
// only thing that has to know, and it prints the reason rather than an
// empty box that looks like "you have never saved one".
payload = null;
failure = cause instanceof Error ? cause.message : String(cause);
})
.finally(() => {
settled = true;
});
return inflight;
}
function adopt(next: WireTrails): void {
payload = next;
failure = null;
settled = true;
// The in-flight promise is the *load*; replacing the payload out from under
// it is fine, but a later `ensure()` must not resolve to the stale one.
inflight = Promise.resolve();
}
export const trails = {
get list(): readonly WireTrail[] {
return payload?.trails ?? [];
},
get payload(): WireTrails | null {
return payload;
},
/** False until the first fetch settles, however it settled. */
get settled(): boolean {
return settled;
},
get failure(): string | null {
return failure;
},
/** A save or a delete is in flight — the form disables itself. */
get busy(): boolean {
return busy;
},
/**
* Whether the viewer offers to save at all.
*
* Two independent reasons it might not, and the screens distinguish them:
* the adapter never offered a write ({@link canWriteTrails}), or the
* answering side declined this one (`readOnly` on the payload). Until the
* first fetch settles we assume it can, so the Save button does not flicker
* into existence a moment after the trail bar draws.
*/
get canSave(): boolean {
if (!canWriteTrails()) return false;
return payload === null || !payload.readOnly;
},
/**
* Why saving is off, when it is.
*
* The answering side's own sentence wins when there is one — it is the more
* specific truth, and it is the one that names the flag or the mount that
* caused it. The generic line is only for an adapter that never offered a
* write at all, which has nothing to say for itself.
*/
get readOnlyReason(): string | null {
if (payload?.readOnly) return payload.readOnlyReason ?? 'This viewer is running read-only.';
if (!canWriteTrails()) return 'This viewer cannot save trails.';
return null;
},
/** Where the files live, project-relative. Null until known. */
get directory(): string | null {
return payload?.directory ?? null;
},
/** Load once. Every screen that lists trails calls this. */
ensure: load,
/** Ask again, because the index moved or a file changed underneath us. */
reload(): Promise<void> {
inflight = null;
return load();
},
/**
* Save the walk under a name.
*
* Hops travel as ids and directions only — the answering side reads each
* symbol's name, kind and file out of the graph, so a saved trail is always
* something the index itself said.
*
* @returns the id written, or null when the save failed (see `failure`).
*/
async save(name: string, note: string, hops: readonly TrailHop[]): Promise<string | null> {
busy = true;
try {
const answer = await saveTrail({
name,
note,
hops: hops.map((hop) => ({ dir: hop.dir, id: hop.id })),
});
adopt(answer);
return answer.saved ?? null;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
return null;
} finally {
busy = false;
}
},
/** Remove a saved trail. Returns whether it went. */
async remove(id: string): Promise<boolean> {
busy = true;
try {
adopt(await deleteTrail(id));
return true;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
return false;
} finally {
busy = false;
}
},
/** Drop the last failure, so a retry starts from a clean screen. */
clearFailure(): void {
failure = null;
},
};
+72
View File
@@ -686,3 +686,75 @@ export interface WireDeadCode {
corroborated: boolean;
timing: { elapsedMs: number };
}
/* ---------------------------------------------------------- saved trails -- */
/**
* How a saved hop fared against the index as it is NOW.
*
* A trail is stored by qualified name rather than by node id (a node id
* contains its start line, so any edit above a symbol renames it), and every
* hop is re-resolved on the way out. This is what that re-resolution found.
*/
export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
export interface WireTrailHop {
dir: 'start' | 'down' | 'up';
/** The name as it was when the trail was saved. */
name: string;
qualifiedName: string;
kind: string;
savedFile: string;
savedLine: number;
status: WireTrailHopStatus;
/** The symbol's id NOW. Null when nothing answers to it any more. */
id: string | null;
file: string | null;
line: number | null;
/** Finished screen wording for a status that is not `ok`; null when it is. */
note: string | null;
}
export interface WireTrail {
id: string;
name: string;
note: string;
author: string;
createdAt: string;
updatedAt: string;
hops: WireTrailHop[];
/** Hops that still resolve to a symbol in this index. */
resolved: number;
/** Every hop resolved, and none of them moved. */
intact: boolean;
/**
* The longest run of CONSECUTIVE resolved hops, as the `t` param. Null when
* nothing in the trail resolves. Never stitched across a hole — the trail is
* a path, and a fabricated adjacency is worse than a short one.
*/
encoded: string | null;
/** 1-based index of the first hop `encoded` carries. */
openFrom: number;
/** How many hops `encoded` carries. */
openCount: number;
/** The symbol the trail opens at — the last hop of that run. */
openId: string | null;
}
export interface WireTrails {
trails: WireTrail[];
/** Writes are off. Save and Delete are hidden, and the screen says why. */
readOnly: boolean;
readOnlyReason: string | null;
/** Project-relative directory the files live in. */
directory: string;
/** Files in that directory that were not readable trails. */
skipped: number;
bounded: boolean;
/** The id just written, on the answer to a save. */
saved?: string;
/** That save replaced a trail of the same name. */
replaced?: boolean;
/** The id just removed, on the answer to a delete. */
deleted?: string;
}
+13
View File
@@ -17,6 +17,7 @@
* at rest, the empty screen and this panel, so all three agree on the order.
*/
import EntrySection from '../components/entry/EntrySection.svelte';
import SavedTrails from '../components/SavedTrails.svelte';
import { palette } from '../lib/palette.svelte';
import { buildEntryPanel, flowPair, type EntryRow } from '../lib/entry-model';
import { flowHref, navigate } from '../lib/navigation';
@@ -120,6 +121,13 @@
</div>
{/if}
<!-- The one list here that a person wrote rather than the graph derived. It
is drawn in full (not hidden when empty) because this screen is where a
reader comes looking for one. -->
<div class="saved">
<SavedTrails hideWhenEmpty={false} />
</div>
{#if palette.entriesFailure}
<p class="state">Could not read the entry points — {palette.entriesFailure}</p>
{:else if !palette.entriesSettled}
@@ -146,6 +154,11 @@
padding: 26px 40px 6px;
}
.saved {
max-width: 800px;
padding: 14px 40px 0;
}
.head h2 {
margin: 0 0 6px;
font-size: 20px;
+13
View File
@@ -14,6 +14,7 @@
* start a flow, is `#/entry` (`EntryView`); this screen links to it.
*/
import PaletteRows from '../components/PaletteRows.svelte';
import SavedTrails from '../components/SavedTrails.svelte';
import { palette } from '../lib/palette.svelte';
import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
import { entryHref, fileHref, flowHref, navigate } from '../lib/navigation';
@@ -71,6 +72,13 @@
</p>
</div>
<!-- Above the derived lists on purpose: a walk somebody named and kept is a
better place to start than any ranking, when there is one. It draws
nothing at all when there is not. -->
<div class="saved">
<SavedTrails />
</div>
{#if entries.sections.length > 0}
<section class="entries" aria-label="Where to start">
<div class="entries-h">
@@ -96,6 +104,11 @@
padding-bottom: 8px;
}
.saved {
max-width: 800px;
padding: 8px 40px 0;
}
.entries {
max-width: 720px;
padding: 8px 40px 48px;