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
+79 -9
View File
@@ -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">