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