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:
co-authored by
Claude Opus 5
parent
e9596af1cf
commit
87afc50e76
+10
-4
@@ -9,11 +9,9 @@
|
||||
import FlowView from './views/FlowView.svelte';
|
||||
import NotFoundView from './views/NotFoundView.svelte';
|
||||
import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
|
||||
import { trail } from './lib/trail.svelte';
|
||||
import { trail, resolveTrailNames } from './lib/trail.svelte';
|
||||
import { project } from './lib/project.svelte';
|
||||
|
||||
let query = $state('');
|
||||
|
||||
// One `/api/stats` for the whole app: the top bar's counts and the Symbol
|
||||
// view's blast-radius denominator come out of the same payload.
|
||||
$effect(() => {
|
||||
@@ -37,6 +35,14 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Hops restored from a URL carry ids and nothing else; one batched request
|
||||
// turns the bar back into names. Runs after every trail change, and does
|
||||
// nothing when every hop already has one.
|
||||
$effect(() => {
|
||||
void trail.hops.length;
|
||||
void resolveTrailNames();
|
||||
});
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
@@ -84,7 +90,7 @@
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<TopBar bind:this={topbar} bind:query project={project.name} stats={project.summary} />
|
||||
<TopBar bind:this={topbar} project={project.name} stats={project.summary} />
|
||||
<TrailBar />
|
||||
<main>
|
||||
{#if route.view === 'symbol'}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The rows of a palette — shared by the panel under the search box and the
|
||||
* empty screen's "where to start" list, because they are the same rows and a
|
||||
* second copy would drift.
|
||||
*
|
||||
* Selection is passed in rather than owned here: in the panel it belongs to
|
||||
* the keyboard, on the empty screen there is none.
|
||||
*/
|
||||
import KindGlyph from './KindGlyph.svelte';
|
||||
import type { Palette, PaletteItem } from '../lib/search-model';
|
||||
|
||||
interface Props {
|
||||
palette: Palette;
|
||||
/** Index into `palette.items`, or -1 for no keyboard selection. */
|
||||
selected?: number;
|
||||
/**
|
||||
* Set to 'option' when these rows sit inside a listbox (the search panel).
|
||||
* Left off on the empty screen, where they are just links: `role="option"`
|
||||
* outside a listbox is a lie a screen reader acts on.
|
||||
*/
|
||||
rowRole?: 'option' | undefined;
|
||||
/** Prefix for each row's DOM id, so a combobox can point at the selected one. */
|
||||
idPrefix?: string;
|
||||
onpick: (item: PaletteItem) => void;
|
||||
onhover?: (index: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
palette,
|
||||
selected = -1,
|
||||
rowRole = undefined,
|
||||
idPrefix = 'palette-row',
|
||||
onpick,
|
||||
onhover,
|
||||
}: Props = $props();
|
||||
|
||||
/** Running index into the flat item list, so a row knows its keyboard position. */
|
||||
function flatIndex(sectionIndex: number, rowIndex: number): number {
|
||||
let base = 0;
|
||||
for (let i = 0; i < sectionIndex; i += 1) base += palette.sections[i]?.items.length ?? 0;
|
||||
return base + rowIndex;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each palette.sections as section, s (section.title)}
|
||||
<div class="head">
|
||||
<span class="head-title">{section.title}</span>
|
||||
{#if section.note}<span class="head-note">{section.note}</span>{/if}
|
||||
</div>
|
||||
{#each section.items as item, r (item.id)}
|
||||
{@const index = flatIndex(s, r)}
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
class:sel={index === selected}
|
||||
data-palette-row={index}
|
||||
id={`${idPrefix}-${index}`}
|
||||
role={rowRole}
|
||||
aria-selected={rowRole ? index === selected : undefined}
|
||||
onmousedown={(event) => {
|
||||
// mousedown, not click: the input's blur would close the panel first.
|
||||
event.preventDefault();
|
||||
onpick(item);
|
||||
}}
|
||||
onmouseenter={() => onhover?.(index)}
|
||||
>
|
||||
{#if item.type === 'route'}
|
||||
<KindGlyph kind="route" />
|
||||
<span class="mid">
|
||||
<span class="nm">{item.url}</span>
|
||||
<span class="sig">{item.handler}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<KindGlyph kind={item.node.kind} />
|
||||
<span class="mid">
|
||||
<span class="nm">{item.name}</span>
|
||||
{#if item.meta}<span class="sig">{item.meta}</span>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="loc">{item.location}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 4px;
|
||||
border-bottom: 1px solid var(--rule-faint);
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.head-note {
|
||||
overflow: hidden;
|
||||
color: var(--ink-4);
|
||||
font-size: 11.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
align-items: baseline;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--rule-faint);
|
||||
color: var(--ink);
|
||||
gap: 10px;
|
||||
grid-template-columns: 18px 1fr auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
.row.sel {
|
||||
background: var(--press);
|
||||
}
|
||||
|
||||
.mid {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nm {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sig {
|
||||
margin-left: 6px;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.loc {
|
||||
color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The results panel under the search box (design spec §3.7).
|
||||
*
|
||||
* It renders whatever `palette.view` is: the entry points when the box is
|
||||
* empty, the ranked kind groups when it is not. The keyboard lives in
|
||||
* `TopBar` (the keys are pressed in the input, not here) and arrives as the
|
||||
* `selected` index; this component's only job beyond drawing is keeping that
|
||||
* row in view when the selection moves past the panel's edge.
|
||||
*/
|
||||
import PaletteRows from './PaletteRows.svelte';
|
||||
import { palette } from '../lib/palette.svelte';
|
||||
import type { PaletteItem } from '../lib/search-model';
|
||||
|
||||
interface Props {
|
||||
onpick: (item: PaletteItem) => void;
|
||||
}
|
||||
|
||||
let { onpick }: Props = $props();
|
||||
|
||||
let panel: HTMLDivElement | null = $state(null);
|
||||
let view = $derived(palette.view);
|
||||
|
||||
$effect(() => {
|
||||
const index = palette.selected;
|
||||
if (!panel) return;
|
||||
const row = panel.querySelector(`[data-palette-row="${index}"]`);
|
||||
row?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="panel" bind:this={panel} id="palette-panel" role="listbox" aria-label="Search results">
|
||||
{#if view.hint}
|
||||
<p class="hint">{view.hint}</p>
|
||||
{/if}
|
||||
|
||||
<PaletteRows
|
||||
palette={view}
|
||||
selected={palette.selected}
|
||||
rowRole="option"
|
||||
{onpick}
|
||||
onhover={(index) => palette.select(index)}
|
||||
/>
|
||||
|
||||
{#if palette.failure}
|
||||
<p class="note">{palette.failure}</p>
|
||||
{:else if palette.pending && view.items.length === 0}
|
||||
<p class="note">Searching…</p>
|
||||
{:else if view.empty}
|
||||
<p class="note">{view.empty}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: 32px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--rule-faint);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { router, mapHref, flowHref, symbolHref } from '../lib/router.svelte';
|
||||
import { trail } from '../lib/trail.svelte';
|
||||
import { palette } from '../lib/palette.svelte';
|
||||
import SearchPalette from './SearchPalette.svelte';
|
||||
import type { PaletteItem } from '../lib/search-model';
|
||||
import { walkTo } from '../lib/walk';
|
||||
|
||||
interface Props {
|
||||
/** Indexed project name, e.g. "codegraph/". Null until stats load. */
|
||||
project?: string | null;
|
||||
/** "13,060 symbols · 46,004 edges · 593 files indexed". Null until loaded. */
|
||||
stats?: string | null;
|
||||
query?: string;
|
||||
/** Results panel, owned by the search palette (CG-45). */
|
||||
palette?: Snippet;
|
||||
}
|
||||
|
||||
let { project = null, stats = null, query = $bindable(''), palette }: Props = $props();
|
||||
let { project = null, stats = null }: Props = $props();
|
||||
|
||||
let input: HTMLInputElement | null = $state(null);
|
||||
|
||||
@@ -31,13 +31,75 @@
|
||||
export function focusSearch(): void {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
palette.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Following a result is a `start` hop, never `down` or `up`: nothing on
|
||||
* screen was stepped through to get there, and claiming a direction would
|
||||
* put a `→` in the trail that describes no call.
|
||||
*/
|
||||
export function pick(item: PaletteItem): void {
|
||||
const id = item.type === 'route' ? item.nodeId : item.id;
|
||||
// A route whose handler never resolved to a node has nowhere to go; the
|
||||
// row stays, because "this URL exists and we could not place it" is true.
|
||||
if (!id) return;
|
||||
palette.reset();
|
||||
input?.blur();
|
||||
walkTo(
|
||||
item.type === 'route'
|
||||
? { id, name: item.handler, kind: null }
|
||||
: { id, name: item.node.name, kind: item.node.kind },
|
||||
'start'
|
||||
);
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') input?.blur();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
palette.hide();
|
||||
input?.blur();
|
||||
return;
|
||||
}
|
||||
if (!palette.open) {
|
||||
// Any other key means the box is being used again after a dismissal.
|
||||
if (event.key !== 'Tab') palette.show();
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
palette.move(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
palette.move(-1);
|
||||
break;
|
||||
case 'Enter': {
|
||||
event.preventDefault();
|
||||
const item = palette.selectedItem;
|
||||
if (item) pick(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A click anywhere else closes the panel. `mousedown` on a row calls
|
||||
* `preventDefault`, so picking a result never races this.
|
||||
*/
|
||||
function onpointerdown(event: PointerEvent) {
|
||||
if (!palette.open) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Node && searchBox?.contains(target)) return;
|
||||
palette.hide();
|
||||
}
|
||||
|
||||
let searchBox: HTMLDivElement | null = $state(null);
|
||||
</script>
|
||||
|
||||
<svelte:window {onpointerdown} />
|
||||
|
||||
<header class="topbar">
|
||||
<a class="brand" href="#/" aria-label="CodeGraph home">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
@@ -51,19 +113,27 @@
|
||||
<a href={flowHref()} class:active={view === 'flow'}>Flow</a>
|
||||
</nav>
|
||||
|
||||
<div class="search" role="search">
|
||||
<div class="search" role="search" bind:this={searchBox}>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={query}
|
||||
bind:value={palette.query}
|
||||
{onkeydown}
|
||||
onfocus={() => palette.show()}
|
||||
id="q"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder={'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus'}
|
||||
aria-label="Search symbols and files"
|
||||
role="combobox"
|
||||
aria-expanded={palette.open}
|
||||
aria-controls="palette-panel"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={palette.open ? `palette-row-${palette.selected}` : undefined}
|
||||
/>
|
||||
{@render palette?.()}
|
||||
{#if palette.open}
|
||||
<SearchPalette onpick={pick} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="project" title="Indexed project">
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
|
||||
import { navigate, symbolHref, flowHref } from '../lib/router.svelte';
|
||||
|
||||
/**
|
||||
* "Read as flow" replays the trail as a computed path in the Flow view,
|
||||
* which is phase 2 (CG-50). The control is built and wired; it stays hidden
|
||||
* until there is a view to send it to, because a button that lands on a
|
||||
* placeholder is worse than no button.
|
||||
*/
|
||||
const READ_AS_FLOW = false;
|
||||
|
||||
let hops = $derived(trail.hops);
|
||||
|
||||
function step(index: number) {
|
||||
@@ -17,9 +25,23 @@
|
||||
navigate(flowHref(encodeTrail(hops)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the path, keep the place.
|
||||
*
|
||||
* Emptying the trail while you are reading a symbol would also throw the
|
||||
* symbol away, which is not what "Clear" says. It restarts the trail at
|
||||
* where you are — one `start` hop — and only leaves for the empty screen
|
||||
* when there is nowhere to stay.
|
||||
*/
|
||||
function clear() {
|
||||
const here = trail.current;
|
||||
trail.clear();
|
||||
navigate('#/');
|
||||
if (!here) {
|
||||
navigate('#/');
|
||||
return;
|
||||
}
|
||||
trail.push({ id: here.id, name: here.name, kind: here.kind, dir: 'start' });
|
||||
navigate(symbolHref(here.id, { trail: encodeTrail(trail.hops) }), { replace: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -27,7 +49,10 @@
|
||||
<span class="label">Trail</span>
|
||||
|
||||
{#if hops.length === 0}
|
||||
<span class="empty">Follow a call and the path you walked shows up here.</span>
|
||||
<span class="empty"
|
||||
>Step into a call on the right, or up to a caller on the left — the trail records the
|
||||
path.</span
|
||||
>
|
||||
{:else}
|
||||
{#each hops as hop, i (hop.id)}
|
||||
{#if i > 0}
|
||||
@@ -59,7 +84,7 @@
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
{#if hops.length > 1}
|
||||
{#if READ_AS_FLOW && hops.length > 1}
|
||||
<button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
|
||||
{/if}
|
||||
{#if hops.length > 0}
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
class="row"
|
||||
class:origin={isOrigin}
|
||||
class:sel={railFocus.at('left', indexOf(groupIndex, rowIndex))}
|
||||
data-target={node.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title={rowTitle(row)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The empty screen — and the answer to "where do I start".
|
||||
*
|
||||
* Nothing selected is the normal first state of a viewer opened on a project
|
||||
* nobody has read before, so it carries the same entry points the palette
|
||||
* shows at rest, at full length: the routes a request arrives on, the files
|
||||
* that run something at module level, and the symbols the most code depends
|
||||
* on. Every one of them is derived from the graph — see
|
||||
* `src/ui-server/api/entrypoints.ts` for what each is derived from.
|
||||
*/
|
||||
import PaletteRows from '../components/PaletteRows.svelte';
|
||||
import { palette } from '../lib/palette.svelte';
|
||||
import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
|
||||
import { walkTo } from '../lib/walk';
|
||||
|
||||
interface Props {
|
||||
project?: string | null;
|
||||
}
|
||||
let { project = null }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
void palette.ensureEntries();
|
||||
});
|
||||
|
||||
let entries = $derived(buildEntryPalette(palette.entries));
|
||||
|
||||
function pick(item: PaletteItem) {
|
||||
const id = item.type === 'route' ? item.nodeId : item.id;
|
||||
if (!id) return;
|
||||
walkTo(
|
||||
item.type === 'route'
|
||||
? { id, name: item.handler, kind: null }
|
||||
: { id, name: item.node.name, kind: item.node.kind },
|
||||
'start'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
@@ -17,6 +49,15 @@
|
||||
what it calls on the right — each callee lined up with the line that makes the call.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if entries.sections.length > 0}
|
||||
<section class="entries" aria-label="Where to start">
|
||||
<h3>Where to start</h3>
|
||||
<div class="rows">
|
||||
<PaletteRows palette={entries} onpick={pick} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -24,4 +65,25 @@
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* `.emptystate` itself is global (app.css) and shared with the other views;
|
||||
only its bottom padding changes here, to sit against the list below. */
|
||||
.scroll :global(.emptystate) {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.entries {
|
||||
max-width: 720px;
|
||||
padding: 8px 40px 48px;
|
||||
}
|
||||
|
||||
.entries h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rows {
|
||||
border: 1px solid var(--rule-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user