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:
co-authored by
Claude Opus 5
parent
55a33055ee
commit
47576b392e
@@ -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
@@ -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 ---------- */
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user