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
+150
View File
@@ -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>