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
@@ -1987,6 +1987,94 @@ export class QueryBuilder {
return rows;
}
/**
* The graph's executable roots — files that RUN something at module level,
* ranked by how much of the project they set in motion.
*
* The engine records a statement at the top level of a file as an edge from
* the *file* node, so `src/bin/codegraph.ts` calling `program.parse()` at
* module scope is a `calls` edge out of a `file`. That set is what makes the
* roots of a dependency graph visible: a library module holds definitions and
* runs nothing until someone imports it, while a CLI, a worker entry or a
* build script does its work on the way down the file. `instantiates` counts
* the same way — `new Server(...)` at module scope is the same act.
*
* Ranking multiplies the two things an entry point does: it runs (calls), and
* it wires the project together (distinct other files its symbols reach). One
* alone is misleading — a registration table makes hundreds of module-level
* calls into itself, and a barrel file imports everything and runs nothing.
* The product puts the file that does both at the top.
*/
getTopCallingFiles(
limit: number
): Array<{ nodeId: string; filePath: string; calls: number; reaches: number; score: number }> {
if (limit <= 0) return [];
return this.db
.prepare(
`WITH runs AS (
SELECT e.source AS id, COUNT(*) AS calls
FROM edges e
JOIN nodes n ON n.id = e.source
WHERE n.kind = 'file' AND e.kind IN ('calls', 'instantiates')
GROUP BY e.source
),
cand AS (
SELECT r.id AS id, n.file_path AS fp, r.calls AS calls
FROM runs r JOIN nodes n ON n.id = r.id
),
wires AS (
SELECT sn.file_path AS fp, COUNT(DISTINCT tn.file_path) AS reaches
FROM edges e
JOIN nodes sn ON sn.id = e.source
JOIN nodes tn ON tn.id = e.target
WHERE e.kind != 'contains'
AND sn.file_path <> tn.file_path
AND sn.file_path IN (SELECT fp FROM cand)
GROUP BY sn.file_path
)
SELECT c.id AS nodeId,
c.fp AS filePath,
c.calls AS calls,
COALESCE(w.reaches, 0) AS reaches,
c.calls * (1 + COALESCE(w.reaches, 0)) AS score
FROM cand c LEFT JOIN wires w ON w.fp = c.fp
ORDER BY score DESC, calls DESC, filePath
LIMIT ?`
)
.all(limit) as Array<{
nodeId: string;
filePath: string;
calls: number;
reaches: number;
score: number;
}>;
}
/**
* How many OTHER files depend on each of the given files.
*
* Counted through the symbols, not the file nodes: an `imports` edge points
* at the imported symbol, so a file node almost never receives one and
* counting edges into it would report every file as depended on by nobody.
* Same-file edges are excluded, which is what makes zero mean "nothing else
* in the index reaches into this file" — the honest reading of a root.
*/
getFileDependentCounts(filePaths: string[]): Array<{ filePath: string; dependents: number }> {
if (filePaths.length === 0) return [];
return this.db
.prepare(
`SELECT tn.file_path AS filePath, COUNT(DISTINCT sn.file_path) AS dependents
FROM edges e
JOIN nodes tn ON tn.id = e.target
JOIN nodes sn ON sn.id = e.source
WHERE e.kind != 'contains'
AND tn.file_path IN (SELECT value FROM json_each(?))
AND sn.file_path <> tn.file_path
GROUP BY tn.file_path`
)
.all(JSON.stringify(filePaths)) as Array<{ filePath: string; dependents: number }>;
}
/**
* References recorded against a symbol that never resolved to a node — the
* calls and type mentions that leave the index (a third-party package, a
+23
View File
@@ -1377,6 +1377,29 @@ export class CodeGraph {
return this.queries.getTopDependedOn(limit);
}
/**
* The graph's executable roots — files that run something at module level (a
* CLI, a worker entry, a script), ranked by calls x the number of other files
* they reach. A statement at the top level of a file is recorded as an edge
* out of the *file* node, which is what makes these visible at all.
*/
getTopCallingFiles(
limit: number
): Array<{ nodeId: string; filePath: string; calls: number; reaches: number; score: number }> {
return this.queries.getTopCallingFiles(limit);
}
/**
* How many other files depend on each of the given files, counted through
* their symbols (an `imports` edge points at the symbol, not the file).
* A zero means nothing else in the index reaches into that file.
*/
getFileDependentCounts(filePaths: string[]): Map<string, number> {
return new Map(
this.queries.getFileDependentCounts(filePaths).map((row) => [row.filePath, row.dependents])
);
}
/**
* References from a symbol that never resolved to an indexed node — the
* calls and type mentions that leave the index. Lets a reader account for
+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 };
}