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
+184
View File
@@ -0,0 +1,184 @@
/**
* `GET /api/entrypoints` — where to start reading a project you have never
* opened.
*
* The empty state and the resting search palette both have the same problem:
* a graph of thirteen thousand symbols and no obvious door. Three answers,
* every one of them derived from the graph rather than from a filename
* convention:
*
* - **Routes** — a request arriving from outside is the most literal entry a
* codebase has. Straight from the routing manifest (`/api/routes`), and
* absent for a project that is not a routed app.
* - **Files that run something** — the engine records a statement at the top
* level of a file as an edge out of the *file* node, so a CLI, a worker
* entry or a build script has `calls` where a library module has none. That
* is what makes `src/bin/codegraph.ts` the root of this repo's CLI flow.
* Ranked by calls x how many other files they reach, so the file that both
* runs and wires the project together outranks a registration table that
* makes a hundred module-level calls into itself.
* - **Hubs** — the most depended-on symbols. Not an entry in the "runs first"
* sense; an entry in the sense that reading one tells you the most about
* what the project is made of, and a change to one radiates furthest.
*
* Tests and fixtures are excluded from both derived lists. They are real code
* with real callers, but "where do I start reading" never means a test.
*/
import type { CodeGraph } from '../../index';
import type { Node, NodeKind } from '../../types';
import { intParam } from './respond';
import { buildRoutes } from './routes';
import { isTestFile } from '../../search/query-utils';
import { toNodeRef, wireList, type WireList, type WireNodeRef } from './wire';
/** Rows per derived list, and the default for `limit`. */
const DEFAULT_LIMIT = 12;
/**
* Ranked rows examined before the test filter and the per-directory cap run.
*
* Fixed rather than a multiple of `limit` so the same project answers with the
* same rows whatever the caller asks for. It also means the `total` on the two
* derived lists is a FLOOR — "at least this many" — because the tests it skips
* are only recognisable in JavaScript (`isTestFile` reads directory shapes and
* CamelCase suffixes that do not survive translation into SQL). That is the
* honest reading, and the viewer prints the rows rather than the count.
*/
const SCAN_ROWS = 400;
/**
* At most this many executable files from any one directory.
*
* Without it a repo with twenty one-off scripts in `scripts/` answers "where do
* I start" with twenty scripts, and the CLI everybody actually wants falls off
* the end. Two keeps a directory represented without letting it own the list.
*/
const MAX_FILES_PER_DIR = 2;
/** Kinds that are never a useful hub row: a mention, a container, or a name. */
const NON_HUB_KINDS: ReadonlySet<NodeKind> = new Set<NodeKind>([
'file',
'import',
'export',
'parameter',
]);
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 {
/** Distinct symbols that depend on this one. */
dependents: number;
}
export interface WireEntryPoints {
routes: {
routed: boolean;
routeCount: number;
items: Array<{ url: string; handler: string; file: string; line: number; handlerId: string | null }>;
};
files: WireList<WireEntryFile>;
hubs: WireList<WireEntryHub>;
}
export function buildEntryPoints(cg: CodeGraph, query: URLSearchParams): WireEntryPoints {
const limit = intParam(query, 'limit', { min: 1, max: 50, default: DEFAULT_LIMIT });
return {
routes: routeEntries(cg, limit),
files: executableFiles(cg, limit),
hubs: hubs(cg, limit),
};
}
/**
* The routing manifest, trimmed to a starting-points list.
*
* `buildRoutes` is reused rather than re-derived so a route row means exactly
* the same thing here as on the routes endpoint — including its handler id,
* which is what makes the row navigable.
*/
function routeEntries(cg: CodeGraph, limit: number): WireEntryPoints['routes'] {
const manifest = buildRoutes(cg, new URLSearchParams()) as {
routed: boolean;
routeCount: number;
entries: WireEntryPoints['routes']['items'];
};
return {
routed: manifest.routed,
routeCount: manifest.routeCount,
items: manifest.entries.slice(0, limit),
};
}
/**
* Files that do something on the way down, most first.
*
* Over-fetched before filtering, because the two things that shrink the list —
* tests and the per-directory cap — are only knowable after the rows come back,
* and a project whose noisiest module-level callers are all test files would
* otherwise answer with an empty list.
*/
function executableFiles(cg: CodeGraph, limit: number): WireList<WireEntryFile> {
const ranked = cg.getTopCallingFiles(SCAN_ROWS);
const kept: Array<{ node: Node; calls: number; reaches: number }> = [];
const perDir = new Map<string, number>();
let eligible = 0;
for (const row of ranked) {
if (isTestFile(row.filePath)) continue;
eligible += 1;
if (kept.length >= limit) continue;
const dir = directoryOf(row.filePath);
const taken = perDir.get(dir) ?? 0;
if (taken >= MAX_FILES_PER_DIR) continue;
const node = cg.getNode(row.nodeId);
if (!node) continue;
perDir.set(dir, taken + 1);
kept.push({ node, calls: row.calls, reaches: row.reaches });
}
const dependents = cg.getFileDependentCounts(kept.map((k) => k.node.filePath));
const items: WireEntryFile[] = kept.map(({ node, calls, reaches }) => ({
...toNodeRef(node),
calls,
reaches,
dependents: dependents.get(node.filePath) ?? 0,
}));
// `eligible` counts every non-test file the scan saw: a floor, never an
// overstatement.
return wireList(items, Math.max(eligible, items.length));
}
/** The most depended-on symbols, tests and non-navigable kinds removed. */
function hubs(cg: CodeGraph, limit: number): WireList<WireEntryHub> {
const ranked = cg.getTopDependedOn(SCAN_ROWS);
const items: WireEntryHub[] = [];
let eligible = 0;
for (const row of ranked) {
const node = cg.getNode(row.nodeId);
if (!node || NON_HUB_KINDS.has(node.kind) || isTestFile(node.filePath)) continue;
eligible += 1;
if (items.length >= limit) continue;
items.push({ ...toNodeRef(node), dependents: row.dependents });
}
return wireList(items, Math.max(eligible, items.length));
}
/** `src/bin/codegraph.ts` -> `src/bin`; a root file -> `.`. */
function directoryOf(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const cut = normalized.lastIndexOf('/');
return cut < 0 ? '.' : normalized.slice(0, cut);
}
+17 -1
View File
@@ -1,7 +1,7 @@
/**
* The read-only JSON API the viewer reads its screens from.
*
* Six endpoints, one per screen, each answering in a single round-trip — the
* Eight endpoints, one per screen, each answering in a single round-trip — the
* same principle as `codegraph_explore`: return enough that the caller does not
* have to ask a follow-up question. Everything here is a *reader* of the
* existing schema; nothing indexes, resolves, or writes.
@@ -10,9 +10,11 @@
* GET /api/stats what this index is and how much to trust it
* GET /api/search?q= the search palette
* GET /api/node/<id> the Symbol view: rails, members, tests, blast radius
* GET /api/nodes?id=&id= names for ids you already have (the trail)
* GET /api/source?file=&from=&to= verbatim source, with a drift verdict
* GET /api/file/<path> the File view: outline and import rails
* GET /api/routes the URL to handler map, when there is one
* GET /api/entrypoints where to start reading: routes, roots, hubs
* ```
*
* It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
@@ -33,10 +35,14 @@ import { buildNode } from './node';
import { buildSource } from './source';
import { buildFile } from './file';
import { buildRoutes } from './routes';
import { buildEntryPoints } from './entrypoints';
import { buildNodeRefs } from './nodes';
export { GraphSession } from './session';
export { ApiError } from './respond';
export * from './wire';
export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints';
export type { WireNodeRefs } from './nodes';
/**
* A mounted API, plus the handle it holds open.
@@ -62,6 +68,7 @@ const API_INDEX = {
{ path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
{ path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
{ path: '/api/node/<id>', description: 'One symbol: callers, callees, members, tests, blast radius.' },
{ path: '/api/nodes', description: 'Names and locations for ids you already have.', params: ['id'] },
{
path: '/api/source',
description: 'Verbatim source for an indexed file, omitted when it has drifted on disk.',
@@ -69,6 +76,11 @@ const API_INDEX = {
},
{ path: '/api/file/<path>', description: 'One file: outline and import rails.' },
{ path: '/api/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
{
path: '/api/entrypoints',
description: 'Where to start reading: routes, files that run something, and hubs.',
params: ['limit'],
},
],
};
@@ -87,6 +99,10 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
return ok(res, buildSearch(session.acquire(), ctx.query), ctx.method);
case '/api/routes':
return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
case '/api/entrypoints':
return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
case '/api/nodes':
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
case '/api/source':
return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
default:
+54
View File
@@ -0,0 +1,54 @@
/**
* `GET /api/nodes?id=…&id=…` — names for ids you already have.
*
* The trail is the reason this exists. It travels in the URL, and a URL can
* only carry ids, so a shared or reloaded six-hop trail arrives as six opaque
* `method:<hash>` strings with nothing to draw. Every other screen learns a
* symbol's name as a side effect of asking for the symbol; the trail never
* asks, because it draws hops it is not looking at.
*
* Deliberately the ref shape (`WireNodeRef`) and not the Symbol view payload:
* six of those would ship six rail sets and six blast radiuses to render six
* words. Ids arrive as repeated `id` parameters rather than one comma-joined
* list — a node id can be a file path, and a file path can contain a comma.
*/
import type { CodeGraph } from '../../index';
import { badRequest } from './respond';
import { toNodeRef, type WireNodeRef } from './wire';
/** Ids per request. A trail long enough to exceed this is not a trail. */
export const MAX_NODE_REFS = 60;
export interface WireNodeRefs {
items: WireNodeRef[];
/** Ids that name nothing in this index — a stale link, not an error. */
missing: string[];
}
export function buildNodeRefs(cg: CodeGraph, query: URLSearchParams): WireNodeRefs {
const ids = query.getAll('id').filter((id) => id !== '');
if (ids.length === 0) {
throw badRequest(
'No ids were given.',
'Use /api/nodes?id=<id>&id=<id> — one `id` parameter per symbol.'
);
}
if (ids.length > MAX_NODE_REFS) {
throw badRequest(`Too many ids: ${ids.length}. At most ${MAX_NODE_REFS} per request.`);
}
const unique = [...new Set(ids)];
const byId = cg.getNodesByIds(unique);
const items: WireNodeRef[] = [];
const missing: string[] = [];
// Answer in the order asked, so the caller never has to re-sort.
for (const id of unique) {
const node = byId.get(id);
if (node) items.push(toNodeRef(node));
else missing.push(id);
}
return { items, missing };
}