feat(ui): the search palette, entry points and a trail that survives the URL (CG-45)

Search: `/` or ⌘K focuses the box; results arrive grouped by kind with their
glyph, signature and file:line, ↑/↓/Enter walk them, Esc dismisses. A group
appears where its best result did, so flattening the groups reproduces the
ranking the keyboard walks — the panel's flat item list IS that concatenation.
A flow question ("how does X reach Y", "X -> Y") is recognised and searches
both endpoints with a note, rather than offering a row that would land on the
phase-2 Flow view.

Entry points answer "where do I start" on the empty screen and in the resting
palette, all derived from the graph: routes, files that run something at module
level (the engine records a top-level statement as an edge out of the file node,
which is what makes src/bin/codegraph.ts the root of the CLI flow — ranked by
calls x the files they reach, so a registration table calling into itself does
not outrank the CLI), and the most depended-on symbols. Tests are excluded from
both derived lists.

Trail: hops record the direction they were walked (→ into a call, ← up to a
caller), clicking one truncates back to it, Clear keeps the place instead of
throwing it away, and the whole walk travels in the URL. A shared or reloaded
trail arrives as ids, so hops learn their names back through a new batch
endpoint and a session name cache — without it, walking back across a
truncation redrew earlier hops as raw hashes. "Read as flow" stays hidden until
there is a Flow view to send it to.

New endpoints: /api/entrypoints and /api/nodes. New engine reads:
getTopCallingFiles, getFileDependentCounts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-27 00:54:18 -05:00
co-authored by Claude Opus 5
parent e9596af1cf
commit 87afc50e76
20 changed files with 1926 additions and 67 deletions
+88
View File
@@ -163,6 +163,67 @@ export interface WireBlastScale {
estimated: boolean;
}
/* ------------------------------------------------------- search palette -- */
/** How a result's text matched the query — the server's primary sort key. */
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
export interface WireSearchResult extends WireNodeRef {
matchKind: MatchKind;
}
export interface WireSearchGroup {
kind: NodeKind;
count: number;
items: WireSearchResult[];
}
export interface WireSearch {
query: string;
/** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
text: string;
filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
results: WireList<WireSearchResult>;
/** Kind buckets in ranked order — flattening them reproduces the ranking. */
groups: WireSearchGroup[];
}
export interface WireNodeRefs {
items: WireNodeRef[];
/** Ids that name nothing in this index — a stale link, not an error. */
missing: string[];
}
/* ---------------------------------------------------------- entry points -- */
export interface WireEntryRoute {
url: string;
handler: string;
file: string;
line: number;
handlerId: string | null;
}
export interface WireEntryFile extends WireNodeRef {
/** Calls and instantiations made at the top level of the file. */
calls: number;
/** Distinct other files this one's symbols reach. */
reaches: number;
/** Other files reaching into this one. Zero means nothing imports it. */
dependents: number;
}
export interface WireEntryHub extends WireNodeRef {
dependents: number;
}
export interface WireEntryPoints {
routes: { routed: boolean; routeCount: number; items: WireEntryRoute[] };
/** `total` is a floor on both lists — the server counts what its scan saw. */
files: WireList<WireEntryFile>;
hubs: WireList<WireEntryHub>;
}
export interface WireStats {
project: { root: string; name: string };
index: {
@@ -257,6 +318,33 @@ export function fetchSymbol(id: string, signal?: AbortSignal): Promise<WireSymbo
return getJson<WireSymbolPayload>(`api/node/${encoded}`, signal);
}
export function fetchSearch(
query: string,
opts: { limit?: number } = {},
signal?: AbortSignal
): Promise<WireSearch> {
const params = new URLSearchParams({ q: query });
if (opts.limit) params.set('limit', String(opts.limit));
return getJson<WireSearch>(`api/search?${params}`, signal);
}
/** Names and locations for ids you already have — what the trail redraws with. */
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
const params = new URLSearchParams();
for (const id of ids) params.append('id', id);
return getJson<WireNodeRefs>(`api/nodes?${params}`, signal);
}
export function fetchEntryPoints(
opts: { limit?: number } = {},
signal?: AbortSignal
): Promise<WireEntryPoints> {
const params = new URLSearchParams();
if (opts.limit) params.set('limit', String(opts.limit));
const query = params.toString();
return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
}
export function fetchSource(
file: string,
from: number,
+191
View File
@@ -0,0 +1,191 @@
/**
* The search palette's live state.
*
* Everything that decides *what* is on screen lives in `search-model.ts` as
* plain functions; this module only owns the parts that need time: the debounce
* that keeps a fast typist from firing a request per keystroke, the abort that
* throws away an answer to a query nobody is asking any more, and the selection
* the ↑/↓ keys move.
*
* The entry points are fetched once and kept — they describe the index, not the
* query — so the palette has something to show the instant it opens.
*/
import { fetchEntryPoints, fetchSearch, type WireEntryPoints, type WireSearch } from './api';
import {
buildEntryPalette,
buildSearchPalette,
moveSelection,
parseFlowQuery,
type Palette,
type PaletteItem,
} from './search-model';
/** Results asked of the server for one search. */
const SEARCH_LIMIT = 40;
/**
* Entry-point rows fetched, and how many of them the palette shows.
*
* One fetch serves both readers: the palette wants a short list under the box,
* the empty screen wants the long one. Fetching the long list and slicing is a
* request saved and — more to the point — keeps the two lists in the same
* order, which they would not be if they were two answers taken at two times.
*/
const ENTRY_LIMIT = 24;
export const PALETTE_ENTRY_ROWS = 6;
/**
* Milliseconds of quiet before a query is sent.
*
* The server answers a search in single-digit milliseconds on this repo's own
* index, so this is not about protecting it — it is about not showing three
* different result sets while a word is still being typed.
*/
const DEBOUNCE_MS = 90;
let query = $state('');
let open = $state(false);
let selected = $state(0);
let loading = $state(false);
let failure = $state<string | null>(null);
let answers = $state<WireSearch[]>([]);
let entries = $state<WireEntryPoints | null>(null);
let inflight: AbortController | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
/** Guards against an older answer landing after a newer one. */
let generation = 0;
let entriesInflight: Promise<void> | null = null;
function loadEntries(): Promise<void> {
if (entriesInflight) return entriesInflight;
entriesInflight = fetchEntryPoints({ limit: ENTRY_LIMIT })
.then((value) => {
entries = value;
})
.catch(() => {
// The palette still works without them; a failed "where do I start"
// should never stop someone from typing a name.
entries = null;
});
return entriesInflight;
}
function cancel(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
inflight?.abort();
inflight = null;
}
async function run(text: string, mine: number): Promise<void> {
const flow = parseFlowQuery(text);
const controller = new AbortController();
inflight = controller;
loading = true;
try {
const queries = flow ? [flow.from, flow.to] : [text];
const results = await Promise.all(
queries.map((q) => fetchSearch(q, { limit: SEARCH_LIMIT }, controller.signal))
);
if (mine !== generation) return;
answers = results;
failure = null;
} catch (cause) {
if (controller.signal.aborted || mine !== generation) return;
answers = [];
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
if (mine === generation) loading = false;
}
}
function schedule(text: string): void {
cancel();
const mine = (generation += 1);
if (text.trim() === '') {
answers = [];
failure = null;
loading = false;
return;
}
timer = setTimeout(() => {
timer = null;
void run(text, mine);
}, DEBOUNCE_MS);
}
/** The palette as it should be drawn right now. */
function current(): Palette {
if (query.trim() === '') return buildEntryPalette(entries, { perSection: PALETTE_ENTRY_ROWS });
return buildSearchPalette(answers, parseFlowQuery(query));
}
export const palette = {
get query(): string {
return query;
},
set query(next: string) {
if (next === query) return;
query = next;
selected = 0;
schedule(next);
},
get open(): boolean {
return open;
},
get loading(): boolean {
return loading;
},
get failure(): string | null {
return failure;
},
get view(): Palette {
return current();
},
get selected(): number {
return selected;
},
get selectedItem(): PaletteItem | null {
const items = current().items;
return items[Math.min(selected, items.length - 1)] ?? null;
},
/** True while a typed query has no answer yet — the panel says so. */
get pending(): boolean {
return query.trim() !== '' && (loading || timer !== null);
},
show(): void {
open = true;
void loadEntries();
},
hide(): void {
open = false;
},
select(index: number): void {
selected = index;
},
move(delta: number): void {
selected = moveSelection(selected, delta, current().items.length);
},
/** Close and empty the box — what picking a result leaves behind. */
reset(): void {
cancel();
generation += 1;
query = '';
answers = [];
failure = null;
loading = false;
selected = 0;
open = false;
},
/** Load the entry points without opening the panel (the empty screen wants them). */
ensureEntries: loadEntries,
get entries(): WireEntryPoints | null {
return entries;
},
};
+266
View File
@@ -0,0 +1,266 @@
/**
* What the search palette decides, without a browser.
*
* The panel under the input is a flat keyboard list drawn as groups: ↑/↓ walk
* every row in ranked order, and the group headers are captions on top of that
* order rather than a second axis to navigate. So the model here is one
* function — {@link buildPalette} — that turns whatever the palette has (a
* search answer, two of them for a flow question, or the entry points it shows
* when the box is empty) into `sections` for rendering and `items` for the
* keyboard, with `items` being exactly the concatenation of the sections' rows.
*
* Tested in `__tests__/ui-search-model.test.ts`.
*/
import type {
WireEntryPoints,
WireNodeRef,
WireSearch,
WireSearchResult,
} from './api';
import { basename, plural } from './symbol-model';
/* ------------------------------------------------------------ flow query -- */
export interface FlowQuery {
from: string;
to: string;
}
/**
* "how does X reach Y", "X -> Y", "X → Y".
*
* Phase 1 has no Flow view to send this to, but the question is worth
* recognising anyway: someone who types it gets both endpoints looked up
* instead of a search for the whole sentence, which matches nothing. CG-50
* turns the same parse into a computed path.
*/
const FLOW_SENTENCE =
/^\s*(?:how\s+(?:does|do|would|can)\s+)?([\w$.]+)\s+(?:reach|reaches|call|calls|hit|hits|get\s+to|end\s+up\s+(?:in|at))\s+([\w$.]+)\s*\??\s*$/i;
const FLOW_ARROW = /^\s*([\w$.]+)\s*(?:->|→|=>)\s*([\w$.]+)\s*\??\s*$/;
/** The last dotted segment: `Service.load` asks about `load`. */
function lastSegment(name: string): string {
const cut = name.lastIndexOf('.');
return cut < 0 ? name : name.slice(cut + 1);
}
export function parseFlowQuery(query: string): FlowQuery | null {
const match = FLOW_SENTENCE.exec(query) ?? FLOW_ARROW.exec(query);
if (!match) return null;
const from = lastSegment(match[1] as string);
const to = lastSegment(match[2] as string);
if (!from || !to || from === to) return null;
return { from, to };
}
/* ----------------------------------------------------------------- rows -- */
export type PaletteItem =
| { type: 'symbol'; id: string; node: WireNodeRef; name: string; meta: string; location: string }
| { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null };
export interface PaletteSection {
/** Sentence-case caption, e.g. "Methods", "Files that run something". */
title: string;
/** A second line under the caption, when the group needs explaining. */
note?: string;
items: PaletteItem[];
}
export interface Palette {
sections: PaletteSection[];
/** Every row, in the order ↑/↓ walks them. */
items: PaletteItem[];
/** A sentence above the sections — the flow-question note, when there is one. */
hint: string | null;
/** Nothing to show, and why. Null when there is something. */
empty: string | null;
}
/** Plural caption for a kind bucket: "Methods", "Type aliases", "Files". */
export function kindGroupTitle(kind: string, count: number): string {
const word = kind.replace(/_/g, ' ');
const many = word.endsWith('s') ? `${word}es` : `${word}s`;
const title = count === 1 ? word : many;
return title.charAt(0).toUpperCase() + title.slice(1);
}
/**
* `tools.ts:412` — where the symbol is, short enough for the right column.
*
* A file's location is its DIRECTORY, because its name column is already the
* basename: printing the path twice tells a reader nothing and pushes the row
* past the panel's width on any deeply-nested file.
*/
export function locationOf(node: WireNodeRef): string {
if (node.kind !== 'file') return `${basename(node.file)}:${node.line}`;
const cut = node.file.lastIndexOf('/');
return cut < 0 ? 'project root' : node.file.slice(0, cut);
}
function symbolItem(node: WireNodeRef, meta = ''): PaletteItem {
return {
type: 'symbol',
id: node.id,
node,
name: node.kind === 'file' ? basename(node.file) : node.name,
meta: meta || signatureOf(node),
location: locationOf(node),
};
}
/** The signature, trimmed to something that fits one row. */
function signatureOf(node: WireNodeRef): string {
if (!node.signature) return '';
const oneLine = node.signature.replace(/\s+/g, ' ').trim();
return oneLine.length > 72 ? `${oneLine.slice(0, 71)}` : oneLine;
}
/* --------------------------------------------------------------- search -- */
/**
* Interleave two answers, keeping each one's rank.
*
* A flow question names two symbols and both matter equally, so taking the
* first of each before the second of either is the only merge that does not
* quietly rank one endpoint above the other. Duplicates (a symbol that matched
* both halves) keep their earliest position.
*/
export function interleaveResults(
a: readonly WireSearchResult[],
b: readonly WireSearchResult[]
): WireSearchResult[] {
const merged: WireSearchResult[] = [];
const seen = new Set<string>();
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
for (const list of [a, b]) {
const item = list[i];
if (item && !seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
}
return merged;
}
/**
* Group results by kind, a group appearing where its best result did.
*
* The same rule the server uses, re-applied here because a flow question merges
* two answers and the merged order is not the order either of them shipped.
*/
export function groupByKind(results: readonly WireSearchResult[]): PaletteSection[] {
const sections: PaletteSection[] = [];
const byKind = new Map<string, PaletteSection>();
for (const result of results) {
let section = byKind.get(result.kind);
if (!section) {
section = { title: '', items: [] };
byKind.set(result.kind, section);
sections.push(section);
}
section.items.push(symbolItem(result));
}
for (const [kind, section] of byKind) section.title = kindGroupTitle(kind, section.items.length);
return sections;
}
export function buildSearchPalette(
answers: readonly WireSearch[],
flow: FlowQuery | null
): Palette {
const results =
answers.length > 1
? interleaveResults(answers[0]?.results.items ?? [], answers[1]?.results.items ?? [])
: answers[0]?.results.items ?? [];
const sections = groupByKind(results);
const items = sections.flatMap((section) => section.items);
const hint = flow
? `Reading the path between two symbols arrives with the Flow view. Here is what ${flow.from} and ${flow.to} name.`
: null;
return {
sections,
items,
hint,
empty: items.length === 0 ? 'No symbol or file in the index matches that.' : null,
};
}
/* ---------------------------------------------------------- entry points -- */
/**
* Where to start reading — the palette's resting state and the empty screen.
*
* The three sections say what they are derived from rather than asserting that
* a file IS the entry point: "runs something at module level" is a fact about
* the graph, "this is the main file" would be a guess.
*/
export function buildEntryPalette(
entries: WireEntryPoints | null,
opts: { perSection?: number } = {}
): Palette {
if (!entries) return { sections: [], items: [], hint: null, empty: null };
const cap = opts.perSection ?? Number.POSITIVE_INFINITY;
const take = <T>(items: readonly T[]): T[] =>
Number.isFinite(cap) ? items.slice(0, cap) : [...items];
const sections: PaletteSection[] = [];
if (entries.routes.routed && entries.routes.items.length > 0) {
sections.push({
title: 'Routes',
note: 'A request from outside arrives here.',
items: take(entries.routes.items).map((route) => ({
type: 'route' as const,
id: `route:${route.url}:${route.file}:${route.line}`,
url: route.url,
handler: route.handler,
location: `${basename(route.file)}:${route.line}`,
nodeId: route.handlerId,
})),
});
}
if (entries.files.items.length > 0) {
sections.push({
title: 'Files that run something',
note: 'Statements at the top level of the file — a CLI, a worker entry, a script.',
items: take(entries.files.items).map((file) =>
symbolItem(file, `${plural(file.calls, 'call')} at module level · reaches ${plural(file.reaches, 'file')}`)
),
});
}
if (entries.hubs.items.length > 0) {
sections.push({
title: 'Most depended on',
note: 'The symbols a change radiates furthest from.',
items: take(entries.hubs.items).map((hub) =>
symbolItem(hub, `${plural(hub.dependents, 'dependent')}`)
),
});
}
return {
sections,
items: sections.flatMap((section) => section.items),
hint: null,
empty:
sections.length === 0
? 'This index has no routes, no file that runs anything, and nothing depended on yet.'
: null,
};
}
/* ------------------------------------------------------------- keyboard -- */
/** Wrap-around ↑/↓ over the flat item list. */
export function moveSelection(index: number, delta: number, length: number): number {
if (length === 0) return 0;
return (((index + delta) % length) + length) % length;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* The trail's wire format — the part with no state in it.
*
* Split out of `trail.svelte.ts` so it can be tested without a Svelte runtime:
* the round-trip through the URL is the whole reason the trail is shareable,
* and it is the one part of the trail that can be silently wrong.
*
* Encoding: comma-separated tokens, each `<dir><encoded id>` where dir is
* `s` (start) | `d` (stepped down, into a call) | `u` (stepped up, to a
* caller). The dir char is ALWAYS present — an id may itself begin with 'd'
* or 'u' (`union:…`), so an optional prefix would be ambiguous.
*/
export type HopDirection = 'start' | 'down' | 'up';
export interface TrailHop {
id: string;
/** null until the node is fetched; render `hopLabel()` rather than this. */
name: string | null;
kind: string | null;
dir: HopDirection;
}
const DIR_TO_CHAR: Record<HopDirection, string> = { start: 's', down: 'd', up: 'u' };
const CHAR_TO_DIR: Record<string, HopDirection> = { s: 'start', d: 'down', u: 'up' };
/**
* A readable stand-in for a hop whose name has not been resolved yet.
*
* Only ever seen for a moment: a cold load asks `/api/nodes` for the names of
* every hop it restored from the URL. It still has to be readable, because a
* slow answer would otherwise put a 32-character hash in the trail bar.
*/
export function hopLabel(hop: TrailHop): string {
if (hop.name) return hop.name;
const body = hop.id.includes(':') ? hop.id.slice(hop.id.indexOf(':') + 1) : hop.id;
// Path-shaped ids (`file:src/mcp/tools.ts`) read best as their basename.
const basename = body.slice(body.lastIndexOf('/') + 1);
if (basename.length === 0 || basename.length > 40) return `${body.slice(0, 8)}`;
// A content hash is not a name: shown whole it is a wall of hex wide enough
// to push the rest of the trail off screen.
if (/^[0-9a-f]{16,}$/.test(basename)) return `${basename.slice(0, 8)}`;
return basename;
}
export function encodeTrail(hops: readonly TrailHop[]): string {
return hops.map((h) => DIR_TO_CHAR[h.dir] + encodeURIComponent(h.id)).join(',');
}
export function decodeTrail(encoded: string | null): TrailHop[] {
if (!encoded) return [];
const hops: TrailHop[] = [];
for (const token of encoded.split(',')) {
if (token.length < 2) continue;
const dir = CHAR_TO_DIR[token[0] as string];
if (!dir) continue;
let id: string;
try {
id = decodeURIComponent(token.slice(1));
} catch {
id = token.slice(1);
}
if (id) hops.push({ id, name: null, kind: null, dir });
}
return hops;
}
+62 -50
View File
@@ -4,60 +4,38 @@
* Hops live in memory (they carry names and kinds, which the URL cannot),
* and are mirrored into the `t` query param so a reload or a shared link
* still reproduces the walk. On a cold load only the ids survive; names are
* filled in by `resolve()` as each hop's node is fetched.
*
* Encoding: comma-separated tokens, each `<dir><encoded id>` where dir is
* `s` (start) | `d` (stepped down, into a call) | `u` (stepped up, to a
* caller). The dir char is ALWAYS present — an id may itself begin with 'd'
* or 'u' (`union:…`), so an optional prefix would be ambiguous.
* filled in by `resolve()` as each hop's node is fetched. The wire format
* itself lives in `trail-codec.ts`, where it can be tested without a runtime.
*/
export type HopDirection = 'start' | 'down' | 'up';
import { fetchNodeRefs } from './api';
import { encodeTrail, decodeTrail, type HopDirection, type TrailHop } from './trail-codec';
export interface TrailHop {
id: string;
/** null until the node is fetched; render `hopLabel()` rather than this. */
name: string | null;
kind: string | null;
dir: HopDirection;
}
const DIR_TO_CHAR: Record<HopDirection, string> = { start: 's', down: 'd', up: 'u' };
const CHAR_TO_DIR: Record<string, HopDirection> = { s: 'start', d: 'down', u: 'up' };
/** A readable stand-in for a hop whose name has not been resolved yet. */
export function hopLabel(hop: TrailHop): string {
if (hop.name) return hop.name;
const body = hop.id.includes(':') ? hop.id.slice(hop.id.indexOf(':') + 1) : hop.id;
// Path-shaped ids (`file:src/mcp/tools.ts`) read best as their basename.
const basename = body.slice(body.lastIndexOf('/') + 1);
return basename.length > 0 && basename.length <= 40 ? basename : `${body.slice(0, 8)}`;
}
export function encodeTrail(hops: readonly TrailHop[]): string {
return hops.map((h) => DIR_TO_CHAR[h.dir] + encodeURIComponent(h.id)).join(',');
}
export function decodeTrail(encoded: string | null): TrailHop[] {
if (!encoded) return [];
const hops: TrailHop[] = [];
for (const token of encoded.split(',')) {
if (token.length < 2) continue;
const dir = CHAR_TO_DIR[token[0] as string];
if (!dir) continue;
let id: string;
try {
id = decodeURIComponent(token.slice(1));
} catch {
id = token.slice(1);
}
if (id) hops.push({ id, name: null, kind: null, dir });
}
return hops;
}
export { encodeTrail, decodeTrail, hopLabel } from './trail-codec';
export type { HopDirection, TrailHop } from './trail-codec';
let hops = $state<TrailHop[]>([]);
/**
* Every name this session has learned, by id.
*
* The hop objects cannot carry it: truncating the trail throws them away, and
* walking back through history rebuilds the dropped hops from the URL, which
* holds ids and nothing else. Without this cache the bar would re-fetch — or,
* worse, redraw a hash for a symbol it had already named a second ago.
*/
const known = new Map<string, { name: string | null; kind: string | null }>();
function remember(id: string, info: { name?: string | null; kind?: string | null }): void {
// Nothing to remember is not an entry: an empty one would read as "already
// known" and stop the bar from ever asking for the name.
if (!info.name && !info.kind) return;
const at = known.get(id) ?? { name: null, kind: null };
if (info.name) at.name = info.name;
if (info.kind) at.kind = info.kind;
known.set(id, at);
}
export const trail = {
get hops(): readonly TrailHop[] {
return hops;
@@ -75,6 +53,7 @@ export const trail = {
* loop — the trail is a path, not a history.
*/
push(hop: { id: string; name?: string | null; kind?: string | null; dir?: HopDirection }): void {
remember(hop.id, hop);
const existing = hops.findIndex((h) => h.id === hop.id);
if (existing >= 0) {
hops = hops.slice(0, existing + 1);
@@ -102,6 +81,7 @@ export const trail = {
/** Fill in the name/kind of a hop once its node has been fetched. */
resolve(id: string, info: { name?: string | null; kind?: string | null }): void {
remember(id, info);
const hop = hops.find((h) => h.id === id);
if (!hop) return;
if (info.name) hop.name = info.name;
@@ -116,11 +96,43 @@ export const trail = {
hydrate(encoded: string | null): void {
const decoded = decodeTrail(encoded);
if (encodeTrail(decoded) === encodeTrail(hops)) return;
// Keep any names already resolved for ids that survive the change.
const known = new Map(hops.filter((h) => h.name).map((h) => [h.id, h]));
// Names survive the change — including for hops this trail dropped earlier
// and history has just brought back.
hops = decoded.map((h) => {
const seen = known.get(h.id);
return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
});
},
};
/**
* Give the hops restored from a URL their names back.
*
* A trail travels as ids, so a shared or reloaded link arrives with every hop
* but the one on screen unnamed — and `hopLabel` then draws a hash. One batched
* request fixes the whole bar. Ids that name nothing are marked resolved with
* the label they already had, so a stale link asks once and not on every
* re-render.
*/
const nameless = new Set<string>();
export async function resolveTrailNames(): Promise<void> {
const unknown = hops
.filter((hop) => !hop.name && !known.get(hop.id)?.name && !nameless.has(hop.id))
.map((hop) => hop.id);
if (unknown.length === 0) return;
try {
const { items, missing } = await fetchNodeRefs(unknown);
for (const node of items) {
trail.resolve(node.id, {
name: node.kind === 'file' ? node.file : node.name,
kind: node.kind,
});
}
// An id this index does not hold is a stale link. Recorded so the bar asks
// once rather than on every redraw.
for (const id of missing) nameless.add(id);
} catch {
// A name is a nicety; the hop still navigates without one.
}
}