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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user